Skip to content
Agentic Control Plane

OpenCode 2.0 rewrites the plugin API: what breaks, what still works, and how to tell

David Crowe David Crowe · · Updated · 8 min read
opencode hooks permissions plugins reference
Share X HN LinkedIn

The short version. OpenCode 2.0 (tags v2.0.22+, not yet on latest) replaces the plugin contract. A 1.x plugin does not load. Model calls through a configured provider keep working, so the session looks the same. The check is two lines: run a call your policy denies, then look for its audit row.

1.x hooks reference →  ·  1.x permissions reference →  ·  install guide →

We run a daily canary that installs the current release of every harness we support, installs our plugin through the live installer, pushes one governed call through it, and asserts the audit row landed. On 13 October it installed opencode v2.0.27 and reported this:

ACP · session governed: 3 model calls · 2 tool calls · 2.6¢
FAIL no audit row for opencode since 2026-10-13T04:52:21.000Z within 120s

Three model calls priced. Two tool calls run. Zero audit rows. The session receipt said governed; the tool boundary said nothing. Ten hours later the same canary installed 1.19.0 and went green, because upstream had pointed its installer back at a v1 branch. That window is the whole story of this post: a harness release can switch governance off without a single visible error, and the only thing that caught it was a scheduled call with an assertion on the other side.

What is actually in the 2.0 train

Everything below is read from tag v2.0.29 on sst/opencode (tagged 14 October) against v1.19.0. There are no release notes to quote; the release body is the string release: v2.0.29.

The repo is restructured. packages/opencode is gone. The runtime is now @opencode/core, @opencode/cli and @opencode/server. Thirty-six packages where there were a handful.

The plugin package is renamed and rewritten. @opencode-ai/plugin becomes @opencode/plugin, published on npm at 2.0.29 alongside @opencode/schema, @opencode/client and @opencode/sdk. The 1.x shape was a function that returned an object of named hooks. The 2.0 shape is a definition with a setup function that registers hooks imperatively against domains:

import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "example",
  setup: async (ctx) => {
    await ctx.permission.hook("evaluate", (e) => {
      // e.effect is "allow" | "deny" | "ask", already computed from the user's rules
    })
    await ctx.tool.hook("execute.before", (e) => { /* e.tool, e.id, e.input */ })
    await ctx.tool.hook("execute.after", (e) => { /* e.status, e.result | e.error */ })
    await ctx.session.hook("http.request", (e) => { e.request.headers.set("X-Example", "1") })
    return () => { /* cleanup on unload */ }
  },
})

The 1.x hook names do not exist. A search of packages/core/src at v2.0.29 for permission.ask, tool.execute.before, tool.execute.after, chat.headers, event and shell.env finds nothing. They were not renamed with a compatibility alias. They are gone.

The loader rejects a 1.x export. packages/core/src/plugin/module.ts validates the default export and fails with:

Plugin must export a default definition with an id and an effect or setup function.

Config migrates. plugin: [...] is read as legacy and folded into the new plugins list. The v1 permission block and the v1 provider block each have a migration under packages/core/src/v1/config/. Your opencode.json keeps working. That is the part that makes the failure quiet.

Old to new, hook by hook

1.x (@opencode-ai/plugin) 2.0 (@opencode/plugin)
export default async (input, options) => ({ ...hooks }) export default Plugin.define({ id, setup(ctx) })
"permission.ask"(input, output) sets output.status ctx.permission.hook("evaluate", e => { e.effect = … })
"tool.execute.before"(input, output), throw to block ctx.tool.hook("execute.before", e => …), throw to block
"tool.execute.after"(input, output) ctx.tool.hook("execute.after", e => …) with status: "completed" \| "error"
"chat.headers"(input, output) ctx.session.hook("http.request", e => e.request.headers.set(…))
"event" ctx.event.subscribe(…)
"shell.env" ctx.shell.hook("create.before", e => { e.env … })
second argument options ctx.options

Two details worth knowing if you port something. First, the permission event now carries effect already resolved from the user’s rules, so a plugin can see an upstream deny and leave it alone. In 1.x the hook only fired for ask, and the plugin could not tell whether the user had denied the call themselves. Second, the permission request is addressed by action and resources, not by tool name and arguments. edit and write both evaluate as action edit with the target path as the resource; webfetch evaluates with the URL. The tool’s full input still arrives at execute.before, keyed by the same call id that the permission event carries in source.id, so a plugin that wants arguments for its decision caches them there and reads them back in evaluate.

Why nothing tells you

A session on 2.0 with a 1.x plugin still does three things that look like governance. The provider block routes model calls through whatever proxy you configured, so they are priced and show up in a cost receipt. The permission block still prompts for ask rules, so you still see native approvals. And the plugin’s own session-start line never prints, which is the kind of absence nobody notices.

What stops happening: the pre-call check against workspace policy, the pre-approval that skips the prompt when policy says allow, and the audit row per tool call. Those three are the plugin. The receipt is the proxy.

So the check is not “do I see cost” or “do I see prompts”. It is:

  1. Run one tool call your workspace policy denies. If it executes, no plugin is deciding.
  2. Open the audit log for that session. If the model calls are there and the tool calls are not, you are looking at exactly what our canary saw.

What we changed in acp-opencode

OpenCode 2.0 resolves a plugin package by trying <package>/server before <package>. So one npm package can carry both entries: the root export stays the 1.x hooks object, and a ./server export subpath carries the 2.0 definition. 1.x never asks for the subpath; 2.0 asks for it first.

The 2.0 entry maps the same contract onto the new hooks. evaluate posts the call to the workspace, sets effect to allow or deny, leaves ask for OpenCode’s own gate, and never loosens an upstream deny. execute.before is the deny-only backstop for tools that never reach a permission rule. execute.after writes the audit row. http.request stamps the session id so proxy-side spend joins hook-side audit rows. Kill switch, credentials and fail posture are unchanged from 1.x. Not ported yet: the local SQLite metering (the 2.0 event bus has a different shape) and the TUI toasts.

The config you already have keeps working on both:

{
  "$schema": "https://opencode.ai/config.json",
  "plugin": ["acp-opencode"],
  "permission": { "bash": "ask", "edit": "ask", "webfetch": "ask" }
}

Two honest caveats. The 2.0 entry is tested against the contract read from the tag and a fake context, not against a 2.0 binary, because the 2.0 releases ship source archives only and opencode-ai on npm has no 2.x. And the API is a nightly train with no notes; the hook names above are what v2.0.29 has, and a later tag can move them. The canary will tell us when it does.

The general point

Every harness in this space is going to do this at least once. The plugin API is the youngest part of each of them, and the harness authors owe you nothing about it. The failure mode when it changes is not a crash. It is a session that looks identical to yesterday’s with the control layer missing.

Two things hold against that. A scheduled call through the real installed harness with an assertion on the audit side, so the gap is measured the same day and not discovered in a postmortem. And a decision point that sits outside the harness, so the harness changing its plugin surface changes how you attach, not what you enforce.

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog