Skip to content

Commit 2702777

Browse files
committed
docs: expand V1 plugin migration guide
1 parent 5d1841e commit 2702777

4 files changed

Lines changed: 377 additions & 10 deletions

File tree

‎services/www/src/docs/content/build/plugins/index.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ title: "Overview"
55
Plugins can modify OpenCode's behavior and add new features. To change the terminal UI, build a [CLI
66
plugin](/build/plugins/cli).
77

8+
Migrating an existing OpenCode 1 plugin? Follow the [V1 plugin migration guide](/build/plugins/migrate-v1).
9+
810
```ts title=".opencode/plugins/example/index.ts"
911
import { Plugin } from "@opencode/plugin"
1012

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
---
2+
title: "Migrate plugins from V1"
3+
---
4+
5+
Plugin configuration can be normalized automatically, but plugin implementation code must be ported. V1 plugin functions
6+
return one object containing tools and hooks. A V2 plugin default-exports a definition with an `id` and `setup(ctx)`;
7+
`setup` registers hooks, transforms, tools, and subscriptions through the context.
8+
9+
<Callout type="warning">
10+
V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough.
11+
</Callout>
12+
13+
## Migrate plugin configuration
14+
15+
Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
16+
17+
```jsonc
18+
// V1
19+
{
20+
"plugin": [
21+
"opencode-example-plugin",
22+
["./plugin/local.ts", { "enabled": true }]
23+
]
24+
}
25+
26+
// V2
27+
{
28+
"plugins": [
29+
"opencode-example-plugin",
30+
{
31+
"package": "./plugin/local.ts",
32+
"options": { "enabled": true }
33+
}
34+
]
35+
}
36+
```
37+
38+
V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files.
39+
Keep supporting modules beside the plugin when moving it. Local plugins and package plugins use the same implementation
40+
API.
41+
42+
## Replace the entrypoint
43+
44+
Change the package import, replace the exported plugin function with `Plugin.define`, and move initialization into
45+
`setup`.
46+
47+
```ts title="V1"
48+
import type { Plugin } from "@opencode-ai/plugin"
49+
50+
export const ExamplePlugin: Plugin = async ({ directory, project, client }) => {
51+
await client.app.log({
52+
body: { service: "example", level: "info", message: `Loaded for ${project.id}` },
53+
})
54+
55+
return {
56+
// V1 tools and hooks
57+
}
58+
}
59+
```
60+
61+
```ts title="V2"
62+
import { Plugin } from "@opencode/plugin"
63+
64+
export default Plugin.define({
65+
id: "example",
66+
async setup(ctx) {
67+
await ctx.storage.set("last-project", ctx.location.project.id)
68+
console.log(`Loaded for ${ctx.location.directory}`)
69+
},
70+
})
71+
```
72+
73+
Every V2 plugin needs a stable `id`. Plugin storage is scoped by this ID, and the ID also identifies the plugin in status
74+
and diagnostics. Published plugins should depend on a version of `@opencode/plugin` compatible with the OpenCode release
75+
they target.
76+
77+
## Migrate the context
78+
79+
The V2 context is both an OpenCode client and the plugin extension API. Most operations are grouped by domain instead of
80+
living on a separate `client` property.
81+
82+
| V1 | V2 |
83+
| --------------------------------------- | ------------------------------------------------------------------- |
84+
| `directory` | `ctx.location.directory` |
85+
| `project` | `ctx.location.project` |
86+
| `client` | Domain methods such as `ctx.session`, `ctx.permission`, `ctx.agent` |
87+
| plugin options argument | `ctx.options` |
88+
| returned `dispose()` hook | cleanup function returned by `setup` |
89+
| returned `event` hook | `ctx.event.subscribe()` |
90+
| returned config, model, or tool changes | a domain `transform(...)` |
91+
| `$` Bun shell helper | import and manage the process API your plugin uses |
92+
93+
`ctx.location` describes the location where this plugin instance loaded. It is not the location of every session or event
94+
the plugin may observe. Read the session or event data when that distinction matters.
95+
96+
The context exposes many more domains than the table lists, including catalogs, commands, integrations, MCP servers,
97+
references, skills, storage, VCS, worktrees, and web search. See the [complete plugin context](/build/plugins#api).
98+
99+
## Register hooks in `setup`
100+
101+
V1 returns hooks by string key. V2 registers each hook on the domain that owns the operation. The callback receives one
102+
mutable event instead of separate `input` and `output` objects.
103+
104+
```ts title="V1"
105+
import type { Plugin } from "@opencode-ai/plugin"
106+
107+
export const GuardPlugin: Plugin = async () => ({
108+
"tool.execute.before": async (input, output) => {
109+
if (input.tool === "read" && output.args.filePath.includes(".env")) {
110+
throw new Error("Do not read .env files")
111+
}
112+
},
113+
"shell.env": async (_input, output) => {
114+
output.env.COMPANY_ENV = "development"
115+
},
116+
})
117+
```
118+
119+
```ts title="V2"
120+
import { Plugin } from "@opencode/plugin"
121+
122+
export default Plugin.define({
123+
id: "company.guard",
124+
async setup(ctx) {
125+
await ctx.tool.hook("execute.before", (event) => {
126+
const input = event.input as { filePath?: string }
127+
if (event.tool === "read" && input.filePath?.includes(".env")) {
128+
throw new Error("Do not read .env files")
129+
}
130+
})
131+
132+
await ctx.shell.hook("create.before", (event) => {
133+
event.env.COMPANY_ENV = "development"
134+
})
135+
},
136+
})
137+
```
138+
139+
Registrations stay active for the plugin's lifetime and are disposed when it unloads. Retain the returned registration
140+
only when the plugin needs to remove that particular hook or transform earlier with `registration.dispose()`.
141+
142+
Use this table to find the closest V2 API for each V1 hook:
143+
144+
| V1 extension point | V2 API |
145+
| -------------------------------------- | ------------------------------------------------------------ |
146+
| `event` | `ctx.event.subscribe()` |
147+
| `dispose` | cleanup function returned by `setup` |
148+
| `config` | transforms on the affected domains |
149+
| `tool` map | `ctx.tool.transform(...)` |
150+
| `auth` | `ctx.integration.transform(...)` and integration APIs |
151+
| `provider` | `ctx.catalog.transform(...)` |
152+
| `chat.message` | `ctx.session.hook("prompt", ...)` |
153+
| `chat.params` | `ctx.session.hook("context", ...)` |
154+
| `chat.headers` | `ctx.session.hook("model.request", ...)` or `"http.request"` |
155+
| `permission.ask` | `ctx.permission.hook("evaluate", ...)` |
156+
| `command.execute.before` | command transforms or the prompt hook, depending on intent |
157+
| `tool.execute.before` | `ctx.tool.hook("execute.before", ...)` |
158+
| `tool.execute.after` | `ctx.tool.hook("execute.after", ...)` |
159+
| `shell.env` | `ctx.shell.hook("create.before", ...)` |
160+
| `tool.definition` | `ctx.tool.transform(...)` |
161+
| `experimental.chat.system.transform` | `ctx.session.hook("context", ...)` and edit `event.system` |
162+
| `experimental.chat.messages.transform` | `ctx.session.hook("context", ...)` and edit `event.messages` |
163+
| `experimental.session.compacting` | `ctx.session.hook("compaction", ...)` |
164+
165+
These are migration destinations, not always exact renames. In particular, the V2 `prompt` hook runs before durable
166+
prompt admission, while `context` runs immediately before an agent model request. Register `context`, `compaction`,
167+
`generate`, and `title` separately if a transformation must apply to every model request kind.
168+
169+
There is no direct V2 hook corresponding to every experimental V1 hook. Re-evaluate plugins that use
170+
`experimental.compaction.autocontinue`, `experimental.provider.small_model`, or `experimental.text.complete` against the
171+
V2 session, catalog, and event APIs rather than preserving an internal lifecycle assumption.
172+
173+
`command.execute.before` also has no one-to-one global hook. Register a command with `ctx.command.transform` when the
174+
plugin owns that command. Use the prompt hook when the real intent is to modify prompts submitted by commands and other
175+
prompt sources.
176+
177+
## Migrate request hooks
178+
179+
V2 separates prompt admission, model-visible context, semantic model options, and native HTTP. Choose the narrowest
180+
layer your plugin needs.
181+
182+
```ts title="V1"
183+
export const RequestPlugin = async () => ({
184+
"chat.params": async (_input, output) => {
185+
output.temperature = 0.2
186+
output.maxOutputTokens = 8_000
187+
},
188+
"chat.headers": async (_input, output) => {
189+
output.headers["x-plugin"] = "review"
190+
},
191+
"experimental.chat.system.transform": async (_input, output) => {
192+
output.system.push("Focus on correctness.")
193+
},
194+
})
195+
```
196+
197+
```ts title="V2"
198+
import { Plugin } from "@opencode/plugin"
199+
200+
export default Plugin.define({
201+
id: "review-request",
202+
async setup(ctx) {
203+
await ctx.session.hook("context", (event) => {
204+
event.system.push({ type: "text", text: "Focus on correctness." })
205+
event.options.temperature = 0.2
206+
event.options.maxTokens = 8_000
207+
})
208+
209+
await ctx.session.hook("model.request", (event) => {
210+
event.headers["x-plugin"] = "review"
211+
})
212+
},
213+
})
214+
```
215+
216+
Use semantic provider options in `event.options`. Use `model.request` for model request headers and raw request settings,
217+
and use `http.request` or `http.response` only when the plugin needs the native provider HTTP exchange. Provider-specific
218+
hooks can pass `{ providerID: "..." }` as the third argument to `ctx.session.hook`.
219+
220+
## Migrate custom tools
221+
222+
V1 returns a `tool` map built with the old `tool()` helper. V2 registers tool definitions through a synchronous
223+
`ctx.tool.transform` editor. Tool schemas use JSON Schema, and execution returns structured content.
224+
225+
```ts title="V1"
226+
import { tool } from "@opencode-ai/plugin"
227+
228+
export const ToolsPlugin = async () => ({
229+
tool: {
230+
greeting: tool({
231+
description: "Create a greeting",
232+
args: { name: tool.schema.string() },
233+
async execute(args) {
234+
return `Hello ${args.name}!`
235+
},
236+
}),
237+
},
238+
})
239+
```
240+
241+
```ts title="V2"
242+
import { Plugin } from "@opencode/plugin"
243+
244+
export default Plugin.define({
245+
id: "greeting",
246+
async setup(ctx) {
247+
await ctx.tool.transform((editor) => {
248+
editor.add({
249+
name: "greeting",
250+
description: "Create a greeting",
251+
input: {
252+
type: "object",
253+
properties: { name: { type: "string" } },
254+
required: ["name"],
255+
additionalProperties: false,
256+
},
257+
async execute(input) {
258+
return { content: `Hello ${(input as { name: string }).name}!` }
259+
},
260+
})
261+
})
262+
},
263+
})
264+
```
265+
266+
Transforms are replayable state edits. Keep their callback synchronous, cheap, and free of one-time side effects. Load
267+
external data before registration, capture it in the callback, and call the domain's `reload()` method when that data
268+
changes. A later plugin transform sees earlier changes and may update, replace, or remove them.
269+
270+
The same transform pattern replaces V1 `config` and `provider` hooks, but V2 does not expose one mutable global config
271+
object. Register the change with the domain that owns it: `ctx.agent`, `ctx.catalog`, `ctx.command`, `ctx.integration`,
272+
`ctx.mcp`, `ctx.reference`, `ctx.skill`, `ctx.tool`, `ctx.vcs`, `ctx.websearch`, or `ctx.worktree`. Authentication methods
273+
belong to integrations; provider and model metadata belong to the catalog.
274+
275+
## Migrate events and cleanup
276+
277+
V1's `event` callback becomes an async subscription to the public server event stream. Abort it from the cleanup function
278+
returned by `setup`.
279+
280+
```ts title="V2"
281+
import { Plugin } from "@opencode/plugin"
282+
283+
export default Plugin.define({
284+
id: "notifications",
285+
setup(ctx) {
286+
const controller = new AbortController()
287+
288+
void (async () => {
289+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
290+
if (event.type === "session.idle") console.log("Session completed")
291+
}
292+
})()
293+
294+
return () => controller.abort()
295+
},
296+
})
297+
```
298+
299+
Use the cleanup function for timers, subprocesses, sockets, and other resources the plugin owns. Hook and transform
300+
registrations are scoped to the plugin and are cleaned up automatically.
301+
302+
## Migrate options and persistent state
303+
304+
Read options from `ctx.options`. Use `ctx.storage` instead of inventing a global file when state belongs to a plugin; its
305+
JSON values are durable and scoped to the plugin.
306+
307+
```ts
308+
const strict = ctx.options.strict === true
309+
await ctx.storage.set("settings", { strict })
310+
const settings = await ctx.storage.get("settings")
311+
```
312+
313+
## Support V1 and V2 from one package
314+
315+
A package can temporarily expose both implementations from one default export. V1 calls `server()` and V2 calls
316+
`setup()`. The APIs remain separate; this shape does not translate hooks automatically.
317+
318+
```ts title="src/index.ts"
319+
import { Plugin } from "@opencode/plugin"
320+
321+
export default {
322+
...Plugin.define({
323+
id: "example",
324+
async setup(ctx) {
325+
await ctx.tool.hook("execute.before", () => {
326+
console.log("A tool is about to run")
327+
})
328+
},
329+
}),
330+
async server() {
331+
return {
332+
"tool.execute.before": async () => {
333+
console.log("A tool is about to run")
334+
},
335+
}
336+
},
337+
}
338+
```
339+
340+
V1 object entrypoints are supported in OpenCode `1.18.29` and newer. If you support older V1 releases, use separate
341+
package versions or entrypoints and test the oldest release you claim to support. Remove the V1 implementation after your
342+
support window ends.
343+
344+
## Verify a ported plugin
345+
346+
For each plugin:
347+
348+
1. Confirm its ID and source appear in the active plugin list.
349+
2. Exercise every registered hook, transform, tool, command, and event subscription.
350+
3. Verify cleanup by reloading or removing the plugin.
351+
4. Test plugin options and persisted state with a clean project.
352+
5. For a published package, test the installed package rather than only a workspace-linked copy.
353+
354+
See the [Plugins guide](/build/plugins) for the full Promise API, [Effect plugins](/build/plugins/effect) for the Effect
355+
entrypoint, [plugin RPC](/build/plugins/rpc) for custom methods and events, and [CLI plugins](/build/plugins/cli) for TUI
356+
extensions.

0 commit comments

Comments
 (0)