跳转到内容

OpenCode2 plugin development

Related: opencode2-plugin-development · OpenCode2 server API · V2 plugins overview · Configure plugins · Migrate V1→V2


An OpenCode V2 plugin is a JS/TS module that extends the server (tools, hooks, agents, MCP, worktrees, etc.). The plugin context is essentially an OpenCode server client plus plugin-only transforms, hooks, storage, reloads, and options.

KindEntrypointPackage
Promise (default)Plugin.define({ id, setup(ctx) })@opencode/plugin
EffectPlugin.define({ id, effect(ctx) })@opencode/plugin/effect (+ effect)
CLI / TUI-onlyseparate CLI plugin configsee “CLI plugins” in docs

setup runs on load and may return a cleanup function. Registrations dispose when the plugin unloads (or when you call registration.dispose()).

Not covered here: attaching to a running OpenCode2 HTTP server from bots — use opencode2-server-api.


Minimal Promise plugin:

import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "example",
async setup(ctx) {
console.log(`loaded in OpenCode ${ctx.app.version}`)
console.log(ctx.location.directory)
await ctx.storage.set("loaded", true)
return () => console.log("unloaded")
},
})

Published package shape:

{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": { ".": "./src/index.ts" },
"dependencies": { "@opencode/plugin": "<version compatible with target OpenCode>" }
}

Optional "./rpc" export for a shared RPC contract without loading the implementation.

Stable id is required — scopes ctx.storage, appears in plugin list/diagnostics, and is used with enable/disable wildcards.


V2 does not return a hooks object from the plugin function (that was V1). Everything registers through ctx.<domain> in setup.

JSON Schema input; execute returns structured { content: ... }. Pass context.signal to cancellable work. Namespaces: editor.namespace({ name, description }) then options: { namespace: "acme" } → effective id acme_greeting (dots// → _).

await ctx.tool.transform((editor) => {
editor.add({
name: "greeting",
description: "Create a greeting",
input: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
},
async execute(input, context) {
await context.progress({ status: "greeting" })
return { content: `Hello ${(input as { name: string }).name}!` }
},
})
})
await ctx.tool.hook("execute.before", (event) => {
if (event.tool === "read") console.log(event.input)
})
await ctx.tool.hook("execute.after", (event) => {
if (event.status === "error") console.error(event.error.message)
})

Transforms must stay synchronous, cheap, replayable. Load external data before the callback; call ctx.tool.reload() when captured data changes. Later transforms override same effective name.

await ctx.command.transform((editor) => {
editor.add({
name: "security-review",
description: "Review changes for security issues",
execute: async ({ sessionID, prompt, delivery }) => {
await ctx.session.prompt({
...prompt,
sessionID,
text: `Review these changes for security issues.\n\n${prompt.text}`,
delivery,
})
},
})
})
HookWhen
promptBefore durable prompt admission (rewrites text/files/skills/delivery)
contextBefore agent-loop model call (system, messages, tools, options)
compaction / generate / titleAuxiliary requests; may set result to skip the model
model.requestHeaders / request settings; optional { providerID }
http.request / http.responseNative HTTP; bodies are one-shot streams
retryOverride retry decision/delay
experimental.ws.*WebSocket handshake/frames (experimental)

Also: ctx.permission.hook("evaluate", …), ctx.shell.hook("create.before", …), ctx.event.subscribe({ signal }).

agent, provider, model, integration, mcp, reference, skill, vcs, websearch, worktree — each has transform + usually reload.


{
"$schema": "https://opencode.ai/config.json",
"plugins": [
"opencode-acme-plugin",
"opencode-acme-plugin@1.2.0",
"@acme/opencode-plugin",
"./plugins/local",
"/absolute/path/plugin.ts",
"file:///home/me/plugins/local",
{
"package": "@acme/opencode-plugin",
"options": { "strict": true }
}
]
}

Read options via ctx.options. Relative paths resolve from the config file that contains the entry. Arrays from layered configs merge (lowest → highest precedence), they do not replace.

  • Project: .opencode/plugins/*.ts|js and immediate package dirs
  • Global: ~/.config/opencode/plugins/ (stock) — on this box / opencode2: ~/.config/opencode2/plugins/
  • A bare plugins/ next to project-root opencode.json(c) is not auto-loaded — list it in plugins or move under .opencode/

Enable/disable order: *, -id, -prefix.*, later IDs re-enable. Built-ins opencode.config.policy and opencode.provider.opencode ignore removals.

Terminal window
opencode2 plugin add opencode-acme-plugin@1.2.0
opencode2 plugin add git+https://github.com/anomalyco/opencode-plugin-lane.git
opencode2 plugin list
opencode2 plugin list --builtin
opencode2 plugin check
opencode2 plugin update
opencode2 plugin remove opencode-acme-plugin@1.2.0

Accepts npm names/versions and Git HTTPS/SSH/github: specs (incl. #ref and ::path:). Local paths go in config, not plugin add. Unwatched local deps may need opencode2 service restart / opencode2 reload.

Box paths (opencode2 v2.1.2): config ~/.config/opencode2, data ~/.local/share/opencode2, binary ~/.local/bin/opencode2. Isolated from stock opencode.


ExampleWhat it shows
Docs overview snippetsPlugin.define, tools, hooks, transforms, publish
anomalyco/opencode-plugin-laneReal published V2 plugin: id: "lane", ctx.worktree.transform, Git install via opencode2 plugin add git+https://…
anomalyco/rift plugins/opencodeid: "rift.workspaces", worktree strategy + tools registration
Older @opencode-ai/plugin / tool() helpersV1 — still in many GitHub copies and legacy docs; does not run on V2 without porting

Lane’s entrypoint (canonical V2 pattern):

import { Plugin } from "@opencode/plugin"
import { makeStrategy } from "./strategy"
export default Plugin.define({
id: "lane",
async setup(ctx) {
const strategy = makeStrategy(ctx.options)
await ctx.worktree.transform((editor) => editor.add(strategy))
},
})

  1. V1 ≠ V2. Returning { "tool.execute.before": …, tool: {…} } from a plugin function does not run on V2. Port with migrate-v1: plugin → plugins, @opencode-ai/plugin → @opencode/plugin, hooks → ctx.*.hook, tools → ctx.tool.transform.
  2. Dual support is possible (setup + V1 server() on one export) but APIs stay separate — no auto-translation.
  3. Transforms replay — no one-shot side effects inside the editor callback; use outer scope + reload().
  4. Tool snapshots are frozen per model request; later reload/dispose only affects future requests.
  5. Prompt hook ≠ context hook — admission vs pre-model; register each kind you need.
  6. WebSocket providers skip HTTP hooks; use experimental.ws.* (unstable).
  7. Configured deny permissions skip permission.evaluate hooks.
  8. opencode2 vs opencode — different XDG dirs; use opencode2 CLI and ~/.config/opencode2 on this box.
  9. Pin @opencode/plugin to a version compatible with the OpenCode/opencode2 release; test the installed package, not only a workspace link.
  10. Never put secrets in digests/skills; for server attach/auth see opencode2-server-api.