15a1bbcee0
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
330 lines
11 KiB
Markdown
330 lines
11 KiB
Markdown
# 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.
|