# RPC Plugins **Promise docs: ** **Effect docs: ** **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 (). 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 (), 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 () 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.