--- name: OpenCode V2 Plugins description: Use when creating, loading, configuring, or publishing OpenCode plugins — server plugins via @opencode/plugin, Effect-native plugins, custom RPC methods, or TUI/CLI plugins. Also use for plugin hooks, transforms, custom tools, provider/model registration, and plugin troubleshooting. metadata: opencode/autoinvoke: true --- # OpenCode V2 Plugins OpenCode plugins extend the service and the terminal. Pick the flavor first, copy the matching file from `templates/`, then read the matching reference. **V2 only.** V1 is out of scope: V1 plugins, `server()`-returned hook maps, the `opencode.ai/docs/` site, and the V1→V2 migration material are all out of scope here. See "Version scope" below. Anything you write under `skills/` or `plugins/` gets committed and pushed in the same turn. See "Push every change" below. ## Push every change `~/.config/opencode` is itself the git repository, and `.gitignore` admits only `skills/`, `plugins/`, and itself. After you create, edit, or delete a file under either tree, commit and push it before you report back. Do not leave the change uncommitted, and do not ask whether to push. ```sh cd ~/.config/opencode git status --porcelain # confirm the change landed in skills/ or plugins/ git add -A # .gitignore limits this to skills/ and plugins/ git commit -m "" git push # origin is git@git.xsinfo.vip:xiaoshuai/oc.git git status -sb # expect "## main...origin/main" with no file lines ``` - **Push over SSH.** `origin` is already the SSH URL. An HTTPS push prompts for a password and there is no TTY, so it fails. - **Do not add machine-local files.** `opencode.jsonc`, `cli.json`, `service.json`, `package.json`, `node_modules/` and any `*.bak*` stay local by design; `git add -A` already excludes them, so do not reach for `git add -f`. - **Never `git init` inside `skills/` or `plugins/`.** A `.git` directory in either one is discovered as a plugin and fails to load on every startup. The repository belongs at the config root, one level above both scan roots. - **Write a real commit message.** "update files" is not a history; name the change and, when it corrects a mistake, say what the mistake was. - **If a push fails, say so and stop.** Report the error text. The commit is local and recoverable, so do not retry silently or run `git reset`. ## Source docs Fetch these. Do not work from this skill alone — it is a summary, and it can drift from the pages. | Page | URL | Covers | | ---- | --- | ------ | | Plugins (Promise) | | Server API, transforms, hooks, tools, publish | | Effect plugins | | `@opencode/plugin/effect` | | RPC (Promise) | | `Rpc.define`, registration, calls, events | | RPC (Effect) | | Effect-native RPC — same shape, Effect Schema | | CLI / TUI | | `@opencode/plugin/tui` | | Configure plugins | | `plugins` in config, discovery, disabling, CLI | | Terminal plugins | | `cli.json`, CLI-only plugins, discovery | | CLI settings | | `cli.json` path, no project-local CLI file | | Client | | `client.rpc(Acme)` over HTTP | | Client (Effect) | | Effect client used by Effect RPC | | API schemas | | Every `Info`/schema linked from Reference blocks | | Doc index | | Machine-readable list of every V2 page | The running server serves an OpenAPI document at `/openapi.json` (`opencode api get /openapi.json`). It self-describes as an experimental HttpApi surface for selected instance routes, so it covers the HTTP contract, not the plugin API — transforms, hooks, and `Plugin.define` are not routes. Use the `/api` reference page for schemas. ## Check the installed version first Plugin APIs **and package names** change between releases, so confirm what the user actually has before writing anything. ```sh opencode --version # e.g. v2.0.24 opencode api get /api/info # version + pid + urls + capabilities opencode plugin list # what is loaded right now opencode service status # the background service URL ``` ### Install the package that matches your release The docs' import specifiers are correct. The package is **not bundled with the CLI** — you install it yourself, at the version matching your OpenCode release: ```sh opencode --version # e.g. v2.0.24 cd ~/.config/opencode npm install @opencode/plugin@$(opencode --version | sed 's/^v//') opencode service restart # required: the running # service caches resolution ``` A missing package fails loudly per-plugin rather than breaking the whole instance: ``` failed to load plugin … Cannot find package '@opencode/plugin' imported from /home/you/.config/opencode/plugins//index.ts ``` A **stale `@opencode-ai/plugin`** (the V1-era package name) sitting in `~/.config/opencode/node_modules` is a red herring. It is a leftover user-side dependency; it does **not** reflect what the host provides. Do not read its export list and conclude that `@opencode/plugin` "does not exist" — check npm (`npm view @opencode/plugin version`) instead. The real `@opencode/plugin` ships a `Plugin` namespace with `Plugin.define`, plus `Model`, `Provider`, `Rpc`, `WebSearch`, `Vcs`, and `Worktree`, and its `./*` subpath resolves `@opencode/plugin/rpc`. TUI plugins also need the peers from that package (`solid-js`, `@opentui/core`, `@opentui/solid`, `@opencode/theme`). ### Verify the default export The host requires a **default export** carrying `id` and either `setup` or `effect`. Without one: ``` failed to load plugin … Plugin must export a default definition with an id and an effect or setup function. ``` If a plugin loads but does nothing, check the log at `~/.local/share/opencode/log/opencode.log`, filtering on the plugin directory name. To confirm the runtime context on an unfamiliar machine, write a read-only probe under `~/.config/opencode/plugins/` that dumps `Object.keys(ctx)` to a file from `setup` — do not register hooks, transforms, or tools. ## Version scope Answer for the V2 plugin API. The V1 plugin shape — a `server()` function returning a map of string keys like `"tool.execute.before"` — is not V2 and must not be produced. A V2 plugin uses `Plugin.define` with typed hooks. Never cite . It documents V1. If a question only makes sense for V1, say so rather than answering from memory. ## Follow the docs and the templates - Start from `templates/`. Copy the file and fill in the blanks rather than writing a plugin from scratch; the templates are transcribed from the pages' own examples. - Cite the page whenever a field name, hook name, or option is uncertain. - Do not invent identifiers. A plausible-sounding hook name that is not in the docs silently never fires, and an invented config field can fail to parse. - Do not "improve" on the documented shape. If you need behavior the docs do not describe, say so and propose the closest supported approach. - Prefer the page's own example over any type signature quoted here. Several reference types in these docs are simplified and omit real parameters. ## Entrypoints Four entrypoints. The RPC contract has a Promise and an Effect variant, so it appears as two rows: | Entrypoint | Import | Runs in | Reference | Template | | ---------- | ------ | ------- | --------- | -------- | | Server (Promise) | `@opencode/plugin` | background service | this file | `templates/promise-plugin.ts` | | Server (Effect) | `@opencode/plugin/effect` | background service | `references/effect.md` | `templates/effect-plugin.ts` | | RPC contract | `@opencode/plugin/rpc` | contract only, no code | `references/rpc.md` | `templates/rpc.ts` | | RPC (Effect contract) | `@opencode/plugin/rpc` | contract only, no code | `references/rpc.md` | `templates/rpc-effect.ts` | | TUI / CLI | `@opencode/plugin/tui` | terminal frontend | `references/cli.md` | `templates/tui-plugin.tsx` | Supporting files: `templates/rpc-implementation.ts` (Promise registration), `templates/rpc-implementation-effect.ts` (Effect registration), `templates/opencode-plugin-config.jsonc` (config reference), `templates/package.json` + `templates/package-json.md` (publishing). Default to the Promise server flavor. Use Effect only when the project already uses Effect. A TUI plugin is a separate export from a server plugin. ## Shape ```ts import { Plugin } from "@opencode/plugin" export default Plugin.define({ id: "example", async setup(ctx) { await ctx.storage.set("loaded", true) }, }) ``` `setup` runs at load and may return a cleanup function that runs at unload. Pick an `id` that will not collide — the page does not give naming guidance. ```ts setup(ctx) { const timer = setInterval(() => void refresh(), 60_000) return () => clearInterval(timer) } ``` Options arrive on the untyped `ctx.options` — narrow before use: `const strict = ctx.options.strict === true`. ## Loading Plugins under a discovered `.opencode/plugins/` directory load automatically. Packages and directories elsewhere must be listed in `plugins` in `opencode.json(c)`: ```jsonc { "$schema": "https://opencode.ai/config.json", "plugins": [ "opencode-acme-plugin", "opencode-acme-plugin@1.2.0", "@acme/opencode-plugin", "./plugins/local", "../shared/plugin.ts", "/absolute/path/plugin.ts", "file:///home/me/plugins/local", { "package": "./plugins/company", "options": { "strict": true } } ] } ``` **Relative paths resolve from the config file containing the entry**, not from the working directory. Plugin arrays from applicable config files apply lowest→highest precedence rather than replacing one another. This is the opposite of the `skills` array, which documents as resolving a relative path "from the active OpenCode working directory, not the config file". Do not transfer one rule to the other. Full reference: `templates/opencode-plugin-config.jsonc`. It also covers discovery paths, the `-` / `*` / `.*` disable syntax, the two built-ins that ignore removals, the `opencode plugin` subcommands (including `list --builtin` and scoping `check`/`update` to one package), and reload/pinning rules. ### Reload Watched config directories reload automatically. Startup loads cached package plugins immediately, installs missing packages in the background, and checks unpinned npm and Git plugins for updates without changing the installed package. Exact versions and full commit hashes stay pinned. Changes to unwatched local dependencies may still need `opencode service restart`. ## Context `ctx` is an OpenCode server client plus plugin-only methods. `ctx.location` is where *this plugin instance* loaded (`directory`, `workspaceID`, `project`), not every session it can reach. The plugins page does not enumerate the Promise context as a single interface, so this is the set that page documents, not a verified full type list. Domains with `transform` + `reload` (synchronous edit, then re-read): `agent` · `provider` · `model` · `command` · `integration` · `mcp` · `reference` · `skill` · `tool` · `vcs` · `websearch` · `worktree` Also documented: `app` · `location` · `options` · `session` · `storage` · `event` · `permission` · `plugin` · `generate` · `shell` · `rpc` (`ctx.rpc.register` / `ctx.rpc` are on the RPC page). Hooks live on four domains: `ctx.session.hook`, `ctx.permission.hook`, `ctx.shell.hook`, `ctx.tool.hook`. The Effect page lists a different, more explicit `Context` that also includes `aisdk` and narrows `experimental` and `plugin`. Do not assume the two flavors expose the same members — see `references/effect.md`. ## Transforms The core pattern: a **synchronous** callback that edits domain state through an editor. The Effect page states it outright — "The transform callback is synchronous: it must not return an Effect or Promise." ```ts await ctx.model.transform((editor) => { editor .list() .filter((m) => m.cost.some((tier) => tier.output > 20)) .forEach((m) => editor.remove(m.providerID, m.id)) }) ``` Rules that cause bugs if forgotten: - Any registration, removal, or `reload()` marks the registry changed. The next read **rebuilds by replaying every active transform in order** onto a fresh value. Keep transforms cheap and repeatable. - A value you already read is never mutated by a later rebuild. - Captured inputs are **not watched**. Load external data *before* the sync callback, then call `reload()` when it changes. - `reload()` replays transforms. It does **not** rerun `setup`. ```ts const source = { providers: await loadFromSource() } await ctx.provider.transform((editor) => { source.providers.forEach((p) => editor.add(p)) }) const refresh = async () => { source.providers = await loadFromSource() await ctx.provider.reload() } const timer = setInterval(() => void refresh().catch(console.error), 60_000) return () => clearInterval(timer) ``` Ordering: provider transforms contribute providers and immutable model definitions first. After availability resolves, OpenCode materializes active providers' models, then replays **every** model transform, so a model transform sees the whole candidate collection. Model-transform details: disabled candidates stay editable until all transforms finish, and `update` can add a model only under an available provider. Callbacks edit raw model overrides; provider defaults merge once when OpenCode commits the result. `editor.provider` exposes immutable **source** definitions (including inactive templates), not model-transform output — use it to clamp against the real limit. `ModelEditor.get` returns `DeepMutable`, and `ProviderEditor.update` hands you a mutable draft, so in-place edits inside an `update` callback are expected. `transform` resolves to a `Registration`; `await registration.dispose()` removes that transform and rebuilds from the rest. Idempotent; plugin unload disposes automatically. Each model request captures a stable tool snapshot, so later transforms, reloads, and disposal affect **future** snapshots only. ## Tools ```ts const registration = await ctx.tool.transform((editor) => { editor.namespace({ name: "acme", description: "Customer account tools" }) editor.add({ name: "greeting", description: "Create a greeting", input: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false, }, options: { namespace: "acme", codemode: true }, execute: async (input, context) => { await context.progress({ status: "greeting" }) return { content: `Hello ${(input as { name: string }).name}!` } }, }) }) ``` - Names are **effective names** including namespace: `acme_greeting`. Dots in namespaces and unsupported characters become `_`. `update`/`remove` use that effective name; only `add` creates a tool. A missing name is ignored. - `update` preserves the name and namespace. **Assign** a new schema or options to replace one rather than mutating nested values. Invalid updates are logged and leave the previous definition intact. - A later valid registration overrides the same effective name; disposing it reveals the earlier one. - Pass `context.signal` to cancellable work so stopping the session stops it: ```ts execute: async ({ url }, context) => { const response = await fetch(url, { signal: context.signal }) return { content: await response.text() } } ``` ## Hooks Hooks intercept live operations and use the same registration model as transforms. Multiple plugins may register the same hook; they run in plugin order, so later hooks see earlier changes. For **model-request** hooks the page states that changes "affect only the outgoing model call, not persisted history or configuration". The **prompt** hook is the exception — its edits become the persisted user input. ### Session hooks | Hook | When | Notes | | ---- | ---- | ------ | | `prompt` | User prompt admission | draft at `event.prompt`; `event.metadata` and `event.delivery` are top level | | `context` | Agent loop, incl. tool continuations | system / messages / tools / options | | `compaction` | Checkpoint summaries | set `result` to skip the model call | | `generate` | `ctx.session.generate` calls | | | `title` | Title generation | no `agent`/`tools`; set `result` to supply the title | | `model.request` | Model request settings | every model request; scope with `{ providerID }` | | `http.request` | Native provider HTTP request | bodies are one-shot streams | | `http.response` | Native provider HTTP response | can replace the response | | `experimental.ws.handshake` | WebSocket connect, once per model call | can change `url` / `headers` | | `experimental.ws.send` | Outbound WS frame | can rewrite `frame` | | `experimental.ws.receive` | Inbound WS frame | can rewrite `frame` | | `retry` | Override the retry decision | `{ retry: false }` or `{ retry: true, delay }` | ```ts await ctx.session.hook("context", (event) => { event.system.push({ type: "text", text: "Keep the review focused on correctness." }) delete event.tools.write event.options.temperature = 0.2 }) await ctx.session.hook("compaction", async (event) => { event.result = { summary: await summarize(event.messages) } }) await ctx.session.hook("retry", (event) => { if (event.error.status === 429) event.decision = { retry: true, delay: 10_000 } if (event.attempt >= 3) event.decision = { retry: false } }) await ctx.session.hook( "model.request", (event) => { event.headers["x-plugin"] = "review" }, { providerID: "anthropic" }, ) // The page's "Provider options" section shows `options` on the `context` hook. // `model.request` edits headers instead. await ctx.session.hook( "context", (event) => { event.options.reasoningEffort = "high" }, { providerID: "openai" }, ) ``` Details that matter: - `options` starts **empty** each call; it does not contain resolved model settings. Request overrides beat model defaults, which beat route defaults. `delete` an override or set `undefined` to fall back to configured defaults. - Provider options use the protocol's **semantic** names, not raw HTTP body fields (`reasoningEffort`, `maxTokens`; `topK` exists on Gemini but not OpenAI Responses). - `event.kind` is `"primary" | "compaction" | "title" | "generate"`, and it exists on the `model.request`, `http.request`, `http.response`, and `experimental.ws.*` hooks. It is **not** on `context`, `compaction`, `generate`, `title`, `prompt`, or `retry` — use `agent` and the hook itself to tell auxiliary requests apart on those. - `attempt` is the physical attempt; the initial request is `1`, so the first retry is `2`. The built-in max attempt count is a hard limit. Invalid delays fall back to the computed delay. - Context-overflow recovery is separate: it compacts the conversation rather than retrying the same request. - A hook that must apply to every request registers for each kind. WebSocket caveat: providers streaming over a WebSocket open one connection per session and reuse it, so `http.request` and `http.response` never see that traffic. Changing `experimental.ws.handshake`'s `url` or `headers` changes which socket is used, so rotating a token reopens the connection. The HTTP hooks still run for any request a WebSocket route falls back to. OpenCode does not validate rewritten frames. ### Prompt hook Intercepts user prompts before attachment/skill resolution and durable inbox admission. ```ts await ctx.session.hook("prompt", (event) => { event.prompt.text = event.prompt.text.replaceAll("company-secret", "[redacted]") event.delivery = "queue" }) ``` - `event.prompt` holds `text`, `files`, agent mentions in `agents`, and selected `skills`. `metadata` and `delivery` are separate top-level fields. - Runs once at admission, not before every model call. Session and message IDs are readonly; a hook cannot redirect admission. - Runs in registration order after the session's location plugins are ready. Commands submitted through `session.prompt` run it. Synthetic messages, shell messages, compaction controls, and move controls do not. - No provider scoping — model resolution happens after admission. - Not an exactly-once side-effect boundary. Retrying an ID already **pending or delivered** returns the original admission without rerunning hooks, and concurrent submissions can run hooks more than once — but only the first successful admission wins. - **Edits become the canonical persisted user input.** - Files and skills added by a hook follow the normal resolution and validation path. When rewriting text, fix or drop attachment `mention` offsets that no longer match. Agent mentions do not switch the active agent. - Prompt hooks transform input only — there is **no typed rejection API**. ### Other domains ```ts await ctx.permission.hook("evaluate", async (event) => { if (event.action === "read") return event.effect = "allow" // or "ask" | "deny" event.message = "reason shown on escalation" }) await ctx.shell.hook("create.before", (event) => { if (event.command === "npm") event.command = "bun" event.timeout = Math.min(event.timeout, 60_000) event.env.COMPANY_ENV = "development" // also has cwd and shell }) await ctx.tool.hook("execute.before", (event) => console.log(event.tool, event.input)) await ctx.tool.hook("execute.after", (event) => { if (event.status === "error") console.error(event.error.message) }) ``` Permission hooks run for `allow` and `ask` decisions. An explicit configured `deny` is final and never invokes the hook. ## Storage Durable JSON scoped to the plugin. Unlike model/provider reads, storage returns values directly — see the per-operation note under Effect reads. ```ts await ctx.storage.set("settings", { strict: true }) const settings = await ctx.storage.get("settings") const page = await ctx.storage.scan({ prefix: "cache/", limit: 100 }) // page.entries, page.next for cursor pagination await ctx.storage.remove("settings") ``` ## Events ```ts const controller = new AbortController() void (async () => { for await (const event of ctx.event.subscribe({ signal: controller.signal })) { console.log(event.type) } })() return () => controller.abort() // must abort during cleanup ``` ## Sessions ```ts const created = await ctx.session.create({ title: "Review" }) await ctx.session.prompt({ sessionID: created.id, text: "Review the current changes" }) const summary = await ctx.session.generate({ sessionID: created.id, prompt: "Summarize this project" }) const command = await ctx.session.command({ sessionID: created.id, command: "review", arguments: "--staged" }) const synthetic = await ctx.session.synthetic({ sessionID: created.id, text: "Deployment completed" }) await ctx.session.compact({ sessionID: created.id }) await ctx.session.remove({ sessionID: created.id }) // stops work, deletes children ``` Also available: `get`, `context`, `switchAgent`, `switchModel`, `update`, `interrupt`, `wait`, `move`. `compact` returns the **admitted inbox item**, not a finished summary; compaction runs at the next step boundary. Pass `delivery: "queue"` to preserve queue order. `generate.text` runs a model with no session, no tools, no history — handy for a second opinion inside a hook: ```ts const review = await ctx.generate.text({ model: { providerID: "anthropic", id: "claude-sonnet-4-6" }, prompt: "Review this proposed action.", }) ``` ## Commands The command transform is add-only. ```ts 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, }) }, }) }) ``` ## MCP Plugins manage MCP servers **only** through transforms. ```ts await ctx.mcp.transform((editor) => { editor.set("docs", { type: "remote", url: "https://mcp.example.com" }) editor.update("docs", (server) => { server.disabled = false }) editor.remove("legacy") }) ``` Setting `disabled: true` disconnects; OpenCode reconciles server lifecycle from the transformed config. Call `reload()` after external changes. Prefer `opencode mcp add … --global` for ordinary MCP setup; a transform is for programmatic or dynamic control. ## Worktrees Every operation needs a `projectID`, available as `ctx.location.project.id`. `list` reads only saved inventory — it does not load config, start plugins, or discover filesystem changes. ```ts const projectID = ctx.location.project.id const created = await ctx.worktree.create({ projectID, name: "task" }) const inventory = await ctx.worktree.list({ projectID }) await ctx.worktree.refresh({ projectID }) await ctx.worktree.remove({ projectID, directory: created.directory, force: false }) ``` - Adding a strategy via `ctx.worktree.transform` selects it automatically — the last active registration wins. Disposing or unloading restores the previous implementation, ultimately the bundled Git strategy. - `create` loads config and plugins from the project's saved `canonical` directory and waits for those plugins to activate. A `from` override must belong to that project; choosing a source does not change the config root. **Exception:** calls made *during* that checkout's plugin setup see the registrations made so far and do not wait for their own activation. - `remove` uses the worktree's **recorded** strategy, not the current default. A missing owner **fails** rather than falling back to Git. A strategy can request force confirmation with `new Worktree.OperationError({ message, forceRequired: true })`, importing `Worktree` from `@opencode/plugin`. - `refresh` combines all available strategies, preserves recorded ownership, and prunes missing directories. Observe `worktree.updated` and call `list` again. - Creation failures do not retry through Git. Strategies manage local directories only — remote provisioning is not part of this interface. ## Publish `templates/package.json` is strict JSON; `templates/package-json.md` carries the guidance, the peer dependencies, and the publishing steps. Entrypoint summary: | Export | Needed for | Documented on | | ------ | ---------- | ------------- | | `.` | a server plugin | build/plugins | | `./rpc` | publishing a shared RPC contract; described there as optional | build/plugins | | `./tui` | the TUI plugin; a package listed in `opencode.json(c)` that exposes it is loaded automatically by the CLI | build/plugins/cli, cli/plugins | Do not add a package that exports `./tui` to `cli.json` as well — the CLI gets the active plugin list from the server, so it already works remotely. ## The `$schema` caveat The V2 docs' own examples include `"$schema": "https://opencode.ai/config.json"`, but that file does not match the V2 config shape. Verified against the live file: its `Config` has `additionalProperties: false`, has **no `plugins` key** (only a singular `plugin`), types `skills` as an object rather than the V2 array, and has no `worktree` key. `https://opencode.ai/v2/config.json` returns 404. So keeping that line makes some editors flag the documented V2 `plugins` array as an invalid field, and there is no published V2 config schema to replace it with. Keep the line to match the docs, or drop it for a clean editor — either way, **never infer V2 field names or shapes from that file.** For `cli.json` the schema is `https://opencode.ai/v2/cli.json`. ## Type-checking locally: expect noise `tsc --strict` against the installed `@opencode/plugin` surfaces friction the docs do not mention. Verified, with the fix used in the templates: - **Branded values (Promise flavor).** `Model.ID`, `Provider.ID`, and `Model.cost[].input/.output` plus the cache fields are branded types. The docs write `editor.remove(model.providerID, model.id)` and `tier.output > 20`; against the real types you need `String(...)` on **both** `remove` arguments and `Number(...)` on the money fields. The Effect flavor's `DeepMutable` unwraps the brands, so the docs' form compiles there. - **Two copies of `effect`.** The host redirects plugin imports of `effect` to its own instance, so a local `effect` in `node_modules` is a *different* copy. Effect templates then fail with `Property '[NodeInspectSymbol]' is missing` and similar type-identity errors, and Effect `Schema.Struct` stops satisfying `ValueSchema` (`Codec | StandardSchemaV1 | JsonSchema`). These are **local typecheck artifacts, not plugin bugs** — the Effect page requires the host's instance, which is exactly what you get at runtime. Install `effect` at the version `@opencode/plugin` depends on if you want a clean typecheck. - **JSX.** A TUI template reports `Cannot use JSX unless the '--jsx' flag is provided` and `JSX.IntrinsicElements` is missing until you pass `--jsx` and install the Solid/OpenTUI peers. Do not "fix" these by loosening the code or by abandoning a documented form such as Effect Schema. Use the narrowing casts the templates show, and treat the remaining Effect and JSX errors as local-environment noise. ## Troubleshooting - Plugin not loading: it must be under a discovered `.opencode/plugins/` directory or listed in `plugins`. A `plugins/` directory beside a project-root `opencode.json(c)` is not auto-discovered. `.opencode/skills` is for skills, not plugins. Confirm with `opencode plugin list`. - Plugin loads but nothing changes: a `*` entry plus a `-id` disable elsewhere, a wrong relative path (config-file-relative, not working-directory-relative), or a change to an unwatched local dependency. - Overridden tool: a later registration wins. `dispose()` the later one to reveal the earlier definition. - Hook not firing: check the hook name exists on the page for **your** flavor — the Effect `SessionHooks` set differs from the Promise one. Also confirm `context` covers only the agent loop. - Type errors in a transform callback: the callback is synchronous. - Nothing in the model list: a provider transform registered a definition but no connection, so it is not available. A registered definition does not mean its resource work finished. - Environment: `~/.local/share/opencode/log/opencode.log`, filter `role=server`. `--print-logs` alone prints **client** logs; server logs (which is where plugin loading shows up) need `opencode --standalone --print-logs`. For a debug run use `opencode --log-level debug`. - Stuck service: `opencode service restart`, then `opencode service status`. Compare with `opencode --standalone` to isolate shared-service issues. ## Verifying before you answer 1. Confirm the installed version (`opencode --version`, `opencode api get /api/info`). 2. Check the doc page for that area for the exact identifier, and cite it. 3. For schema shapes, use `/openapi.json` or — the `Reference` block at the end of each API section points at the right schema. 4. If a page is silent, or a claim cannot be traced to a page, say so and mark it as inference. Do not guess a field name or present an inference as documented. 5. If you touched anything under `skills/` or `plugins/`, confirm it is pushed before you answer: `git -C ~/.config/opencode status -sb` must show `## main...origin/main` and no file lines. ## Rules the pages state - A `transform` callback is typed `(editor) => void` in both flavors. - Tool `update` preserves name and namespace; assign new schemas or options rather than mutating nested values. - `reload()` replays transforms. It does not rerun `setup` (Promise) or the plugin `effect` (Effect). - Use the effective tool name including its namespace for `update`/`remove`. - Tear down event subscriptions: `AbortController` in Promise, `forkScoped` in Effect. - Prefer a page's example over any simplified type signature quoted in a reference — the docs' shared `Hooks` type, for instance, omits the provider-scoping argument their own examples pass. ## References - `templates/README.md` — picking and using the starter files - `references/effect.md` — Effect plugins () - `references/rpc.md` — RPC, Promise and Effect flavors ( and ) - `references/cli.md` — TUI/CLI plugins () - `templates/opencode-plugin-config.jsonc` — full config reference ()