Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Develop, install, troubleshoot, and integrate OpenCode plugins for the opencode CLI. Use when: writing new OpenCode plugins, porting Pi SDK extensions to OpenCode, registering custom tools via the plugin API, fixing plugin load failures, resolving module dependency issues in plugins, installing plugins globally or per-project, understanding the plugin hook system, or any task involving @opencode-ai/plugin, opencode plugin command, or opencode.json plugin config. Covers: plugin format, Hooks API,
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 229% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 353% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 157% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 149% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 255% | 0% |
Complete guide for developing, installing, and troubleshooting plugins for the OpenCode CLI (opencode). Validated against OpenCode v1.14.20 and @opencode-ai/plugin v1.14.19.
OpenCode plugins are ESM JavaScript modules that export an async function. The function receives a PluginInput and returns Promise<Hooks>. Plugins register custom tools, hook into the agent lifecycle, and modify behavior at runtime.
Plugin function called once at load time
-> returns Hooks object
-> hooks are called per-event during the sessionmy-plugin/
├── package.json # Must have "type": "module", "main": "index.js"
└── index.js # The plugin code (ESM)Minimal package.json:
json{ "name": "my-opencode-plugin", "version": "1.0.0", "type": "module", "main": "index.js", "peerDependencies": { "@opencode-ai/plugin": ">=1.14.0" } }
javascriptexport default async function MyPlugin(input) { return { // hooks go here }; }
This is a valid plugin that does nothing. Every hook is optional.
The plugin function receives a single argument with these fields:
javascriptexport default async function MyPlugin(input) { input.client // OpenCode SDK client (for server API calls) input.project // Project metadata object input.directory // Project root directory (e.g., "~/my-project") input.worktree // Project worktree root input.serverUrl // URL object for the OpenCode server input.$ // BunShell instance for running shell commands input.experimental_workspace // Workspace registration API }
Key fields you'll use most:
input.directory — the project root. Use this to derive project slugs, locate config files, resolve relative paths.input.$ — BunShell for running commands. Usage: await $\git status\.cwd(dir).quiet().text()input.client — OpenCode SDK client for making API calls to the running server.The plugin returns a Hooks object. Every hook is optional — only implement what you need.
javascriptexport default async function MyPlugin(input) { return { tool: { /* register custom tools */ }, event: async ({ event }) => { /* raw SSE events */ }, "experimental.chat.system.transform": async (input, output) => { /* inject into system prompt */ }, "tool.execute.before": async (input, output) => { /* before tool execution */ }, "tool.execute.after": async (input, output) => { /* after tool execution */ }, "experimental.session.compacting": async (input, output) => { /* before context compaction */ }, "chat.message": async (input, output) => { /* new message received */ }, "chat.params": async (input, output) => { /* modify LLM parameters */ }, "permission.ask": async (input, output) => { /* modify permission behavior */ }, "shell.env": async (input, output) => { /* modify shell environment */ }, }; }
Tools are registered in the tool hook as a map of tool name → tool definition.
tool() HelperImport from @opencode-ai/plugin/tool:
javascriptimport { tool } from "@opencode-ai/plugin/tool";
The tool() function takes an object with description, args, and execute:
javascripttool({ description: "What this tool does. The LLM reads this to decide when to use it.", args: { action: tool.schema.enum(["create", "delete"]), name: tool.schema.string().describe("Name of the thing"), count: tool.schema.number().optional().describe("How many"), }, async execute(args, context) { // args is typed: { action: "create"|"delete", name: string, count?: number } // Return a string or { output: string, metadata?: {...} } return `Created ${args.name}`; }, })
tool.schema is the Zod library (v4). Available types:
tool.schema.string(), tool.schema.number(), tool.schema.boolean()tool.schema.enum(["a", "b", "c"]).optional(), .describe("..."), .default(value)javascriptasync execute(args, ctx) { ctx.sessionID // current session ID ctx.messageID // current message ID ctx.agent // agent name (e.g., "build") ctx.directory // project directory for this session ctx.worktree // worktree root ctx.abort // AbortSignal for cancellation ctx.metadata({ title: "doing X", metadata: { key: "val" } }) // update display }
javascript// Simple string return "Done."; // With metadata return { output: "Created 3 items", metadata: { count: 3, items: [...] } };
When a tool has multiple actions (like "save", "search", "delete"), use an enum parameter and switch on it:
javascripttool({ description: "Manage items. Use 'create' to add, 'list' to view, 'delete' to remove.", args: { action: tool.schema.enum(["create", "list", "delete"]), name: tool.schema.string().optional(), }, async execute(args, ctx) { switch (args.action) { case "create": if (!args.name) return { output: "Error: name required for create", metadata: { error: true } }; // ... create logic return { output: `Created ${args.name}` }; case "list": // ... list logic return { output: "Items: ..." }; case "delete": // ... delete logic return { output: `Deleted ${args.name}` }; } }, })
javascriptreturn { tool: { my_first_tool: tool({ /* ... */ }), my_second_tool: tool({ /* ... */ }), my_third_tool: tool({ /* ... */ }), }, };
Tool names in the map become the tool IDs the LLM sees.
"experimental.chat.system.transform"Inject content into the system prompt every turn. This is how you give the LLM persistent context.
javascript"experimental.chat.system.transform": async (input, output) => { // input.sessionID? — current session (may be undefined) // input.model — the model being used // output.system — string array, push to append output.system.push("<my-context>\nImportant info for the LLM\n</my-context>"); },
"tool.execute.after"Called after every tool execution. Use for logging, extraction, side effects.
javascript"tool.execute.after": async (input, output) => { // input.tool — tool name (e.g., "read", "bash", "my_custom_tool") // input.sessionID — session ID // input.callID — call ID // input.args — the arguments passed to the tool // output.title — displayed title // output.output — the tool's output text // output.metadata — the tool's metadata },
"tool.execute.before"Called before every tool execution. Use for validation, modification, or logging.
javascript"tool.execute.before": async (input, output) => { // input.tool — tool name // input.sessionID — session ID // input.callID — call ID // output.args — you can modify the args before execution },
"experimental.session.compacting"Called before context compaction. Use to persist data that should survive compaction.
javascript"experimental.session.compacting": async (input, output) => { // input.sessionID — session being compacted // output.context — string array, push context strings to preserve // output.prompt — if set, replaces the default compaction prompt entirely output.context.push("Key info to preserve: ..."); },
"event"Raw SSE events. Receives every event the OpenCode server emits.
javascriptevent: async ({ event }) => { // event.type — event type string // event.properties — event payload },
"shell.env"Modify environment variables for shell commands.
javascript"shell.env": async (input, output) => { // input.cwd — working directory // output.env — env var map, modify in place output.env["MY_VAR"] = "my_value"; },
"chat.message"Called when a new user message is received.
javascript"chat.message": async (input, output) => { // input.sessionID // input.agent, input.model, input.messageID // output.message — UserMessage object // output.parts — Part[] array },
"permission.ask"Modify permission behavior for tool execution.
javascript"permission.ask": async (input, output) => { // input — the Permission object // output.status — "ask" (default), "deny", or "allow" // Set to "allow" to auto-approve, "deny" to auto-deny },
bash# Install from npm opencode plugin my-plugin-package # Install from local path opencode plugin /path/to/my-plugin # Install globally (in ~/.config/opencode/) opencode plugin /path/to/my-plugin -g # Force reinstall opencode plugin /path/to/my-plugin -g --force
opencode plugin Doesnode_modules/opencode.json under the "plugin" keyopencode serve startIn opencode.json:
json{ "plugin": [ "npm-package-name", "/absolute/path/to/local/plugin", ["plugin-with-options", { "debug": true }] ] }
Plugins can be:
/)[name/path, options] where options are passed as the second argument to the plugin function~/.config/opencode/opencode.json — applies to all projects<project>/.opencode/opencode.json — project-specificThis is the #1 source of plugin failures.
When a plugin is installed via local path, OpenCode loads it from that path. But import statements like import { tool } from "@opencode-ai/plugin/tool" resolve relative to the plugin's directory, NOT relative to the config directory's node_modules/. This causes Cannot find module errors.
You will see this in logs as:
ERROR service=plugin path=file:///path/to/plugin error=Cannot find module '@opencode-ai/plugin/tool'For each local plugin, create a node_modules directory with symlinks to the config directory's packages:
bash# For each plugin that imports from @opencode-ai/plugin or zod: mkdir -p /path/to/my-plugin/node_modules ln -sf ~/.config/opencode/node_modules/@opencode-ai /path/to/my-plugin/node_modules/@opencode-ai ln -sf ~/.config/opencode/node_modules/zod /path/to/my-plugin/node_modules/zod
This ensures all imports resolve correctly regardless of where the plugin code lives.
The tool() helper is trivial — it just returns its input and attaches Zod:
javascript// Instead of: import { tool } from "@opencode-ai/plugin/tool" import { z } from "zod"; function tool(input) { return input; } tool.schema = z;
But you still need zod to resolve, so the symlink approach is cleaner.
These Node.js built-in modules always work (no imports needed beyond Node):
node:fs/promises, node:path, node:os, node:cryptonode:child_process, node:util, node:streamPlugins are loaded once. Use closure variables keyed by sessionID for per-session state:
javascriptconst sessions = new Map(); function getSession(sessionID) { if (!sessions.has(sessionID)) { sessions.set(sessionID, { turnCount: 0, data: [], }); } return sessions.get(sessionID); } export default async function MyPlugin(input) { return { "tool.execute.after": async (input) => { const session = getSession(input.sessionID); session.turnCount++; }, }; }
State in the plugin closure survives across sessions within the same opencode serve process. It's lost on server restart. Persist important data to disk:
javascriptimport * as fs from "node:fs/promises"; import * as path from "node:path"; async function saveData(filePath, data) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(data), "utf-8"); } async function loadData(filePath) { try { return JSON.parse(await fs.readFile(filePath, "utf-8")); } catch { return null; } }
~/.opencode/ or project dirsopencode.json plugin entriesUse input.$ (BunShell) for running commands:
javascriptexport default async function MyPlugin(input) { const $ = input.$; return { tool: { mytool: tool({ description: "Runs things", args: { cmd: tool.schema.string() }, async execute(args, ctx) { // Simple command const output = await $\`git status\`.cwd(ctx.directory).quiet().text(); // With environment const result = await $.env({ PATH: "/usr/bin" })\`echo hello\`.quiet().text(); // Don't throw on non-zero exit const out = await $\`some-command\`.nothrow().quiet().text(); return output; }, }), }, }; }
Alternatively, use Node.js child_process:
javascriptimport { exec as execCb } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(execCb); const { stdout } = await execAsync("git status", { cwd: directory, maxBuffer: 10 * 1024 * 1024 });
Node exec is often simpler for dynamic arguments. BunShell's template literal doesn't handle variable interpolation in command arguments well.
javascriptimport { tool } from "@opencode-ai/plugin/tool"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as os from "node:os"; const NOTES_DIR = path.join(os.homedir(), ".opencode", "notes"); async function ensureDir(d) { await fs.mkdir(d, { recursive: true }); } export default async function NotesPlugin(input) { return { tool: { notes: tool({ description: "Manage project notes. Save and retrieve notes for this project.", args: { action: tool.schema.enum(["save", "read", "list", "delete"]), title: tool.schema.string().optional().describe("Note title"), content: tool.schema.string().optional().describe("Note content"), }, async execute(args, ctx) { const projectNotes = path.join(NOTES_DIR, path.basename(ctx.directory)); await ensureDir(projectNotes); switch (args.action) { case "save": { if (!args.title || !args.content) return "Error: title and content required"; const filePath = path.join(projectNotes, `${args.title}.md`); await fs.writeFile(filePath, args.content); return `Saved note: ${args.title}`; } case "read": { if (!args.title) return "Error: title required"; const content = await fs.readFile( path.join(projectNotes, `${args.title}.md`), "utf-8" ).catch(() => null); return content || `Note "${args.title}" not found`; } case "list": { const files = await fs.readdir(projectNotes).catch(() => []); return files.length > 0 ? `Notes:\n${files.map(f => `- ${f.replace(".md", "")}`).join("\n")}` : "No notes yet"; } case "delete": { if (!args.title) return "Error: title required"; await fs.unlink(path.join(projectNotes, `${args.title}.md`)).catch(() => {}); return `Deleted note: ${args.title}`; } } }, }), }, }; }
javascriptimport { tool } from "@opencode-ai/plugin/tool"; import * as fs from "node:fs/promises"; import * as path from "node:path"; const docBuffer = new Map(); export default async function AutoDocPlugin(input) { const projectDir = input.directory; const docPath = path.join(projectDir, "AUTO_API_DOC.md"); async function loadDoc() { try { return await fs.readFile(docPath, "utf-8"); } catch { return ""; } } return { tool: { document_api: tool({ description: "Document an API endpoint or function. Records documentation that persists across sessions.", args: { name: tool.schema.string().describe("Function/endpoint name"), description: tool.schema.string().describe("What it does"), params: tool.schema.string().optional().describe("Parameters as JSON"), returns: tool.schema.string().optional().describe("Return value"), }, async execute(args, ctx) { let doc = (await loadDoc()) || "# API Documentation\n\n"; const entry = `\n## ${args.name}\n${args.description}\n` + (args.params ? `\n**Params**: ${args.params}\n` : "") + (args.returns ? `\n**Returns**: ${args.returns}\n` : ""); doc += entry; await fs.writeFile(docPath, doc); return `Documented: ${args.name}`; }, }), }, "experimental.chat.system.transform": async (input, output) => { const doc = await loadDoc(); if (doc) { output.system.push(`<api-docs>\n${doc}\n</api-docs>`); } }, "experimental.session.compacting": async (input, output) => { const doc = await loadDoc(); if (doc) output.context.push(doc); }, }; }
When porting from Pi SDK's pi.registerTool() / pi.on() pattern:
| Pi SDK | OpenCode Plugin | |---|---| | pi.registerTool({ name: "x", ... }) | tool: { x: tool({ ... }) } | | pi.registerCommand("x", ...) | No equivalent — use tools instead | | pi.on("session_start", fn) | Load state in plugin function body | | pi.on("before_agent_start", fn) | "experimental.chat.system.transform" hook | | pi.on("turn_end", fn) | "tool.execute.after" hook (approximate) | | pi.on("tool_call", fn) | "tool.execute.before" hook | | pi.on("tool_result", fn) | "tool.execute.after" hook | | pi.on("session_before_compact", fn) | "experimental.session.compacting" hook | | ctx.sessionManager.getSessionFile() | Use ctx.sessionID + disk paths | | ctx.cwd | input.directory or ctx.directory (in tool execute) | | Pi TypeBox schemas (Type.Object) | Zod schemas (tool.schema) | | pi-tui rendering (Box, Text) | Not available — tools return strings |
Key differences when porting:
/commands. Use tools instead.Type.Object({...})) → Zod ({ key: tool.schema.string() })Check logs for ERROR service=plugin:
bash# If running via opencode serve, check stderr for: # ERROR service=plugin path=... error=Cannot find module '...'
Common causes:
"type": "module" in package.json — plugins MUST be ESMnode -e "import('/path/to/plugin/index.js').then(m => console.log(typeof m.default))"default as an async functiontool map must match exactly what's returnedtool() helper is called correctly (description, args, execute)The error looks like:
Cannot find module '@opencode-ai/plugin/tool' from '/path/to/plugin/index.js'Fix: create symlinks (see "Module Resolution" section). Verify with:
bashnode -e "import('/path/to/plugin/index.js').then(m => console.log('OK:', typeof m.default)).catch(e => console.error('FAIL:', e.message))"
output properties that aren't documentedbash# Check config cat ~/.config/opencode/opencode.json | grep -A5 plugin # Check the plugin loads node -e "import('/path/to/plugin/index.js').then(m => console.log('Plugin loads, type:', typeof m.default))" # List installed packages ls ~/.config/opencode/node_modules/ | grep -i plugin-name
For local path plugins, you can edit the plugin code in place. Changes take effect on the next opencode serve start. There's no hot reload.
bashopencode serve --pure --port 4096
The --pure flag disables all external plugins. Useful for isolating plugin-caused issues.
Plugins survive:
opencode upgrade — config file and local paths aren't touchedopencode serve which re-reads configPlugins DON'T survive:
ctx.abort to respect cancellation signalsOther measured skills in the registry, with their headline benchmark lift.