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:
@@ -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>)
|
||||
Reference in New Issue
Block a user