diff --git a/services/www/src/docs/content/build/plugins/index.mdx b/services/www/src/docs/content/build/plugins/index.mdx
index 04a6c91bebe..89302e0548a 100644
--- a/services/www/src/docs/content/build/plugins/index.mdx
+++ b/services/www/src/docs/content/build/plugins/index.mdx
@@ -5,6 +5,8 @@ title: "Overview"
Plugins can modify OpenCode's behavior and add new features. To change the terminal UI, build a [CLI
plugin](/build/plugins/cli).
+Migrating an existing OpenCode 1 plugin? Follow the [V1 plugin migration guide](/build/plugins/migrate-v1).
+
```ts title=".opencode/plugins/example/index.ts"
import { Plugin } from "@opencode/plugin"
diff --git a/services/www/src/docs/content/build/plugins/migrate-v1.mdx b/services/www/src/docs/content/build/plugins/migrate-v1.mdx
new file mode 100644
index 00000000000..b8268b2ca7c
--- /dev/null
+++ b/services/www/src/docs/content/build/plugins/migrate-v1.mdx
@@ -0,0 +1,356 @@
+---
+title: "Migrate plugins from V1"
+---
+
+Plugin configuration can be normalized automatically, but plugin implementation code must be ported. V1 plugin functions
+return one object containing tools and hooks. A V2 plugin default-exports a definition with an `id` and `setup(ctx)`;
+`setup` registers hooks, transforms, tools, and subscriptions through the context.
+
+
+ V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough.
+
+
+## Migrate plugin configuration
+
+Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object:
+
+```jsonc
+// V1
+{
+ "plugin": [
+ "opencode-example-plugin",
+ ["./plugin/local.ts", { "enabled": true }]
+ ]
+}
+
+// V2
+{
+ "plugins": [
+ "opencode-example-plugin",
+ {
+ "package": "./plugin/local.ts",
+ "options": { "enabled": true }
+ }
+ ]
+}
+```
+
+V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files.
+Keep supporting modules beside the plugin when moving it. Local plugins and package plugins use the same implementation
+API.
+
+## Replace the entrypoint
+
+Change the package import, replace the exported plugin function with `Plugin.define`, and move initialization into
+`setup`.
+
+```ts title="V1"
+import type { Plugin } from "@opencode-ai/plugin"
+
+export const ExamplePlugin: Plugin = async ({ directory, project, client }) => {
+ await client.app.log({
+ body: { service: "example", level: "info", message: `Loaded for ${project.id}` },
+ })
+
+ return {
+ // V1 tools and hooks
+ }
+}
+```
+
+```ts title="V2"
+import { Plugin } from "@opencode/plugin"
+
+export default Plugin.define({
+ id: "example",
+ async setup(ctx) {
+ await ctx.storage.set("last-project", ctx.location.project.id)
+ console.log(`Loaded for ${ctx.location.directory}`)
+ },
+})
+```
+
+Every V2 plugin needs a stable `id`. Plugin storage is scoped by this ID, and the ID also identifies the plugin in status
+and diagnostics. Published plugins should depend on a version of `@opencode/plugin` compatible with the OpenCode release
+they target.
+
+## Migrate the context
+
+The V2 context is both an OpenCode client and the plugin extension API. Most operations are grouped by domain instead of
+living on a separate `client` property.
+
+| V1 | V2 |
+| --------------------------------------- | ------------------------------------------------------------------- |
+| `directory` | `ctx.location.directory` |
+| `project` | `ctx.location.project` |
+| `client` | Domain methods such as `ctx.session`, `ctx.permission`, `ctx.agent` |
+| plugin options argument | `ctx.options` |
+| returned `dispose()` hook | cleanup function returned by `setup` |
+| returned `event` hook | `ctx.event.subscribe()` |
+| returned config, model, or tool changes | a domain `transform(...)` |
+| `$` Bun shell helper | import and manage the process API your plugin uses |
+
+`ctx.location` describes the location where this plugin instance loaded. It is not the location of every session or event
+the plugin may observe. Read the session or event data when that distinction matters.
+
+The context exposes many more domains than the table lists, including catalogs, commands, integrations, MCP servers,
+references, skills, storage, VCS, worktrees, and web search. See the [complete plugin context](/build/plugins#api).
+
+## Register hooks in `setup`
+
+V1 returns hooks by string key. V2 registers each hook on the domain that owns the operation. The callback receives one
+mutable event instead of separate `input` and `output` objects.
+
+```ts title="V1"
+import type { Plugin } from "@opencode-ai/plugin"
+
+export const GuardPlugin: Plugin = async () => ({
+ "tool.execute.before": async (input, output) => {
+ if (input.tool === "read" && output.args.filePath.includes(".env")) {
+ throw new Error("Do not read .env files")
+ }
+ },
+ "shell.env": async (_input, output) => {
+ output.env.COMPANY_ENV = "development"
+ },
+})
+```
+
+```ts title="V2"
+import { Plugin } from "@opencode/plugin"
+
+export default Plugin.define({
+ id: "company.guard",
+ async setup(ctx) {
+ await ctx.tool.hook("execute.before", (event) => {
+ const input = event.input as { filePath?: string }
+ if (event.tool === "read" && input.filePath?.includes(".env")) {
+ throw new Error("Do not read .env files")
+ }
+ })
+
+ await ctx.shell.hook("create.before", (event) => {
+ event.env.COMPANY_ENV = "development"
+ })
+ },
+})
+```
+
+Registrations stay active for the plugin's lifetime and are disposed when it unloads. Retain the returned registration
+only when the plugin needs to remove that particular hook or transform earlier with `registration.dispose()`.
+
+Use this table to find the closest V2 API for each V1 hook:
+
+| V1 extension point | V2 API |
+| -------------------------------------- | ------------------------------------------------------------ |
+| `event` | `ctx.event.subscribe()` |
+| `dispose` | cleanup function returned by `setup` |
+| `config` | transforms on the affected domains |
+| `tool` map | `ctx.tool.transform(...)` |
+| `auth` | `ctx.integration.transform(...)` and integration APIs |
+| `provider` | `ctx.catalog.transform(...)` |
+| `chat.message` | `ctx.session.hook("prompt", ...)` |
+| `chat.params` | `ctx.session.hook("context", ...)` |
+| `chat.headers` | `ctx.session.hook("model.request", ...)` or `"http.request"` |
+| `permission.ask` | `ctx.permission.hook("evaluate", ...)` |
+| `command.execute.before` | command transforms or the prompt hook, depending on intent |
+| `tool.execute.before` | `ctx.tool.hook("execute.before", ...)` |
+| `tool.execute.after` | `ctx.tool.hook("execute.after", ...)` |
+| `shell.env` | `ctx.shell.hook("create.before", ...)` |
+| `tool.definition` | `ctx.tool.transform(...)` |
+| `experimental.chat.system.transform` | `ctx.session.hook("context", ...)` and edit `event.system` |
+| `experimental.chat.messages.transform` | `ctx.session.hook("context", ...)` and edit `event.messages` |
+| `experimental.session.compacting` | `ctx.session.hook("compaction", ...)` |
+
+These are migration destinations, not always exact renames. In particular, the V2 `prompt` hook runs before durable
+prompt admission, while `context` runs immediately before an agent model request. Register `context`, `compaction`,
+`generate`, and `title` separately if a transformation must apply to every model request kind.
+
+There is no direct V2 hook corresponding to every experimental V1 hook. Re-evaluate plugins that use
+`experimental.compaction.autocontinue`, `experimental.provider.small_model`, or `experimental.text.complete` against the
+V2 session, catalog, and event APIs rather than preserving an internal lifecycle assumption.
+
+`command.execute.before` also has no one-to-one global hook. Register a command with `ctx.command.transform` when the
+plugin owns that command. Use the prompt hook when the real intent is to modify prompts submitted by commands and other
+prompt sources.
+
+## Migrate request hooks
+
+V2 separates prompt admission, model-visible context, semantic model options, and native HTTP. Choose the narrowest
+layer your plugin needs.
+
+```ts title="V1"
+export const RequestPlugin = async () => ({
+ "chat.params": async (_input, output) => {
+ output.temperature = 0.2
+ output.maxOutputTokens = 8_000
+ },
+ "chat.headers": async (_input, output) => {
+ output.headers["x-plugin"] = "review"
+ },
+ "experimental.chat.system.transform": async (_input, output) => {
+ output.system.push("Focus on correctness.")
+ },
+})
+```
+
+```ts title="V2"
+import { Plugin } from "@opencode/plugin"
+
+export default Plugin.define({
+ id: "review-request",
+ async setup(ctx) {
+ await ctx.session.hook("context", (event) => {
+ event.system.push({ type: "text", text: "Focus on correctness." })
+ event.options.temperature = 0.2
+ event.options.maxTokens = 8_000
+ })
+
+ await ctx.session.hook("model.request", (event) => {
+ event.headers["x-plugin"] = "review"
+ })
+ },
+})
+```
+
+Use semantic provider options in `event.options`. Use `model.request` for model request headers and raw request settings,
+and use `http.request` or `http.response` only when the plugin needs the native provider HTTP exchange. Provider-specific
+hooks can pass `{ providerID: "..." }` as the third argument to `ctx.session.hook`.
+
+## Migrate custom tools
+
+V1 returns a `tool` map built with the old `tool()` helper. V2 registers tool definitions through a synchronous
+`ctx.tool.transform` editor. Tool schemas use JSON Schema, and execution returns structured content.
+
+```ts title="V1"
+import { tool } from "@opencode-ai/plugin"
+
+export const ToolsPlugin = async () => ({
+ tool: {
+ greeting: tool({
+ description: "Create a greeting",
+ args: { name: tool.schema.string() },
+ async execute(args) {
+ return `Hello ${args.name}!`
+ },
+ }),
+ },
+})
+```
+
+```ts title="V2"
+import { Plugin } from "@opencode/plugin"
+
+export default Plugin.define({
+ id: "greeting",
+ async setup(ctx) {
+ 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) {
+ return { content: `Hello ${(input as { name: string }).name}!` }
+ },
+ })
+ })
+ },
+})
+```
+
+Transforms are replayable state edits. Keep their callback synchronous, cheap, and free of one-time side effects. Load
+external data before registration, capture it in the callback, and call the domain's `reload()` method when that data
+changes. A later plugin transform sees earlier changes and may update, replace, or remove them.
+
+The same transform pattern replaces V1 `config` and `provider` hooks, but V2 does not expose one mutable global config
+object. Register the change with the domain that owns it: `ctx.agent`, `ctx.catalog`, `ctx.command`, `ctx.integration`,
+`ctx.mcp`, `ctx.reference`, `ctx.skill`, `ctx.tool`, `ctx.vcs`, `ctx.websearch`, or `ctx.worktree`. Authentication methods
+belong to integrations; provider and model metadata belong to the catalog.
+
+## Migrate events and cleanup
+
+V1's `event` callback becomes an async subscription to the public server event stream. Abort it from the cleanup function
+returned by `setup`.
+
+```ts title="V2"
+import { Plugin } from "@opencode/plugin"
+
+export default Plugin.define({
+ id: "notifications",
+ setup(ctx) {
+ const controller = new AbortController()
+
+ void (async () => {
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
+ if (event.type === "session.idle") console.log("Session completed")
+ }
+ })()
+
+ return () => controller.abort()
+ },
+})
+```
+
+Use the cleanup function for timers, subprocesses, sockets, and other resources the plugin owns. Hook and transform
+registrations are scoped to the plugin and are cleaned up automatically.
+
+## Migrate options and persistent state
+
+Read options from `ctx.options`. Use `ctx.storage` instead of inventing a global file when state belongs to a plugin; its
+JSON values are durable and scoped to the plugin.
+
+```ts
+const strict = ctx.options.strict === true
+await ctx.storage.set("settings", { strict })
+const settings = await ctx.storage.get("settings")
+```
+
+## Support V1 and V2 from one package
+
+A package can temporarily expose both implementations from one default export. V1 calls `server()` and V2 calls
+`setup()`. The APIs remain separate; this shape does not translate hooks automatically.
+
+```ts title="src/index.ts"
+import { Plugin } from "@opencode/plugin"
+
+export default {
+ ...Plugin.define({
+ id: "example",
+ async setup(ctx) {
+ await ctx.tool.hook("execute.before", () => {
+ console.log("A tool is about to run")
+ })
+ },
+ }),
+ async server() {
+ return {
+ "tool.execute.before": async () => {
+ console.log("A tool is about to run")
+ },
+ }
+ },
+}
+```
+
+V1 object entrypoints are supported in OpenCode `1.18.29` and newer. If you support older V1 releases, use separate
+package versions or entrypoints and test the oldest release you claim to support. Remove the V1 implementation after your
+support window ends.
+
+## Verify a ported plugin
+
+For each plugin:
+
+1. Confirm its ID and source appear in the active plugin list.
+2. Exercise every registered hook, transform, tool, command, and event subscription.
+3. Verify cleanup by reloading or removing the plugin.
+4. Test plugin options and persisted state with a clean project.
+5. For a published package, test the installed package rather than only a workspace-linked copy.
+
+See the [Plugins guide](/build/plugins) for the full Promise API, [Effect plugins](/build/plugins/effect) for the Effect
+entrypoint, [plugin RPC](/build/plugins/rpc) for custom methods and events, and [CLI plugins](/build/plugins/cli) for TUI
+extensions.
diff --git a/services/www/src/docs/content/migrate-v1.mdx b/services/www/src/docs/content/migrate-v1.mdx
index da5c8c432ae..511cd0e0877 100644
--- a/services/www/src/docs/content/migrate-v1.mdx
+++ b/services/www/src/docs/content/migrate-v1.mdx
@@ -9,7 +9,7 @@ package-managed V1 installation before installing V2; the V2 curl installer repl
V2 has three intentional breaking changes:
-- [Plugins](#plugins) use a new plugin API.
+- [Plugins](/build/plugins/migrate-v1) use a new plugin API.
- The [server API and clients](#server-api-and-clients) have new contracts.
- [Terminal client configuration](/cli/config) moves from layered `tui.json(c)` files to one global `cli.json` file (auto
migrated).
@@ -31,6 +31,17 @@ compatibility bug rather than an expected migration requirement.
Install the V2 terminal client with the [terminal startup guide](/cli).
+You do not need to rewrite everything before starting V2. A practical migration is:
+
+1. Keep your existing configuration and file-based definitions.
+2. Start V2 and verify models, credentials, agents, permissions, and MCP servers.
+3. Port plugins, because V1 plugin implementations do not run in V2.
+4. Port integrations that call the server API.
+5. Convert configuration to the native V2 shape when you are ready. This step is optional.
+
+Keep a copy of your V1 setup while validating the migration. V1 and V2 use the same configuration locations, so do not
+point V1 at files after converting them to native V2-only shapes.
+
## Configuration
This section covers both JSON/JSONC configuration and file-based definitions under `.opencode/`.
@@ -522,10 +533,7 @@ Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object
```jsonc
// V1
{
- "plugin": [
- "opencode-example-plugin",
- ["./plugin/local.ts", { "enabled": true }]
- ]
+ "plugin": ["opencode-example-plugin", ["./plugin/local.ts", { "enabled": true }]]
}
// V2
@@ -541,13 +549,13 @@ Rename `plugin` to `plugins`. Replace a package-and-options tuple with an object
```
V2 discovers local plugins from both `.opencode/plugin/` and `.opencode/plugins/`; use `.opencode/plugins/` for V2 files.
-Moving a file between these directories does not migrate its implementation.
-V1 plugins will not work in V2.
+
+ V1 plugin implementations do not run in V2. Moving a file or renaming its config entry is not enough.
+
-The config entry can be translated automatically, but plugin implementation code must be ported to the released V2 API.
-Use the [Plugins guide](/build/plugins) to replace V1 hooks and entrypoints. Related local modules and dependencies can
-remain with the plugin while you port its implementation.
+Port entrypoints, hooks, tools, events, and package exports with the dedicated
+[plugin migration guide](/build/plugins/migrate-v1).
## Server API and clients
diff --git a/services/www/src/docs/lib/navigation.ts b/services/www/src/docs/lib/navigation.ts
index 0b238030a89..a4e8bc9451f 100644
--- a/services/www/src/docs/lib/navigation.ts
+++ b/services/www/src/docs/lib/navigation.ts
@@ -95,6 +95,7 @@ export const docsSections: DocsSection[] = [
{ title: "Overview", slug: "build/plugins" },
{ title: "RPC", slug: "build/plugins/rpc" },
{ title: "CLI", slug: "build/plugins/cli" },
+ { title: "Migrate from V1", slug: "build/plugins/migrate-v1" },
],
},
{