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
11 KiB
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.
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:
{
"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
unknownin TypeScript, so narrow them before use. - Any Standard Schema validator — Zod, Valibot, ArkType. Types are inferred.
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:
events: { refreshed: { schema: { type: "object", additionalProperties: false } } }
Implement (Promise page)
Register inside setup:
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
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:
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:
const unsubscribe = acme.events.on("updated", (event) => {
console.log(event.type, event.location.directory, event.data.text)
})
unsubscribe()
Async iterable form:
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 receivedtypeis prefixed (rpc.acme.updated). - Every event carries
dataandlocation. - 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.
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:
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:
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:
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.