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
+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.