Files
xiaoshuai 15a1bbcee0 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
2026-10-08 13:12:10 +08:00

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 unknown in 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 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.

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.