Skip to content
Build docs

RPC

Contents

Effect plugins can expose methods and events that return typed Effects and Streams.

Use Rpc.define with Effect Schema to define the RPC.

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 values and infers their TypeScript types.

TypeScript
input: Schema.Struct({ query: Schema.String })

JSON Schema and Standard Schema are also supported.

Use input for the method argument and output for its return value. Leave either one out when there is no value.

TypeScript
search: {
  input: Schema.Struct({ query: Schema.String }),
  output: Schema.Struct({ text: Schema.String }),
}

Add expected failures to errors. Each key becomes the error’s type.

TypeScript
errors: {
  not_found: Schema.Struct({ query: Schema.String }),
}

Add events to the top-level events map. Event data must be an object.

TypeScript
events: {
  updated: {
    schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
  },
}

Register the implementation from the plugin Effect:

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),
})

The error map becomes the error type of each method Effect. Use context.error(...) to create a declared error.

Once the RPC is registered, it can be called over HTTP or from another plugin.

Create the Effect client, then create the RPC subclient:

TypeScript
import { OpenCode } from "@opencode/client/effect"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/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)))

Another Effect plugin can create a local subclient from its context:

TypeScript
import { Effect } from "effect"

effect: (ctx) =>
  Effect.gen(function* () {
    const acme = ctx.rpc(Acme)
    const result = yield* acme.search({ query: "hello" })
    yield* Effect.logInfo(result.text)
  }).pipe(Effect.orDie)

RPC events are Streams. Subscribe by event name and run the Stream in a scoped fiber:

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