Version OpenCode skills and plugins

Repository sits at the OpenCode config root rather than inside skills/ or
plugins/. OpenCode scans exactly those two subdirectories, and a .git
directory inside either one is picked up as a plugin and fails to load:

  loading plugin id=.../plugins/.git entrypoint=.../plugins/.git/index
  failed to load plugin ... AggregateError: 2 errors building .../plugins/.git/index

The watcher.ignore config option does not help: it only suppresses filesystem
update events, not plugin discovery, and the "-" disable prefix only applies to
plugins listed in opencode.json(c), not auto-discovered ones.

.gitignore admits only skills/ and plugins/ (plus itself). Personal config
(opencode.jsonc, cli.json, service.json) and node_modules stay local.

skills/opencode-plugins  V2-only plugin authoring reference and 7 templates,
                         transcribed from the V2 docs with the URL on every file
plugins/searxng-search   searxng_search tool over a self-hosted SearXNG JSON API
This commit is contained in:
2026-10-08 13:12:10 +08:00
commit 15a1bbcee0
17 changed files with 3494 additions and 0 deletions
+727
View File
@@ -0,0 +1,727 @@
---
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.
## 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) | <https://opencode.ai/v2/docs/build/plugins/> | Server API, transforms, hooks, tools, publish |
| Effect plugins | <https://opencode.ai/v2/docs/build/plugins/effect/> | `@opencode/plugin/effect` |
| RPC (Promise) | <https://opencode.ai/v2/docs/build/plugins/rpc/> | `Rpc.define`, registration, calls, events |
| RPC (Effect) | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> | Effect-native RPC — same shape, Effect Schema |
| CLI / TUI | <https://opencode.ai/v2/docs/build/plugins/cli/> | `@opencode/plugin/tui` |
| Configure plugins | <https://opencode.ai/v2/docs/plugins> | `plugins` in config, discovery, disabling, CLI |
| Terminal plugins | <https://opencode.ai/v2/docs/cli/plugins> | `cli.json`, CLI-only plugins, discovery |
| CLI settings | <https://opencode.ai/v2/docs/cli/config> | `cli.json` path, no project-local CLI file |
| Client | <https://opencode.ai/v2/docs/build/client> | `client.rpc(Acme)` over HTTP |
| Client (Effect) | <https://opencode.ai/v2/docs/build/client/effect> | Effect client used by Effect RPC |
| API schemas | <https://opencode.ai/v2/docs/api> | Every `Info`/schema linked from Reference blocks |
| Doc index | <https://opencode.ai/v2/llms.txt> | 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/<name>/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 <https://opencode.ai/docs/>. 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
<https://opencode.ai/v2/docs/skills> 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<Model.Info>`, 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 <https://opencode.ai/v2/docs/api> —
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.
## 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<Spec, Failures>` 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
(<https://opencode.ai/v2/docs/build/plugins/effect/>)
- `references/rpc.md` — RPC, Promise and Effect flavors
(<https://opencode.ai/v2/docs/build/plugins/rpc/> and
<https://opencode.ai/v2/docs/build/plugins/effect/rpc/>)
- `references/cli.md` — TUI/CLI plugins
(<https://opencode.ai/v2/docs/build/plugins/cli/>)
- `templates/opencode-plugin-config.jsonc` — full config reference
(<https://opencode.ai/v2/docs/plugins>)