AGENTS.md files keep working. Your plugins and server integrations do not.OpenCode V1 OpenCode V2 ─────────────────────────── ────────────────────────────────────── opencode.json (permission, agent) ───> same file works, native shape optional .opencode/ agents, skills, commands ─> works unchanged tui.json ───> ~/.config/opencode/cli.json (automatic) plugin: fn returning hooks ───> Plugin.define({ id, setup }) REWRITE @opencode-ai/sdk (and its /v2) ───> @opencode/client REWRITE one server per client ───> one shared background service
| Area | OpenCode V1 | OpenCode V2 | Action |
| Plugins | function returning a hooks object | Plugin.define({ id, setup }) | rewrite |
| Server API | @opencode-ai/sdk | @opencode/client | rewrite |
| Terminal config | layered tui.json(c) | one global cli.json | automatic |
| Config, agents, skills | opencode.json, .opencode/ | same files and paths | none |
| Process model | each client starts a server | one shared service for TUI, desktop and web | check integrations |
opencode. They do not run side by side. The V2 installer replaces the V1 binary, so keep a copy of your V1 setup while you test.12curl -fsSL https://opencode.ai/v2/install | bash opencode --version
12Migrate my OpenCode configuration, including file-based definitions, from the V1 format to the native V2 format. Preserve its behavior and all unrelated settings.
permissions array. Actions were renamed too: bash is shell, task is subagent, write and patch are edit.1234567891011121314// V1 { "permission": { "bash": { "git push *": "ask" }, "edit": "allow" }, "tools": { "websearch": false } } // V2 { "permissions": [ { "action": "shell", "resource": "git push *", "effect": "ask" }, { "action": "edit", "resource": "*", "effect": "allow" }, { "action": "websearch", "resource": "*", "effect": "deny" } ] }
external_directory is allowed by default in V2. V1 asked before reading paths outside the project. Add { "action": "external_directory", "resource": "*", "effect": "ask" } to keep the old behavior.#:| V1 | V2 |
agent, mode | agents (prompt becomes system, disable becomes disabled) |
model + variant | "anthropic/claude-sonnet-4-5#high" |
command (subtask) | commands (subagent) |
plugin: ["pkg", ["./x.ts", opts]] | plugins: ["pkg", { "package": "./x.ts", "options": opts }] |
mcp.<name> with enabled | mcp.servers.<name> with disabled |
provider with npm, api | providers with package: "aisdk:...", settings.baseURL |
skills.paths, skills.urls | one skills array |
snapshot, attachment, reference | snapshots, media, references |
compaction.preserve_recent_tokens | compaction.keep.tokens |
lsp config but runs no language servers. It also reads only AGENTS.md, not CLAUDE.md. Todos are off by default; enable them with experimental.todo: true.id and registers everything inside setup(ctx). The full reference is the plugin migration guide.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' }) }, })
event instead of input and output.| V1 hook | V2 API |
event | ctx.event.subscribe() |
tool map, tool.definition | ctx.tool.transform(...) |
tool.execute.before / after | ctx.tool.hook('execute.before' / 'execute.after', ...) |
shell.env | ctx.shell.hook('create.before', ...) |
chat.message | ctx.session.hook('prompt', ...) |
chat.params, experimental.chat.system.transform | ctx.session.hook('context', ...) |
chat.headers | ctx.session.hook('model.request', ...) |
permission.ask | ctx.permission.hook('evaluate', ...) |
auth | ctx.integration.transform(...) |
experimental.session.compacting | ctx.session.hook('compaction', ...) |
dispose | cleanup function returned by setup |
context runs only before agent requests. Register compaction, generate and title hooks too if a change must reach every model request. Kimaki registers all four.tool() helper is gone. Tools use plain JSON Schema and return structured content. Transforms are replayed, so keep the callback synchronous and free of side effects.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}!` } }, }) }) }, })
ctx.session.hook('context') runs on every request and sits before all messages.1await client.session.instructions.entry.put({ sessionID, key: 'my-integration', value: systemPrompt })
@opencode-ai/sdk/v2. That export is a transitional client over V1 routes. Changing your import to it does not migrate you to OpenCode V2.| Role | OpenCode V1 | OpenCode V2 |
| Network client | @opencode-ai/sdk (including /v2) | @opencode/client |
| Embedded host | @opencode-ai/sdk | @opencode/sdk |
| Plugin API | @opencode-ai/plugin | @opencode/plugin |
| CLI package | opencode-ai | @opencode/cli |
123456789import { OpenCode } from '@opencode/client' import { Service } from '@opencode/client/service' const endpoint = (await Service.discover({ file })) ?? (await Service.ensure({ file, command: ['opencode', 'serve', '--service', '--hostname', '127.0.0.1'] })) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const info = await client.server.info()
.data, .error, nested path/query/body and throwOnError.| OpenCode V1 | OpenCode V2 |
session.create({ directory, permission }) | session.create({ location: { directory }, permissions }) |
session.promptAsync({ parts }) | session.prompt({ sessionID, text, files, delivery }) |
promptAsync({ noReply: true }) | session.prompt({ sessionID, text, resume: false }) |
promptAsync({ system }) | session.instructions.entry.put({ sessionID, key, value }) |
session.abort(...) | session.interrupt({ sessionID, resume? }) |
session.update({ permission }) | session.update({ sessionID, permissions }) |
| question APIs and events | session.form.* and form.* events |
message.part.* snapshots | session.text.*, session.reasoning.*, session.tool.* events |
12345678910const session = await client.session.create({ location: { directory }, permissions: [{ action: 'edit', resource: `${directory}/**`, effect: 'allow' }], }) await client.session.prompt({ sessionID: session.id, text: 'Fix the failing test', delivery: 'steer', })
session.prompt returns the accepted inbox item, not the reply. The answer arrives on the event stream.session.prompt({ delivery: 'steer' | 'queue' }) │ ├──> session.inbox.enqueued ├──> session.execution.started start typing, start timer ├──> session.inbox.delivered prompt is now visible history │ ├──> session.text.delta ... session.text.ended full text on ended ├──> session.tool.input.* ... session.tool.success | failed │ └──> session.execution.succeeded | failed | interrupted
delivery: 'steer' lands at the next safe step boundary of the running turn.delivery: 'queue' waits until the current execution finishes.resume: false adds the message without waking the agent.session.execution.succeeded as the "turn done" signal. session.idle is deprecated and also fires after an interrupt.client.event.subscribe() is a lazy async iterable. On reconnect you must read state again (active sessions, inbox, pending forms, permissions). Missed deltas are gone; restore the final values.message.list and session.list before you pick "the latest user message".session.list to a directory or project, or you mirror sessions from other projects.session.form.list returns only pending forms. Questions are forms with metadata.kind = "question".permission.reply takes decision (once, always, reject), not reply.switchAgent or switchModel fails, stop. Do not send the prompt with the old model.parentID. The tool is subagent, its input is agent, and the child is linked through session.created.data.parentID.curl -fsSL https://opencode.ai/v2/install | bashexternal_directory rule added if you want V1 behaviorPlugin.define and a stable id@opencode-ai/sdk (and /v2) to @opencode/clientsession.execution.*, not session.idlecontext hookopencode command and the same config locations. Do not point V1 at a config you converted to the native V2 shape.@opencode/client for network access and @opencode/sdk for embedding.