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>)
+543
View File
@@ -0,0 +1,543 @@
# TUI / CLI Plugins
**Docs: <https://opencode.ai/v2/docs/build/plugins/cli/>** — fetch this page
before writing a TUI plugin. This file is a summary of it.
**Loading: <https://opencode.ai/v2/docs/cli/plugins>**
**Template: `../templates/tui-plugin.tsx`**
`@opencode/plugin/tui` extends the **terminal frontend** with commands, routes,
slots, Markdown renderers, notifications, and local state. A TUI plugin is a
separate export from a server plugin — one package can have both.
```ts title="src/tui.ts"
import { Plugin } from "@opencode/plugin/tui"
export default Plugin.define({
id: "acme.cli",
setup(context) {
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
},
})
```
Import `@opencode/plugin/tui` directly. OpenCode resolves it at runtime, so a
local plugin needs no absolute path to an OpenCode checkout. `setup` may return
a cleanup function.
## Context
`setup` receives configuration, app metadata, the current location, the client,
cached data, theme tokens, the OpenTUI renderer, and the UI APIs.
```ts
setup(context) {
const compact = context.options.compact === true
const location = context.location ?? context.data.location.default()
const version = context.app.version
const channel = context.app.channel
const client = context.client
const renderer = context.renderer
const theme = context.theme
}
```
`context.client` is the generated client and works against a remote server.
```ts
const response = await context.client.plugin.list({
location: context.location ?? context.data.location.default(),
})
const plugins = response.data
```
## Events
`data.on` handles one typed event, `data.listen` handles every server event.
Both return an unsubscribe function.
```ts
const stopPermission = context.data.on("permission.asked", (event) => {
context.ui.toast.show({ message: `Permission ${event.data.id}` })
})
const stopAll = context.data.listen(({ details }) => console.log(details.type))
return () => {
stopPermission()
stopAll()
}
```
## Cached data
Reads are served from local cached data; the page also exposes `sync` and
invalidation ("synchronization, and invalidation" for sessions, "refresh
controls" for location). Do not assume `invalidate` empties the cache or that a
`list()` immediately after it returns an empty array — the page does not say
that.
Most collections expose `list`, `sync`, and `invalidate`, but the exact set varies
and **`get` is not universal**. Documented `get` / lookup: `session.get`,
`session.message.get`, and the "lookup" mentioned for pending items; `project.get`
and the lookup for saved project permissions; and `shell.get`. The
location-scoped catalogs (agents, commands, integrations, models, providers,
references, skills, MCP servers and resources) are documented with `list`,
`sync`, and `invalidate` only. Other exceptions: `session.root` / `family` /
`cost` / `status` are separate, `location.default()` has no `list`/`get`, and
version control uses `vcs.info(...)`. Check the name in the page's example.
```ts
// sessions: list, get, root, family, cost, status, sync, invalidate
const rootID = context.data.session.root(sessionID)
const cost = context.data.session.cost(sessionID)
const status = context.data.session.status(sessionID)
await context.data.session.sync(sessionID)
context.data.session.invalidate(sessionID)
// pending inbox items and messages
await context.data.session.pending.sync(sessionID)
const pending = context.data.session.pending.list(sessionID)
await context.data.session.message.sync(sessionID)
const messages = context.data.session.message.list(sessionID)
// permissions, and forms at a location
await context.data.session.permission.sync(sessionID)
const requests = context.data.session.permission.list(sessionID) ?? []
await context.data.session.form.sync(sessionID, location)
const forms = context.data.session.form.list(sessionID, location) ?? []
await context.data.session.form.reply(reply, location)
await context.data.session.form.cancel(cancel, location)
// projects, shells, location, and version control
await context.data.project.sync()
await context.data.shell.sync(context.location)
await context.data.location.vcs.sync(context.location)
const branch = context.data.location.vcs.info(context.location)?.branch.current
```
Location-scoped catalogs — agents, commands, integrations, models, providers,
references, skills, MCP servers, MCP resources — all take a `location` and
return `?? []` when empty.
## Attention
System notification, sound, or both, based on terminal focus.
```ts
const result = await context.attention.notify({
title: "OpenCode",
message: "Session done",
notification: { when: "blurred" },
sound: { name: "done", volume: 0.5, when: "always" },
})
// result.ok, result.notification, result.sound, result.skipped
```
## Theme and renderer
Use semantic theme tokens with OpenTUI elements and pass `context.renderer` to
renderer-specific helpers.
```tsx
const Status = () => <text fg={context.theme.text.base}>Ready</text>
```
Inside JSX rendered by a route, dialog, or slot, get the context with
`usePlugin`:
```tsx
import { usePlugin } from "@opencode/plugin/tui"
function Status() {
const context = usePlugin()
return <text fg={context.theme.text.base}>{context.app.version}</text>
}
```
## Storage
`store` persists JSON across restarts and syncs across TUI instances. `memory`
survives plugin reloads but dies with the TUI.
```ts
const [settings, updateSettings] = context.storage.store("settings", {
initial: { compact: false },
})
await updateSettings((draft) => {
draft.compact = true
})
const [state, updateState] = context.storage.memory("state", { initial: { count: 0 } })
```
## Commands and keymaps
```ts
context.keymap.layer(() => ({
mode: "global",
priority: 10,
commands: [
{
id: "acme.status",
title: "Show Acme status",
group: "Acme",
bind: "ctrl+g",
palette: true,
slash: { name: "acme", aliases: ["status"], arguments: true },
enabled: () => true,
suggested: true,
run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }),
},
],
bindings: ["acme.status"],
}))
```
Call `layer` from `setup` — including after an `await` — or from a component.
The **layer factory must be pure**. OpenCode evaluates it once without
dependency tracking to validate the initial command shape, then reactively.
A layer is removed at the first of: the plugin is disabled/reloaded/fails
setup, or the component that created it is disposed. A layer may target one
renderable and return `false` from a command to continue keyboard dispatch.
```ts
context.keymap.layer(() => ({
target: () => panel,
commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }],
}))
```
Initial shape errors — a palette command with no ID — throw from `layer()`.
Binding-parse errors are handled by OpenTUI; errors from later reactive
updates follow Solid's rules.
Dispatch and inspection:
```ts
context.keymap.dispatch("acme.status", "verbose")
context.keymap.shortcuts("acme.status")
context.keymap.commands()
context.keymap.pending()
context.keymap.active()
const popMode = context.keymap.mode.push("acme-search")
popMode()
```
## Dialogs and toasts
```ts
await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
const confirmed = await context.ui.dialog.confirm({
title: "Continue?",
message: "Run the Acme action?",
label: { confirm: "Run", cancel: "Cancel" },
})
const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" })
const mode = await context.ui.dialog.select({
title: "Mode",
current: "safe",
options: [
{ title: "Safe", value: "safe", description: "Ask before changes" },
{ title: "Fast", value: "fast", category: "Advanced" },
],
})
```
Custom `search` owns matching and order. It runs for **every** query including
the empty one, and receives only enabled options.
```ts
const project = await context.ui.dialog.select({
title: "Projects",
options: [
{ title: "Alpha", value: "alpha", category: "Recent" },
{ title: "Beta", value: "beta", category: "Pinned" },
],
search: (query, options) =>
options
.filter((o) => o.title.toLowerCase().includes(query.toLowerCase()))
.toSorted((l, r) => r.title.localeCompare(l.title)),
})
```
Select **actions** appear in the footer and run from their keyboard binding, a
click, or Tab focus followed by Enter. By default they receive the selected value
and are **unavailable without a selection** — use `selection: "none"` for actions
that need none. They do not close the dialog or settle the promise.
```ts
await context.ui.dialog.select({
title: "Projects",
options: [{ title: "Alpha", value: "alpha" }],
actions: [
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
{ bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
],
})
```
- An action does not close the dialog or settle the promise.
- Enter or a click resolves the selected value. Escape, `clear()`, or another
dialog resolves `undefined`.
- **Do not bind `ctrl+n` or `ctrl+p`.** The dialog reserves them for navigation
and its own bindings win.
Custom JSX dialogs set their own size and close themselves:
```tsx
context.ui.dialog.set({ size: "large", centered: true })
context.ui.dialog.show(
() => (
<box>
<text>Acme</text>
</box>
),
() => console.log("closed"),
)
context.ui.dialog.clear()
```
Toasts take `title`, `message`, `variant`, and `duration`.
## Markdown
```ts
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
return unregister
```
## Routes and tabs
```tsx
const unregister = context.ui.router.register({
name: "dashboard",
render: ({ data }) => <text>{String(data?.title ?? "Acme")}</text>,
})
context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } })
context.ui.router.navigate({ type: "session", sessionID })
context.ui.router.navigate({ type: "home" })
```
```ts
if (context.ui.tabs.enabled()) {
context.ui.tabs.open(backgroundSessionID) // leaves focus unchanged
context.ui.tabs.focus(sessionID) // opens if needed
const tabs = context.ui.tabs.list()
context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
context.ui.tabs.close(sessionID)
}
```
## Model
```ts
const selected = context.ui.model.current()
const variants = context.ui.model.variant.list()
context.ui.model.variant.set("high")
context.ui.model.variant.set(undefined) // back to the model default
```
`current()` and `variant.list()` are reactive when read in a Solid computation.
`variant.set` returns `false` when no model is selected or the variant does not
exist. A common use is replacing the built-in variant cycle:
```ts
context.keymap.layer(() => ({
commands: [
{
id: "acme.variant.cycle",
title: "Cycle variants",
bind: "tab",
run() {
const variants = context.ui.model.variant.list()
if (variants.length === 0) return
const index = variants.indexOf(context.ui.model.current()?.variant ?? "")
context.ui.model.variant.set(variants[(index + 1) % variants.length])
},
},
],
}))
```
## Slots
Insert or replace JSX at `app`, `home.footer`, `home.footer.status`,
`prompt.footer`, `prompt.footer.status`, `prompt.footer.file`,
`session.composer.top`, `sidebar.content`, or `sidebar.footer`.
Placements: `prepend`, `append`, `before`, `after`, `replace`.
```tsx
return context.ui.slot({
append: "sidebar.content",
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
})
context.ui.slot({ append: "home.footer.status", render: () => <text>SYNCED</text> })
```
Use `home.footer.status` to add to the built-in footer row **without replacing
it**; status contributions appear after health indicators and before the version.
### Session panels
Register a contribution to `session.panel`, then open it from a command. The
host owns sizing, focus, and full-screen presentation; the plugin owns content.
```tsx
import { Show } from "solid-js"
context.ui.slot({
append: "session.panel",
render: (panel) => (
<Show when={panel.name === "acme.review"}>
<ReviewPanel panel={panel} />
</Show>
),
})
context.ui.slot({
append: "app",
render: () => {
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "acme.review",
title: "Open review",
slash: { name: "review" },
run: () => context.ui.panel.open("acme.review"),
},
],
}))
return null
},
})
```
- Opening outside a session returns `false`.
- This is an ordinary slot: all five placements and the existing replacement
ordering rules apply. `append` is what lets independently selectable
contributions coexist; `replace` still takes over the slot.
- Names are **shared selection values, not registered claims**. Use a
plugin-prefixed name. Opening a name with no matching renderer leaves the slot
empty.
- Changing presentation preserves mounted contributions. Closing the panel
disposes them; disabling a plugin removes its contributions through normal
slot cleanup.
- Its keyboard layers and input modes are active only while the panel owns input.
- The slot receives reactive `name`, `sessionID`, `width`, `presentation`, and
`focused`, plus `focus`, `close`, and `toggleFullscreen`. The host keeps narrow
terminals full-screen, so `toggleFullscreen` does nothing until there is room.
```tsx
import type { PanelInput } from "@opencode/plugin/tui/context"
import { usePlugin } from "@opencode/plugin/tui"
function ReviewPanel(props: { panel: PanelInput }) {
const context = usePlugin()
context.keymap.layer(() => ({
commands: [{ id: "acme.review.fullscreen", bind: "f", run: props.panel.toggleFullscreen }],
}))
return <text>Reviewing {props.panel.sessionID}</text>
}
```
```ts
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
const current = context.ui.panel.current()
context.ui.panel.close()
```
## Formatting
```ts
const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
```
Abbreviates the home directory.
## Publish and load
Expose the TUI plugin through `./tui` and add OpenTUI peers when rendering JSX.
```json title="package.json"
{
"name": "opencode-acme-plugin",
"type": "module",
"exports": {
".": "./src/index.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": { "@opencode/plugin": "latest" },
"peerDependencies": {
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"solid-js": ">=1.9.0"
}
}
```
Exporting `./tui` beside the main plugin makes the CLI load it automatically.
The docs say you **do not need** to add the same package to `cli.json` — the CLI
gets the active plugin list from the connected OpenCode server, so this also
works when the server is remote.
Use `cli.json` only for **CLI-only** plugins that run locally in the terminal and
must stay active against a remote server:
```json title="cli.json"
{
"$schema": "https://opencode.ai/v2/cli.json",
"plugins": [
"opencode.example",
"opencode.example@1.0.0",
"@example/opencode-tui",
"@example/opencode-tui@1.0.0",
"./plugins/status",
"../plugins/status",
"/home/user/plugins/status",
"file:///home/user/plugins/status"
]
}
```
Entries are processed in order. Prefix an ID or wildcard with `-` to disable
matching plugins. The `cli/plugins` page shows `["*", "-opencode.notifications",
"-team.*"]` as its example; the explicit meaning of `*` and `.*` is spelled out
only on the `plugins` config page, so treat the wildcard semantics here as
inferred from that example.
```json title="cli.json"
{ "plugins": ["*", "-opencode.notifications", "-team.*"] }
```
Options use the object form:
```json title="cli.json"
{
"$schema": "https://opencode.ai/v2/cli.json",
"plugins": [{ "package": "@example/opencode-tui", "options": { "compact": true } }]
}
```
`cli.json` lives at `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json`. **There is no project-local CLI settings
file** — a `cli.json` next to a project `opencode.json(c)` is not read.
(Source: <https://opencode.ai/v2/docs/cli/config>)
Server plugins belong in `opencode.json(c)`.
OpenCode also discovers plugins under the global config directory and project
`.opencode` directories, each using the published-package layout with server and
TUI entrypoints together:
```text title="Plugin discovery paths"
<global-config>/plugins/status/index.ts
<global-config>/plugins/status/tui.ts
<project>/.opencode/plugins/status/index.ts
<project>/.opencode/plugins/status/tui.ts
```
@@ -0,0 +1,403 @@
# Effect Plugins
**Docs: <https://opencode.ai/v2/docs/build/plugins/effect/>** — fetch this page
before writing an Effect plugin. This file is a summary of it.
**Template: `../templates/effect-plugin.ts`**
`@opencode/plugin/effect` is the Effect-native flavor of the server plugin API.
Context operations return Effects or Streams, callbacks return Effects, and
lifetime is a `Scope`. Use it when the project already uses Effect. Otherwise
use the Promise flavor in `SKILL.md`.
## Install
OpenCode provides `effect`, `effect/*`, and `@opencode/plugin` at runtime. Do
**not** bundle them. The page says to install `effect` and `@opencode/plugin` as
peer/dev dependencies for local type-checking and testing.
```sh
bun add --peer @opencode/plugin effect
```
```json title="package.json"
{
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
}
```
If you bundle with a bundler, mark `effect` and `effect/*` external. OpenCode
redirects these imports (including from your `node_modules`) to the host's
runtime so fibers, loggers, and `Schema` parsers share one copy. Dependencies
built on another Effect major (e.g. Effect 3) are **not** supported. Importing a
private `effect/internal/*` path fails to load with an error naming the path.
## Shape
```ts
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
export default Plugin.define({
id: "example",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.storage.set("loaded", true)
}),
})
```
Use `effect:`, not `setup:`. Exporting from `.opencode/plugins/` loads it
automatically; packages use the same `plugins` config as Promise plugins.
## Lifecycle
The scope closes on reload or unload, releasing registrations, scoped fibers, and
finalizers together.
```ts
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.logInfo("unloaded"))
})
```
Background work uses scoped fibers; the scope interrupts them at cleanup.
```ts
yield* Effect.repeat(
storage.set("heartbeat", { time: Date.now() }),
{ schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)
```
`Effect.repeat` takes an Effect **value**, not a function.
## Key differences from Promise plugins
| Aspect | Promise | Effect |
| ------ | ------- | ------ |
| Entry | `setup(ctx)` returning `Promise`/cleanup fn | `effect(ctx)` returning `Effect` |
| Reads | `await ctx.model.list()` | `yield* ctx.model.list()` |
| Transforms | `await ctx.model.transform(cb)` — cb sync | `yield* ctx.model.transform(cb)` — cb **still sync** |
| Cleanup | `return () => {}` | `Effect.addFinalizer` / `forkScoped` |
| Events | `for await (…)` + manual `AbortController` | `Stream` + `forkScoped` |
| Tool I/O | raw JSON Schema | Effect `Schema` |
| Hooks | no rejection API documented for tool hooks | `Effect.fail(new Tool.Error(...))` |
| Registration | `await reg.dispose()` | `yield* reg.dispose` |
`Hooks<Spec, Failures>` and `Transform<Input>` are the shared registration types.
Both return an Effect that requires the plugin scope, which is why
`transform`/`hook`/`dispose` are all yieldable and scoped:
```ts
type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <Name extends keyof Spec>(
name: Name,
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
) => Effect.Effect<Registration, never, Scope.Scope>
type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
```
These are the page's simplified shared types. They are **not** the complete
signature for the session domain: the page's session hook type is
`SessionHookDomain { readonly hook: ModelHooks<SessionHooks> }`, `ModelHooks` is
not defined on the page, and the page's own examples pass a third
`{ providerID }` argument that the `Hooks` type above does not accept. Trust the
page's examples over the simplified type.
The transform callback is still synchronous in both flavors. It must not return
an Effect or a Promise.
Result shape is **per operation, not per flavor**. Some reads are enveloped and
some are not, in both flavors — check the doc example for the call you are using.
```ts
// enveloped
const models = yield* ctx.model.list().pipe(Effect.orDie)
models.data.length
// returned directly
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
created.id
const settings = yield* ctx.storage.get("settings")
```
The page states direct returns for storage ("Storage accepts Effect's JSON type
and returns Effects directly"), for `integration.connection.active` (`Effect<ConnectionInfo | undefined>`) and
`integration.connection.resolve` (a `Credential.Value | undefined`), and for
`ToolDomain.list`
(`Effect<readonly (Tool.Info & { id: string })[]>`); editor `list()` calls also
return plain arrays. For everything else, mirror the page's own example rather
than assuming either shape.
Many examples in the page pipe reads through `Effect.orDie` to drop the error
channel, then read `.data` — copy that shape when the example uses it.
## Transforms
```ts
effect: (ctx) =>
Effect.gen(function* () {
const provider = ctx.provider
const source = { providers: yield* loadFromSource() }
yield* provider.transform((editor) => {
source.providers.forEach((p) => editor.add(p))
})
yield* Effect.repeat(
Effect.gen(function* () {
source.providers = yield* loadFromSource()
yield* provider.reload()
}),
{ schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)
})
```
Rebuild, replay-order, and no-implicit-watch semantics are identical to the
Promise flavor. See `SKILL.md`.
## Tools
Effect plugins register tools with Effect `Schema` instead of raw JSON Schema,
and the executor returns typed output.
```ts
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((editor) => {
editor.namespace({ name: "acme", description: "Customer account tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ greeting: Schema.String }),
options: { namespace: "acme", codemode: true },
execute: ({ name }, context) =>
Effect.gen(function* () {
yield* context.progress({ status: "greeting" })
return { output: { greeting: `Hello ${name}!` } }
}),
})
})
})
```
Return `{ output }` for typed output — that is the shape the page's executor
example uses. The page also says an executor may return "typed output, display
content, or metadata" without showing those other shapes, so confirm on the page
before relying on a `{ content }` form in an Effect plugin.
Effective-name rules (`acme_greeting`), `update`/`remove` semantics, snapshot
behavior, and `dispose` are the same as the Promise flavor.
## Hooks
Callbacks return Effects. Registration stays in the plugin scope.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const session = ctx.session
yield* session.hook("context", (event) =>
Effect.sync(() => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
delete event.tools.write
}),
)
yield* session.hook("compaction", (event) =>
Effect.map(summarize(event.messages), (summary) => {
event.result = { summary }
}),
)
})
```
Failing a tool hook with `Tool.Error`:
```ts
yield* ctx.tool.hook("execute.before", (event) =>
event.tool === "write"
? Effect.fail(new Tool.Error({ message: "Writes are disabled" }))
: Effect.logDebug("tool input", { tool: event.tool, input: event.input }),
)
```
Hook names, event fields, ordering, and override precedence are the same
**where the Effect page documents them** — but the two flavors do not have the
same hook set:
- The Effect `SessionHooks` type lists `context`, `compaction`, `generate`,
`title`, `model.request`, `http.request`, `http.response`, the three
`experimental.ws.*`, and `retry`. **It has no `prompt` hook.** Do not carry
the Promise `prompt` example over without confirming it on this page.
- Provider scoping (`{ providerID: "anthropic" }` as a third argument) is
documented here for `model.request`, `experimental.ws.handshake`, and
`experimental.ws.send`. The simplified `Hooks<Spec, Failures>` type quoted
earlier in this file does not show that third parameter, so do not treat it as
the complete signature.
For the request-hook rules that both flavors share, see `SKILL.md`.
Effect callbacks use Effect interruption instead of `signal`; the other
`experimental.ws.*` and `retry` hooks behave as documented there.
## Events
`ctx.event.subscribe()` is a `Stream`. Fork it in scope for automatic
interruption — no `AbortController` needed.
```ts
yield* ctx.event.subscribe().pipe(
Stream.tap((item) => Effect.logDebug("OpenCode event", { type: item.type })),
Stream.runDrain,
Effect.forkScoped,
)
yield* ctx.event.subscribe().pipe(
Stream.filter((item) => item.type === "config.updated"),
Stream.runForEach(() => Effect.logInfo("configuration changed")),
Effect.forkScoped,
)
```
## Branded values
The re-exported Effect schema constructors produce branded IDs rather than bare
strings. Narrow and construct with them:
```ts
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("claude-sonnet-4-5")
const created = yield* ctx.session.create({ title: "Review" }).pipe(Effect.orDie)
yield* ctx.session
.switchModel({ sessionID: created.id, model: { providerID, id: modelID } })
.pipe(Effect.orDie)
yield* ctx.skill.transform((editor) => {
editor.add(Skill.Info.make({
id: Skill.ID.make("review"),
name: Skill.Name.make("Review"),
description: "Review the current changes",
path: "/workspace/.opencode/skills/review/SKILL.md",
content: "Review the current changes for correctness and missing tests.",
}))
})
```
## Commands
The command transform is add-only here too. The executor returns an Effect.
```ts
yield* command.transform((editor) => {
editor.add({
name: "security-review",
description: "Review changes for security issues",
execute: (input) =>
session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: `Review these changes for security issues.\n\n${input.prompt.text}`,
delivery: input.delivery,
}).pipe(Effect.asVoid),
})
})
```
## Integrations
OAuth method callbacks are Effects and may acquire scoped resources.
```ts
yield* integration.transform((editor) => {
editor.method.update({
integrationID: "acme",
method: { id: "device", type: "oauth", label: "Sign in with Acme" },
authorize: () =>
Effect.succeed({
mode: "code",
url: "https://acme.example/device",
instructions: "Enter the displayed code",
callback: (code) => exchangeCode(code),
}),
})
})
```
`authorize` returns `mode: "auto"` (callback is the Effect that performs the
flow) or `mode: "code"` (callback takes the code). The Effect's `R` may include
`Scope.Scope`, so it can hold scoped resources for the attempt's duration.
## Context shape
`Context` declares `app`, `location`, `options`, and these domains: `agent`,
`aisdk`, `command`, `event`, `experimental`, `integration`, `mcp`, `model`,
`generate`, `permission`, `plugin`, `provider`, `reference`, `rpc`, `session`,
`shell`, `skill`, `storage`, `tool`, `vcs`, `websearch`, `worktree`.
Three narrowed members worth noting:
- `experimental` is only
`{ terminal: Pick<ExperimentalApi<unknown>["persistentPty"], "read"> }`. The
narrowed member is `experimental.terminal`, not `experimental`.
- `plugin` is only `Pick<PluginApi<unknown>, "list">`; `list` reports active,
failed, and resolved plugins, so filter in a pipeline when you need only
active ones. The page's example filters on `item.status === "active"`, but
the shipped `Plugin.Info` nests that as `state.status` — trust the type.
- `vcs` omits `init` as a client method, but a registered VCS **definition** may
still implement it.
This is the **Effect** `Context`. Do not assume it matches the Promise context
member for member — this page does not state that they are identical, and
`aisdk` appears here with no Promise-page counterpart.
The context exposes the Effect client plus plugin-only transforms, hooks,
storage, reloads, and options. It does **not** expose OpenCode's private Core
services.
## Plugin type
```ts
interface Plugin<R = Scope.Scope> {
readonly id: string
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
```
The `R` defaults to `Scope.Scope`, so registrations and scoped fibers are
released when the plugin scope closes.
Two runtime caveats:
- Only **public** `effect` subpaths are provided. Importing an `internal` module
fails to load with an error naming the path.
- The compiled OpenCode binary omits the Scalar/Swagger UI assets used by
Effect's HTTP API doc pages; serving those from a plugin shows a notice.
## Publish
Declare `effect` as a `peerDependency` and export the Effect implementation from
the main entrypoint.
```json title="package.json"
{
"name": "opencode-acme-effect-plugin",
"version": "1.0.0",
"type": "module",
"exports": { ".": "./src/index.ts" },
"dependencies": { "@opencode/plugin": "latest" },
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
}
```
Test the installed tarball, not only a workspace link:
```sh
bun pm pack
bun add ./opencode-acme-effect-plugin-1.0.0.tgz
```
+329
View File
@@ -0,0 +1,329 @@
# RPC Plugins
**Promise docs: <https://opencode.ai/v2/docs/build/plugins/rpc/>**
**Effect docs: <https://opencode.ai/v2/docs/build/plugins/effect/rpc/>**
**Templates: `../templates/rpc.ts` + `rpc-implementation.ts` (Promise),
`../templates/rpc-effect.ts` + `rpc-implementation-effect.ts` (Effect)**
RPC lets a plugin expose custom **methods and events that run on the server** and
can be called by other plugins or by clients. It is a contract plus a
registration — the contract file itself contains no implementation.
There are two flavors with the same shape but different types and calling
conventions. Check the differences table at the end before copying a snippet,
and confirm each identifier on the page for your flavor.
## Define (Promise page)
Everything from here down to the Effect section documents the **Promise** page
(<https://opencode.ai/v2/docs/build/plugins/rpc/>). The Effect flavor follows below.
Use `Rpc.define` in a file that other packages can import without loading your
plugin. Publish it as the `./rpc` export.
```ts title="src/rpc.ts"
import { Rpc } from "@opencode/plugin/rpc"
export const Acme = Rpc.define({
id: "acme",
methods: {
search: {
input: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
output: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
additionalProperties: false,
},
errors: {
not_found: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
},
},
},
events: {
updated: {
schema: {
type: "object",
properties: { itemID: { type: "string" }, text: { type: "string" } },
required: ["itemID", "text"],
additionalProperties: false,
},
},
},
})
```
The RPC page does not describe what `id` affects; it only shows received event
types arriving prefixed — `rpc.acme.updated` for an event named `updated` under `id: "acme"` —
and does not otherwise describe how `id` affects method routing, so do not rely
on that beyond what the page states. Pick a name that will not collide.
The `./rpc` export is described as optional on the **plugins** page
(<https://opencode.ai/v2/docs/build/plugins>), not on the RPC page:
```json title="package.json"
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts"
},
"dependencies": { "@opencode/plugin": "latest" }
}
```
## Schemas (Promise page)
Two formats are supported:
- **JSON Schema** — simple, no dependencies. Values are `unknown` in
TypeScript, so **narrow them before use**.
- **Any Standard Schema validator** — Zod, Valibot, ArkType. Types are inferred.
```ts
import * as z from "zod"
search: { input: z.object({ query: z.string() }), output: z.object({ text: z.string() }) }
```
Omit `input` or `output` when the method takes or returns nothing.
## Errors (Promise page)
Add an `errors` map for expected failures. Each key becomes the error's `type`;
its schema defines the error's `data`. Names beginning with `rpc.` are reserved
by OpenCode.
## Events (Promise page)
Top-level `events` map. Event data must be an object — scalars, arrays, `null`,
and `undefined` are invalid. Use an empty object schema for no data:
```ts
events: { refreshed: { schema: { type: "object", additionalProperties: false } } }
```
## Implement (Promise page)
Register inside `setup`:
```ts title="src/index.ts"
import { Plugin } from "@opencode/plugin"
import { Acme } from "./rpc.js" // your contract file, e.g. src/rpc.ts
export default Plugin.define({
id: "acme-plugin",
async setup(ctx) {
const registration = await ctx.rpc.register(Acme, {
search: async (input, context) => {
const { query } = input as { query: string }
const text = await findText(query, { signal: context.signal })
if (!text) return context.error("not_found", "Result not found", { query })
return { text }
},
})
// the same plugin can call its own RPC
const acme = ctx.rpc(Acme)
const result = await acme.search({ query: "hello" })
await registration.events.emit("updated", { itemID: "item-1", text: "ready" })
},
})
```
The second argument gives `signal` for cancellation and `context.error(...)` for
declared errors — return it or throw it. One plugin may register several RPCs;
the page states that disposing the registration removes it.
## Call (Promise page)
### From a client over HTTP
```ts
import { OpenCode } from "@opencode/client"
import { Acme } from "opencode-acme-plugin/rpc"
const client = OpenCode.make({ baseUrl: "http://localhost:4096" })
const acme = client.rpc(Acme)
const result = await acme.search({ query: "hello" })
```
### From another plugin
Plugins already hold a client. A TUI plugin uses `context.client`:
```ts title="src/tui.ts"
import { Plugin } from "@opencode/plugin/tui"
import { Acme } from "opencode-acme-plugin/rpc"
export default Plugin.define({
id: "acme-tui",
async setup(context) {
const acme = context.client.rpc(Acme)
const result = await acme.search({ query: "hello" })
console.log(result)
},
})
```
## Subscribe (Promise page)
Promise flavor. Callback form returns an unsubscribe function:
```ts
const unsubscribe = acme.events.on("updated", (event) => {
console.log(event.type, event.location.directory, event.data.text)
})
unsubscribe()
```
Async iterable form:
```ts
for await (const event of acme.events.subscribe("updated")) {
console.log(event.data.text)
}
```
Details stated on the Promise page:
- Subscribe with the **local** name (`updated`). The received `type` is
**prefixed** (`rpc.acme.updated`).
- Every event carries `data` and `location`.
- Subscriptions are **live only** — a disconnected subscriber misses events.
- Plugin unload closes its subscriptions.
- External clients receive events from **every** location, so check
`event.location`. The server plugin must still be configured and running.
## Effect flavor
The Effect page
(<https://opencode.ai/v2/docs/build/plugins/effect/rpc/>) has the same contract
shape but Effect types and conventions. `Rpc.define` is imported from
`@opencode/plugin/rpc` here too — not from `@opencode/plugin/effect`.
```ts title="src/rpc.ts"
import { Rpc } from "@opencode/plugin/rpc"
import { Schema } from "effect"
export const Acme = Rpc.define({
id: "acme",
methods: {
search: {
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
errors: { not_found: Schema.Struct({ query: Schema.String }) },
},
},
events: {
updated: {
schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
},
},
})
```
Effect Schema validates and infers types, so no casts. The page also states that
JSON Schema and Standard Schema are supported, so an Effect contract is not
restricted to Effect Schema.
Registration happens inside the plugin `effect`, not `setup`:
```ts title="src/index.ts"
import { Plugin } from "@opencode/plugin/effect"
import { Effect } from "effect"
import { Acme } from "./rpc.js"
export default Plugin.define({
id: "acme-effect-plugin",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Acme, {
search: ({ query }, context) =>
findText(query).pipe(
Effect.flatMap((text) =>
text
? Effect.succeed({ text })
: Effect.fail(context.error("not_found", "Result not found", { query })),
),
),
})
const acme = ctx.rpc(Acme)
const result = yield* acme.search({ query: "hello" })
yield* registration.events.emit("updated", { itemID: "item-1", text: result.text })
}).pipe(Effect.orDie),
})
```
Input arrives already decoded, so the handler destructures instead of casting.
A declared error is raised with `Effect.fail(context.error(type, message, data))`.
`context.signal` is **not** mentioned on the Effect page.
Calling over HTTP needs the Effect client plus an HTTP client layer:
```ts
import { OpenCode } from "@opencode/client/effect"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Acme } from "opencode-acme-plugin/rpc"
const program = Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
const acme = client.rpc(Acme)
return yield* acme.search({ query: "hello" })
})
const result = await Effect.runPromise(program.pipe(Effect.provide(FetchHttpClient.layer)))
```
Subscriptions are a Stream; `events.on` is not mentioned on this page:
```ts
import { Effect, Stream } from "effect"
const acme = ctx.rpc(Acme)
yield* acme.events.subscribe("updated").pipe(
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
Effect.forkScoped,
)
```
"Subscriptions are live only and close when Stream consumption stops."
## Promise vs Effect
| Aspect | Promise | Effect |
| ------ | ------- | ------ |
| `Rpc.define` import | `@opencode/plugin/rpc` | `@opencode/plugin/rpc` (same) |
| Schemas | JSON Schema or Standard Schema; JSON Schema values are `unknown`, narrow them | Effect `Schema`, types inferred. JSON Schema and Standard Schema also supported |
| Handler signature | `async (input, context)`, then `input as { … }` | `({ … }, context) => Effect`, input already decoded |
| Success | `return { text }` | `Effect.succeed({ text })` |
| Declared error | `return context.error("not_found", msg, data)` | `Effect.fail(context.error("not_found", msg, data))` |
| Cancellation | pass `context.signal` to the work | not documented on that page; use interruption |
| Registered in | `await ctx.rpc.register` in `async setup` | `yield* ctx.rpc.register` in `Effect.gen` under `effect:` |
| Emit | `await registration.events.emit(…)` | `yield* registration.events.emit(…)` |
| HTTP client | `OpenCode.make({…})` from `@opencode/client` | `yield* OpenCode.make({…})` from `@opencode/client/effect`, plus `FetchHttpClient.layer` |
| Another plugin calls it | TUI plugin: `context.client.rpc(Acme)` | Effect plugin: `ctx.rpc(Acme)` |
| Subscribe | `events.on(…)` → unsubscribe fn, or `events.subscribe(…)` → async iterable | `events.subscribe(…)` → Stream + `Effect.forkScoped` |
Stated only on the **Promise** page — do not assume these carry over to the
Effect flavor without checking that page: `rpc.`-prefixed error names being
reserved, the "scalars, arrays, `null`, and `undefined` are not valid event
data" rule and its empty-object-schema form, the prefixed `rpc.acme.updated`
type, `event.location`, plugin unload closing subscriptions, `events.on`, and
disposing the registration.
@@ -0,0 +1,78 @@
# Templates
Starter files transcribed from the OpenCode V2 doc pages. **Copy one and fill in
the blanks** rather than writing a plugin from scratch.
| Template | Use for | Source doc |
| -------- | ------- | ---------- |
| `promise-plugin.ts` | Server plugin, Promise flavor (default) | <https://opencode.ai/v2/docs/build/plugins> |
| `effect-plugin.ts` | Server plugin, Effect-native | <https://opencode.ai/v2/docs/build/plugins/effect/> |
| `rpc.ts` | RPC contract, Promise flavor (copy to `src/rpc.ts`) | <https://opencode.ai/v2/docs/build/plugins/rpc/> |
| `rpc-implementation.ts` | RPC registration + calls, Promise | <https://opencode.ai/v2/docs/build/plugins/rpc/> |
| `rpc-effect.ts` | RPC contract, Effect flavor | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> |
| `rpc-implementation-effect.ts` | RPC registration + calls, Effect | <https://opencode.ai/v2/docs/build/plugins/effect/rpc/> |
| `tui-plugin.tsx` | TUI / CLI plugin | <https://opencode.ai/v2/docs/build/plugins/cli/> |
| `opencode-plugin-config.jsonc` | `plugins` in `opencode.json(c)` | <https://opencode.ai/v2/docs/plugins> |
| `package.json` | Publishing a plugin package (strict JSON) | <https://opencode.ai/v2/docs/build/plugins> |
| `package-json.md` | Publishing guidance, peer deps, entrypoints | build/plugins, build/plugins/effect, build/plugins/cli |
| `README.md` | This index | — |
`package.json` is strict JSON with no comments, because Node, bun, npm, and
bundlers parse it with a strict parser. Publishing guidance therefore lives in
`package-json.md` rather than inside the manifest.
The two RPC template pairs are **not** interchangeable: same contract shape,
different schema dialect, handler signature, and registration entrypoint. See the
Promise-vs-Effect table in `../references/rpc.md`.
## Before you write code
1. Check the version: `opencode --version`, `opencode api get /api/info`.
2. Pick the flavor. Default to Promise. Effect only if the project already uses
Effect. TUI is a separate export. RPC is a contract plus a registration.
3. Open the source doc in the table above and keep it open.
4. Copy the template. Delete the sections you do not need.
5. Verify every field name against that page. Do not guess.
## Rules the templates encode
These are the constraints the pages state, not stylistic preferences. Note which
are type-level rather than documented runtime failures.
- A `transform` callback is typed `(editor) => void` in both flavors — no
Promise, no Effect.
- Tool `update` preserves name and namespace. **Assign** a new schema or options
object to replace one rather than mutating nested values. In-place edits inside
an `update` callback are fine — that is what the mutable draft is for.
- `reload()` replays transforms. It does **not** rerun `setup` / `effect`.
- Captured inputs are not watched. Load external data before the sync callback,
then call `reload()` when it changes.
- Use the **effective** tool name — including its namespace — for `update` and
`remove` (`example_greeting`, not `greeting`). Only `add` creates a tool.
- A `context` hook covers the agent loop only. Register each request kind you
actually need.
- Result shape is **per operation, not per flavor**: `storage.get` returns a
value, `model.list()` is enveloped. Copy the page's example for the call you
use.
- Effect plugins use `effect`, not `setup`. The two flavors do not have the same
context members or the same hook set.
- Relative plugin paths in config resolve from the **config file**, not the
working directory.
- Registering an event subscription without tearing it down leaks it.
`forkScoped` handles this in Effect; `AbortController` in Promise.
- `package.json` must be strict JSON — `//` comments there break `bun`, `npm`,
and every bundler. `opencode.jsonc` may use them.
## Anything not in a template
The pages carry more detail than fits here. When you need something not in these
files, go to the source doc rather than improvising:
- Every `Agent.Info`, `Provider.Info`, `Model.Info`, `Session.Info`,
`Tool.Content`, `Mcp.Server`, `Skill.Info`, `Vcs.Info`, `Reference.Info`,
`Integration.Info`, `Connection.Info`, `Form.Answer`, `Command.Info`,
`Plugin.Info`, `WebSearch.*`, `Worktree*` shape:
<https://opencode.ai/v2/docs/api>
- The exact contract of the running server: `/openapi.json` on that server.
- Hook event fields: the `Reference` code block at the end of each section on
the relevant page.
@@ -0,0 +1,143 @@
// Effect server plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/
//
// Copy this file, keep only what you need, delete the rest.
// Transcribed from the doc page. Note the differences from the Promise flavor:
// entry is `effect`, not `setup`; reads are yieldable; cleanup is via Scope.
// ---------------------------------------------------------------------------
// PREREQUISITE: the package is not bundled with the CLI. Install it at the
// version matching your OpenCode release, then restart the service:
//
// cd ~/.config/opencode
// npm install @opencode/plugin@$(opencode --version | sed 's/^v//')
// opencode service restart
//
// A stale `@opencode-ai/plugin` (the V1-era name) in node_modules is a leftover
// dependency, not what the host provides — ignore its export list.
// ---------------------------------------------------------------------------
// The Effect page imports these from the effect entrypoint; the Transforms
// example shows `import { Model, Plugin, Provider } from "@opencode/plugin/effect"`.
import { Model, Plugin, Provider } from "@opencode/plugin/effect"
import { Effect, Schedule, Schema, Stream } from "effect"
export default Plugin.define({
id: "example",
// `effect` runs at load. The scope closes on reload or unload, which releases
// registrations, scoped fibers, and finalizers together.
effect: (ctx) =>
Effect.gen(function* () {
// ---------------------------------------------------------------- storage
yield* ctx.storage.set("loaded", true)
// ---------------------------------------------------------------- options
// Untyped. Narrow before use.
const strict = ctx.options.strict === true
// -------------------------------------------------------------- logging
yield* Effect.logInfo("loaded", { version: ctx.app.version })
// ---------------------------------------------------------------- reads
// Effect reads usually return a decoded envelope: result lands on .data
const models = yield* ctx.model.list().pipe(Effect.orDie)
yield* Effect.logInfo("models", { count: models.data.length })
const active = yield* ctx.plugin
.list()
.pipe(
Effect.orDie,
// NOTE: the doc page writes `p.status === "active"`, but the shipped
// Plugin.Info nests it as `state.status`. Trust the type.
Effect.map((r) => r.data.filter((p) => p.state.status === "active")),
)
yield* Effect.logInfo("active plugins", { count: active.length })
// ------------------------------------------------------------- transform
// The callback is STILL SYNCHRONOUS, even here. No Effect, no Promise.
yield* ctx.model.transform((editor) => {
editor
.list()
.filter((model) => model.cost.some((tier) => tier.output > 20))
.forEach((model) => {
editor.remove(model.providerID, model.id)
})
})
// ----------------------------------------------------------------- tool
// Effect plugins use Effect Schema instead of raw JSON Schema, so input
// and output are typed.
yield* ctx.tool.transform((editor) => {
editor.namespace({ name: "example", description: "Example tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
input: Schema.Struct({ name: Schema.String }),
output: Schema.Struct({ greeting: Schema.String }),
options: { namespace: "example", codemode: true },
execute: ({ name }, context) =>
Effect.gen(function* () {
yield* context.progress({ status: "greeting" })
// The page's executor example returns { output } for typed output.
return { output: { greeting: `Hello ${name}!` } }
}),
})
})
// ---------------------------------------------------------------- hooks
// Callbacks return Effects.
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
delete event.tools.write
}),
)
// A hook may fail with Tool.Error to block a tool call.
// yield* ctx.tool.hook("execute.before", (event) =>
// event.tool === "write"
// ? Effect.fail(new Tool.Error({ message: "Writes are disabled" }))
// : Effect.logDebug("tool input", { tool: event.tool, input: event.input }),
// )
// ------------------------------------------------------------- provider
// Optional: contribute providers and refresh them from a source. The
// page's example loader returns entries shaped
// `{ info: Provider.Info, models: readonly Model.Info[] }`.
if (strict) {
const source = { providers: yield* loadFromSource() }
yield* ctx.provider.transform((editor) => {
source.providers.forEach((provider) => editor.add(provider))
})
// Captured inputs are not watched: fork a scoped fiber to refresh.
yield* Effect.repeat(
Effect.gen(function* () {
source.providers = yield* loadFromSource()
yield* ctx.provider.reload()
}),
{ schedule: Schedule.spaced("1 minute") },
).pipe(Effect.forkScoped)
}
// ---------------------------------------------------------------- events
// subscribe() is a Stream. forkScoped handles interruption for you.
yield* ctx.event.subscribe().pipe(
Stream.filter((item) => item.type === "config.updated"),
Stream.runForEach(() => Effect.logInfo("configuration changed")),
Effect.forkScoped,
)
// No explicit cleanup needed: the scope closes it all.
}),
})
// Stand-in for your own loader, used by the provider block above. The doc
// page's example loader returns `{ info: Provider.Info, models: readonly
// Model.Info[] }` entries.
declare function loadFromSource(): Effect.Effect<
readonly { info: Provider.Info; models: readonly Model.Info[] }[]
>
@@ -0,0 +1,122 @@
// opencode.jsonc — plugin configuration template
//
// Docs: https://opencode.ai/v2/docs/plugins
// Terminal plugins: https://opencode.ai/v2/docs/cli/plugins
//
// https://opencode.ai/v2/docs/config states: "OpenCode supports JSON and
// JSONC. Use JSONC when you want comments or trailing commas." Use the
// two-slash form used below. (Hash comments would not parse — that is a
// property of JSONC, not something the page states.)
//
// Keep the $schema line to match the docs. Be aware that
// https://opencode.ai/config.json does not match the V2 config shape: its
// Config has additionalProperties: false, has no "plugins" key, types "skills"
// as an object, and has no "worktree" key. So some editors will flag the array
// below as invalid. There is no published V2 config schema
// (https://opencode.ai/v2/config.json returns 404). Never infer V2 field names
// from that file. Drop the line if you prefer a clean editor.
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
// --- package plugins ---
"opencode-acme-plugin",
"opencode-acme-plugin@1.2.0", // exact versions stay pinned
"@acme/opencode-plugin",
// --- local paths ---
// RELATIVE PATHS RESOLVE FROM THE CONFIG FILE CONTAINING THIS ENTRY,
// not from the working directory. (The `skills` array is the opposite.)
"./plugins/local",
"../shared/plugin.ts",
"/absolute/path/plugin.ts",
"file:///home/me/plugins/local",
// --- with options --- (read them in setup via ctx.options, untyped)
{
"package": "./plugins/company",
"options": {
"strict": true
}
}
]
}
// ---------------------------------------------------------------------------
// Disabling plugins
//
// Entries are processed in order. Prefix an ID or wildcard with "-" to disable,
// "*" for every plugin, ".*" for an ID prefix. A later ID re-enables one.
//
// "plugins": ["*", "-opencode.provider.*", "opencode.provider.openai", "-acme.reviewer"]
//
// Two built-ins ignore removals so a repository cannot switch off policy
// enforcement: opencode.config.policy and opencode.provider.opencode.
//
// ---------------------------------------------------------------------------
// CLI-only plugins go in cli.json, not here.
//
// A plugin in this file that exports "./tui" is loaded automatically by the CLI.
// Do NOT also add it to cli.json. The CLI gets the active plugin list from the
// connected server, so it works when the server is remote.
//
// cli.json is for plugins that run locally in the terminal and must stay active
// against a remote server.
//
// ---------------------------------------------------------------------------
// Precedence
//
// Plugin arrays from applicable config files apply from lowest to highest
// precedence rather than replacing one another:
//
// ~/.config/opencode/opencode.jsonc
// ./opencode.jsonc
// ./.opencode/opencode.jsonc
//
// ---------------------------------------------------------------------------
// Discovery
//
// Direct .ts/.js files and immediate package directories load from every
// discovered .opencode/plugins/, plus the global config directory:
//
// .opencode/plugins/concise.ts
// .opencode/plugins/acme-package/
// ~/.config/opencode/plugins/status/index.ts
// ~/.config/opencode/plugins/status/tui.ts
//
// A plugins/ directory beside a project-root opencode.json(c) is NOT
// discovered automatically — configure it explicitly or move it under .opencode/.
//
// ---------------------------------------------------------------------------
// CLI commands
//
// add and remove write to the GLOBAL config (~/.config/opencode/opencode.jsonc).
//
// opencode plugin add opencode-acme-plugin@1.2.0
// opencode plugin add github:acme/opencode-plugin
// opencode plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
// opencode plugin list
// opencode plugin list --builtin
// opencode plugin check
// opencode plugin update
// opencode plugin update opencode-acme-plugin
// opencode plugin remove opencode-acme-plugin@1.2.0
//
// Pass a configured package target to check or update only that package.
// plugin add accepts npm names with versions, tags, or ranges, plus
// npm-compatible Git specs. Tarballs and npm aliases are NOT accepted.
//
// plugin check covers server and TUI-only package plugins. Local plugins and
// exact package revisions are skipped.
//
// ---------------------------------------------------------------------------
// 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.
//
// touch .opencode/plugins/concise/index.ts
// opencode service restart // for unwatched local dependencies
@@ -0,0 +1,91 @@
# Publishing a plugin package
`package.json` here is **strict JSON with no comments**, because Node, bun, npm,
and every bundler parse it with a strict parser. Copy it as-is and delete the
entrypoints you do not implement. Guidance lives in this file rather than in
comments inside the manifest.
Source: <https://opencode.ai/v2/docs/build/plugins> (Publish)
## Entrypoints
```json title="package.json"
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "latest"
}
}
```
| Export | Needed for | Documented on |
| ------ | ---------- | ------------- |
| `.` | a server plugin | <https://opencode.ai/v2/docs/build/plugins> |
| `./rpc` | publishing a shared RPC contract others can import without loading your implementation. The docs call this export **optional**. | <https://opencode.ai/v2/docs/build/plugins> |
| `./tui` | the TUI plugin. Exporting it makes the CLI load the plugin automatically when the package is listed in `opencode.json(c)`. **Do not also add the package to `cli.json`** — the CLI gets the active plugin list from the server. | <https://opencode.ai/v2/docs/build/plugins/cli/> and <https://opencode.ai/v2/docs/cli/plugins> |
## Effect plugin
The documented manifest puts `effect` in `peerDependencies`, not
`dependencies`:
```json
{
"dependencies": { "@opencode/plugin": "latest" },
"peerDependencies": { "effect": ">=4.0.0-rc.112" }
}
```
Do **not** bundle `effect`. OpenCode provides `effect`, `effect/*`, and
`@opencode/plugin` at runtime and redirects imports — including from your
`node_modules` — to the host instance, so fibers, loggers, and `Schema` parsers
share one runtime copy. If you bundle, mark `effect` and `effect/*` external.
The same Effect page also says to install `effect` and `@opencode/plugin` as
peer/dev dependencies for local type-checking:
```sh
bun add --peer @opencode/plugin effect
```
Plugins always execute against the host release's `effect`. Using APIs or module
paths from another `effect` major (such as Effect 3) is not supported. Only
public `effect` subpaths are provided; importing an `internal` module fails to
load with an error naming the path. The compiled OpenCode binary omits the
Scalar/Swagger UI assets used by Effect's HTTP API doc pages, so serving those
from a plugin shows a notice.
## TUI plugin rendering JSX
```json
{
"peerDependencies": {
"@opentui/core": ">=0.5.8",
"@opentui/solid": ">=0.5.8",
"solid-js": ">=1.9.0"
}
}
```
From <https://opencode.ai/v2/docs/build/plugins/cli/>. Confirm the version floors
against that page before publishing; they are the kind of value that goes stale.
## Testing before publishing
Test the **installed** package, not only a workspace-linked copy:
```sh
bun pm pack
bun add ./opencode-acme-plugin-1.0.0.tgz
```
Use versions compatible with the OpenCode release you target — check with
`opencode --version`. If a release changes the plugin API, publish a compatible
update rather than leaving an old contract in place.
@@ -0,0 +1,13 @@
{
"name": "opencode-acme-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./rpc": "./src/rpc.ts",
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "latest"
}
}
@@ -0,0 +1,138 @@
// Promise server plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins
// Config: https://opencode.ai/v2/docs/plugins
//
// Copy this file, keep only the sections you need, and delete the rest.
// Transcribed from the doc page's own examples. Where a field name is uncertain,
// re-read the page above before inventing it.
//
// ---------------------------------------------------------------------------
// PREREQUISITE: the package is not bundled with the CLI. Install it at the
// version matching your OpenCode release, then restart the service (the running
// service caches module resolution):
//
// cd ~/.config/opencode
// npm install @opencode/plugin@$(opencode --version | sed 's/^v//')
// opencode service restart
//
// A stale `@opencode-ai/plugin` (the V1-era name) in node_modules is a leftover
// dependency, not what the host provides — ignore its export list.
// ---------------------------------------------------------------------------
// Model.Info.default(providerID, Model.ID.make("id")) builds a model definition.
import { Model, Plugin, Provider } from "@opencode/plugin"
export default Plugin.define({
// Give this a name that will not collide with another plugin.
id: "example",
// Runs when the plugin loads. May return a cleanup function.
async setup(ctx) {
// ---------------------------------------------------------------- storage
// Durable JSON scoped to the plugin. Storage returns values directly.
await ctx.storage.set("settings", { strict: true })
// ctx.storage.get("settings") -> the value
// ctx.storage.scan({ prefix: "cache/", limit: 100 }) -> { entries, next }
// ctx.storage.remove("settings")
// ---------------------------------------------------------------- options
// Untyped. Narrow before use.
const strict = ctx.options.strict === true
// ------------------------------------------------------------- transform
// The callback is SYNCHRONOUS. Do not return a Promise or an Effect from it.
await ctx.model.transform((editor) => {
editor
.list()
// Number() and String(): the shipped types brand these money and id
// fields (Money.USDPerMillionTokens, Model.ID), so `>` and `remove`
// do not typecheck against the raw values. The docs show the unbranded
// form; widen here.
.filter((model) => model.cost.some((tier) => Number(tier.output) > 20))
.forEach((model) => {
editor.remove(String(model.providerID), String(model.id))
})
})
// ------------------------------------------------------------- a tool
// Registration can be disposed to remove just this transform.
const registration = await ctx.tool.transform((editor) => {
editor.namespace({ name: "example", description: "Example tools" })
editor.add({
name: "greeting",
description: "Create a greeting",
// Raw JSON Schema in the Promise flavor, so `input` arrives as `unknown`
// in TypeScript. Narrow it once at the top of execute.
input: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
additionalProperties: false,
},
options: { namespace: "example", codemode: true },
execute: async (rawInput, context) => {
const { name } = rawInput as { name: string }
// Pass the signal so stopping the session stops the work.
await context.progress({ status: "greeting" })
return { content: `Hello ${name}!` }
},
})
})
// --------------------------------------------------------------- a hook
// "context" runs for the agent loop only. Register each kind you need.
const hook = 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
})
// ------------------------------------------------------------- provider
// Optional: contribute a provider and its models.
if (strict) {
const providerID = Provider.ID.make("acme")
const models = [{ ...Model.Info.default(providerID, Model.ID.make("reasoner")), name: "Acme Reasoner" }]
await ctx.provider.transform((editor) => {
editor.add({
info: {
...Provider.Info.empty(providerID),
name: "Acme",
activation: "enabled",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: "http://127.0.0.1:8000/v1" },
},
models,
})
})
}
// --------------------------------------------------------------- events
// Must abort during cleanup.
const controller = new AbortController()
void (async () => {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
console.log(event.type)
}
})()
// ------------------------------------------------- refresh from a source
// Captured inputs are NOT watched. Load first, then reload() on change.
// const source = { models: await loadModels() }
// const timer = setInterval(() => {
// void (async () => {
// source.models = await loadModels()
// await ctx.model.reload()
// })().catch(console.error)
// }, 60_000)
// Return a cleanup function for anything you own. Returning nothing is fine.
return () => {
controller.abort()
void registration.dispose()
void hook.dispose()
// clearInterval(timer)
}
},
})
@@ -0,0 +1,55 @@
// RPC contract (Effect flavor) — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/rpc/
//
// Copy to src/rpc.ts — the implementation template's doc-accurate import is
// "./rpc.js", so the file must be named rpc.ts. Contract only, no
// implementation. Same contract shape as rpc.ts, but with Effect Schema
// instead of raw JSON Schema. Registered by rpc-implementation-effect.ts.
//
// Rpc.define is imported from "@opencode/plugin/rpc" here too, NOT from
// "@opencode/plugin/effect". The Effect page imports Schema from the root
// "effect" module.
//
// TYPE NOTE: the shipped `ValueSchema` type is
// Schema.Codec | StandardSchemaV1 | JsonSchema
// and Effect's `Schema.Struct` is not a `Codec`, so `tsc --strict` can reject
// the form below. The page states JSON Schema and Standard Schema are supported
// here too, so either of these typechecks instead:
// input: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }
// input: z.object({ query: z.string() }) // or Valibot / ArkType
// A method needs BOTH input and output to be present for the types to line up.
//
// PREREQUISITE: install @opencode/plugin at your OpenCode version and restart
// the service — see the PREREQUISITE block in promise-plugin.ts.
//
import { Rpc } from "@opencode/plugin/rpc"
import { Schema } from "effect"
export const Acme = Rpc.define({
// The page does not describe how `id` affects method routing. Pick a name
// that will not collide.
id: "acme",
methods: {
search: {
// Effect Schema validates and infers types, so the handler needs no casts.
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
// Omit input or output if the method takes or returns nothing.
// Each error key becomes the error's `type`.
errors: {
not_found: Schema.Struct({ query: Schema.String }),
},
},
},
events: {
// Event data must be an object.
updated: {
schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
},
},
})
// The Effect page also states that JSON Schema and Standard Schema are
// supported here, so an Effect contract is not restricted to Effect Schema.
@@ -0,0 +1,74 @@
// RPC implementation (Effect flavor) — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/effect/rpc/
//
// Needs templates/rpc-effect.ts copied next to this file as src/rpc.ts, so the
// doc's "./rpc.js" specifier resolves. Rename it rpc.ts on save; the contract
// shape is identical, only the schema dialect differs.
//
// Registration goes inside the plugin `effect`, NOT `setup`.
//
// The docs use "@opencode/plugin" and "@opencode/plugin/effect". Install the
// package at your OpenCode version and restart the service first — see the
// PREREQUISITE block in promise-plugin.ts.
import { Plugin } from "@opencode/plugin/effect"
import { Effect, Stream } from "effect"
import { Acme } from "./rpc.js"
export default Plugin.define({
id: "acme-effect-plugin",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Acme, {
// Input arrives already decoded — destructure it, do not cast.
search: ({ query }, context) =>
findText(query).pipe(
Effect.flatMap((text) =>
text
? Effect.succeed({ text })
: Effect.fail(context.error("not_found", "Result not found", { query })),
),
),
})
// The same plugin can call its own RPC.
const acme = ctx.rpc(Acme)
const result = yield* acme.search({ query: "hello" })
yield* registration.events.emit("updated", { itemID: "item-1", text: result.text })
// Subscriptions are a Stream; forkScoped handles interruption.
// "Subscriptions are live only and close when Stream consumption stops."
yield* acme.events.subscribe("updated").pipe(
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
Effect.forkScoped,
)
// context.signal is NOT documented on the Effect page. Use interruption.
}).pipe(Effect.orDie),
})
// Calling over HTTP uses the Effect client and needs an HTTP client layer:
//
// import { OpenCode } from "@opencode/client/effect"
// import { Effect } from "effect"
// import { FetchHttpClient } from "effect/unstable/http"
// import { Acme } from "opencode-acme-plugin/rpc"
//
// const program = Effect.gen(function* () {
// const client = yield* OpenCode.make({ baseUrl: "http://localhost:4096" })
// const acme = client.rpc(Acme)
// return yield* acme.search({ query: "hello" })
// })
//
// const result = await Effect.runPromise(
// program.pipe(Effect.provide(FetchHttpClient.layer)),
// )
//
// Another Effect plugin calls it with ctx.rpc(Acme). events.on and the
// prefixed event type are documented only on the Promise page — check before
// relying on either here.
declare function findText(query: string): Effect.Effect<string | undefined>
@@ -0,0 +1,79 @@
// RPC implementation + calling — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/rpc/
//
// Two files. The contract is importable without loading the implementation, so
// other plugins and clients can depend on it.
//
// Needs templates/rpc.ts copied next to this file as src/rpc.ts, so the
// doc's "./rpc.js" specifier resolves. An Effect plugin would import the
// Effect-dialect contract instead — see rpc-effect.ts.
//
// The docs use "@opencode/plugin" and "@opencode/plugin/effect". Install the
// package at your OpenCode version and restart the service first — see the
// PREREQUISITE block in promise-plugin.ts.
import { Plugin } from "@opencode/plugin"
import { Acme } from "./rpc.js"
export default Plugin.define({
id: "acme-plugin",
async setup(ctx) {
const registration = await ctx.rpc.register(Acme, {
search: async (input, context) => {
// JSON Schema input is `unknown` — narrow it.
const { query } = input as { query: string }
// `signal` cancels with the request; `context.error` declares a failure.
const text = await findText(query, { signal: context.signal })
if (!text) return context.error("not_found", "Result not found", { query })
return { text }
},
})
// The same plugin can call its own RPC once registered.
const acme = ctx.rpc(Acme)
// Raw JSON Schema output arrives as `unknown` in the Promise flavor; the
// docs say to narrow it.
const result = (await acme.search({ query: "hello" })) as { text: string }
// Emit to subscribers.
await registration.events.emit("updated", { itemID: "item-1", text: result.text })
// ---------------------------------------------------------------- events
// Subscribe with the LOCAL name. The received `type` is prefixed
// ("rpc.acme.updated"). Every event carries `data` and `location`.
//
// Subscriptions are LIVE ONLY: a disconnected subscriber misses events.
// Plugin unload closes them.
//
// const unsubscribe = acme.events.on("updated", (event) => {
// console.log(event.type, event.location.directory, event.data.text)
// })
// unsubscribe()
//
// for await (const event of acme.events.subscribe("updated")) {
// console.log(event.data.text)
// }
return () => {
void registration.dispose()
}
},
})
// Calling from a client over HTTP:
// import { OpenCode } from "@opencode/client"
// import { Acme } from "opencode-acme-plugin/rpc"
// const client = OpenCode.make({ baseUrl: "http://localhost:4096" })
// const acme = client.rpc(Acme)
// const result = await acme.search({ query: "hello" })
// Calling from another plugin (a TUI plugin uses `context.client`):
// import { Plugin } from "@opencode/plugin/tui"
// const acme = context.client.rpc(Acme)
// const result = await acme.search({ query: "hello" })
declare function findText(query: string, options: { signal: AbortSignal }): Promise<string | undefined>
+69
View File
@@ -0,0 +1,69 @@
// RPC contract — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/rpc/
//
// Copy to src/rpc.ts. This file is the contract only — no implementation.
// Registered by rpc-implementation.ts (Promise) or rpc-implementation-effect.ts
// (Effect). Use rpc-effect.ts instead if you want Effect Schema.
// ---------------------------------------------------------------------------
// PREREQUISITE: install @opencode/plugin at your OpenCode version and restart
// the service — see the PREREQUISITE block in promise-plugin.ts.
// ---------------------------------------------------------------------------
import { Rpc } from "@opencode/plugin/rpc"
// ---------------------------------------------------------------------------
// src/rpc.ts — the contract. No implementation here.
//
// Schemas may be JSON Schema (simple, no deps; values are `unknown` in
// TypeScript so narrow them) or any Standard Schema validator such as Zod,
// Valibot, or ArkType (types are inferred).
//
// Error names starting with "rpc." are reserved by OpenCode.
// Event data must be an object: scalars, arrays, null, and undefined are invalid.
export const Acme = Rpc.define({
// The page shows received event types arriving prefixed, e.g. rpc.acme.updated.
// It does not otherwise describe how `id` affects method routing.
id: "acme",
methods: {
search: {
input: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
output: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
additionalProperties: false,
},
// Omit input or output entirely if the method takes or returns nothing.
errors: {
// Each key becomes the error's `type`; this schema defines its `data`.
not_found: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
},
},
},
events: {
updated: {
schema: {
type: "object",
properties: {
itemID: { type: "string" },
text: { type: "string" },
},
required: ["itemID", "text"],
additionalProperties: false,
},
},
// No data at all? Use an empty object schema.
// refreshed: { schema: { type: "object", additionalProperties: false } },
},
})
@@ -0,0 +1,146 @@
// TUI / CLI plugin — template
//
// Docs: https://opencode.ai/v2/docs/build/plugins/cli/
// Loading: https://opencode.ai/v2/docs/cli/plugins
//
// A TUI plugin is a SEPARATE export from a server plugin. One package can have
// both: "." is the server plugin, "./tui" is this file.
// PREREQUISITE: install the package at your OpenCode version and restart the
// service (see the PREREQUISITE block in promise-plugin.ts).
//
// This entrypoint also needs its JSX runtime peers: solid-js, @opentui/core,
// @opentui/solid, @opencode/theme. Without them the file will not load, and
// `tsc` reports that JSX.IntrinsicElements is missing.
import { Plugin } from "@opencode/plugin/tui"
export default Plugin.define({
id: "acme.cli",
// The page's own examples use `async setup(context)` when they await.
async setup(context) {
// ---------------------------------------------------------------- basics
const location = context.location ?? context.data.location.default()
void context.app.version
void context.options.compact
// ---------------------------------------------------------------- toasts
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
// -------------------------------------------------------------- commands
// The layer factory MUST be pure. OpenCode evaluates it once without
// dependency tracking to validate the initial shape, then reactively.
context.keymap.layer(() => ({
mode: "global",
priority: 10,
commands: [
{
id: "acme.status",
title: "Show Acme status",
group: "Acme",
bind: "ctrl+g",
palette: true,
slash: { name: "acme", aliases: ["status"], arguments: true },
enabled: () => true,
suggested: true,
run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }),
},
],
bindings: ["acme.status"],
}))
// ---------------------------------------------------------------- events
// Both return an unsubscribe function.
const stopPermission = context.data.on("permission.asked", (event) => {
context.ui.toast.show({ message: `Permission ${event.data.id}` })
})
const stopAll = context.data.listen(({ details }) => console.log(details.type))
// ---------------------------------------------------------------- dialog
// await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
// const ok = await context.ui.dialog.confirm({ title: "Continue?", message: "Run it?" })
// const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" })
// const mode = await context.ui.dialog.select({
// title: "Mode",
// current: "safe",
// options: [
// { title: "Safe", value: "safe", description: "Ask before changes" },
// { title: "Fast", value: "fast", category: "Advanced" },
// ],
// // `search` owns matching and order. It runs for EVERY query, including
// // the empty one, and receives only enabled options.
// search: (query, options) =>
// options
// .filter((o) => o.title.toLowerCase().includes(query.toLowerCase()))
// .toSorted((l, r) => r.title.localeCompare(l.title)),
// // Actions appear in the footer. They do NOT close the dialog or settle
// // the promise. Never bind ctrl+n / ctrl+p — the dialog reserves them.
// actions: [
// { bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
// { bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
// ],
// })
// ---------------------------------------------------------------- slots
// Placement: prepend | append | before | after | replace.
// Use "home.footer.status" to ADD to the built-in footer without replacing
// it. Status contributions appear after health indicators, before the version.
// The page's own snippet RETURNS context.ui.slot(...) from setup, but never
// says the return value is a teardown (unlike router.register, where it
// does). Treat removeSlot() below as an assumption to verify.
const removeSlot = context.ui.slot({
append: "sidebar.content",
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
})
// ---------------------------------------------------------------- storage
// store: persists across restarts, syncs across TUI instances.
// memory: survives plugin reloads, discarded when the TUI exits.
const [settings, updateSettings] = context.storage.store("settings", {
initial: { compact: false },
})
await updateSettings((draft) => {
draft.compact = true
})
// -------------------------------------------------------------- attention
// context.attention.notify({
// title: "OpenCode",
// message: "Session done",
// notification: { when: "blurred" },
// sound: { name: "done", volume: 0.5, when: "always" },
// })
// ----------------------------------------------------------------- model
// current() and variant.list() are reactive in a Solid computation.
// variant.set returns false when no model is selected or it has no such
// variant. set(undefined) returns to the model default.
// const selected = context.ui.model.current()
// context.ui.model.variant.set("high")
// ------------------------------------------------------------------ tabs
// if (context.ui.tabs.enabled()) {
// context.ui.tabs.open(backgroundSessionID) // leaves focus unchanged
// context.ui.tabs.focus(sessionID) // opens if needed
// const tabs = context.ui.tabs.list()
// context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
// context.ui.tabs.close(sessionID)
// }
// ------------------------------------------------------------- formatting
// const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
// Return a cleanup function for resources the plugin owns. Separately, a
// KEYMAP LAYER is also removed when the plugin is disabled, reloaded, or
// fails setup, or when the component/reactive computation that created it
// (a slot, route, or dialog) is disposed. Disabling a plugin removes slot
// contributions through normal slot cleanup.
return () => {
stopPermission()
stopAll()
removeSlot()
void settings
void location
}
},
})