docs: improve build documentation discovery

This commit is contained in:
Dax Raad 2026-08-31 12:46:49 -04:00
parent 6a38cacc1d
commit 38a10547b4
11 changed files with 368 additions and 123 deletions

View file

@ -11,6 +11,9 @@ truth. Follow links from that page when the question needs more detail. Fetch
<https://opencode.ai/v2/docs/> first when you need to discover the relevant
documentation page.
A machine-readable documentation index is available at
<https://opencode.ai/v2/llms.txt>.
## Version policy
Always answer for OpenCode V2 unless the user explicitly asks about V1,
@ -152,6 +155,8 @@ before answering. Refer to this guide when the user wants to build a plugin. It
covers hooks, transforms, tools, plugin context capabilities, and package
entrypoints. Plugins can also extend the TUI; for those, fetch the
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
For custom methods and events shared with other plugins or clients, fetch the
[RPC guide](https://opencode.ai/v2/docs/build/plugins/rpc).
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)
@ -220,6 +225,16 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
`Service` API can discover, start, stop, and authenticate with the local
background service from a Node application.
## [SDK](https://opencode.ai/v2/docs/build/sdk)
For questions about embedding OpenCode directly in an application, fetch the
full [SDK guide](https://opencode.ai/v2/docs/build/sdk) before answering. The SDK
hosts OpenCode in the application without opening an HTTP listener.
Use the [Effect SDK guide](https://opencode.ai/v2/docs/build/sdk/effect) for
Effect applications. For Cloudflare Durable Objects, use the
[Cloudflare SDK guide](https://opencode.ai/v2/docs/build/sdk/cloudflare).
## [Troubleshooting](https://opencode.ai/v2/docs/troubleshooting)
OpenCode runs a client and a background server. Start by determining whether a

View file

@ -14,6 +14,7 @@
- Keep prose sections brief and focused on one idea. Prefer one to three sentences over large paragraphs.
- Interleave explanations with concrete code, configuration, command, or output examples so pages do not become walls of text.
- Put the relevant example immediately after the text that introduces it, following `content/build/plugins/cli.mdx` as the reference pattern.
- Every subsection that explains syntax, fields, or an API concept must include its own minimal example. A larger example earlier on the page does not count.
- Split long explanations with meaningful headings and examples rather than accumulating caveats in one paragraph.
- Lead with the common task and working example; place edge cases and supporting details afterward.
- Do not stack several prose paragraphs without a visual break. After introducing a concept, use an example, list, table, or task-oriented subheading before covering the next concern.

View file

@ -104,7 +104,7 @@ RPC error wrapper; reserved `rpc.*` types identify framework failures.
RPC events are typed Streams, not callback-style `on` listeners. They receive the
RPC's events from all locations, each with required `location` and a normal
prefixed type such as `rpc.acme.updated`. This differs from server-plugin handles,
which are fixed to their own location. See [plugin RPC](/build/plugins/rpc) for
which are fixed to their own location. See [Effect plugin RPC](/build/plugins/effect/rpc) for
definitions, schemas, registration, and live subscription semantics.
## Local background service

View file

@ -6,6 +6,9 @@ OpenCode is used by millions every day. Build on top of it to create your own
applications, integrations, and agent experiences without starting from
scratch.
We also offer Effect APIs for [plugins](/build/plugins/effect),
[clients](/build/client/effect), and [embedded apps](/build/sdk/effect).
<CardGroup cols={1}>
<Card title="Extend OpenCode" href="/build/plugins">
Build plugins that add tools, integrations, commands, agents, and custom behavior while keeping the rest of OpenCode

View file

@ -631,59 +631,6 @@ interface Context {
}
```
### RPC
Use the same execution-neutral [`Rpc.define` builder](/build/plugins/rpc).
Effect clients and plugins accept Effect Schema, Standard Schema, or plain JSON
Schema. Promise consumers accept only the portable Standard and JSON formats.
```ts
import { Plugin } from "@opencode-ai/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 })),
),
),
})
yield* registration.events.emit("updated", { itemID: "item-1", text: "ready" })
}).pipe(Effect.orDie),
})
```
Effect handlers use normal interruption. Registrations belong to the plugin
scope; `yield* registration.dispose` removes one explicitly. Later registrations
override earlier ones at the same location, without changing in-flight handlers.
`ctx.rpc(Acme)` returns a local typed subclient. Its methods return Effects and
`events.subscribe(name)` returns a Stream. Use scoped fibers when listening
during plugin lifetime:
```ts
const acme = ctx.rpc(Acme)
yield *
acme.events.subscribe("updated").pipe(
Stream.runForEach((event) => Effect.logInfo(event.data.text)),
Effect.forkScoped,
)
```
There is no Effect callback-style `on` API. Subscriptions are location-bound,
live-only, and close when Stream consumption stops. Events use the normal
ephemeral Bus path. Method `errors` maps become typed Effect error channels. Construct one
with `context.error(...)` and fail it with `Effect.fail`; unexpected failures and
transport errors remain separate from the declared method errors.
### References
Read references available at the current location.

View file

@ -0,0 +1,169 @@
---
title: "RPC"
---
Effect plugins can expose methods and events that return typed Effects and
Streams.
## Define
Use `Rpc.define` with Effect Schema to define the RPC.
```ts title="src/rpc.ts"
import { Rpc } from "@opencode-ai/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 }),
},
},
})
```
### Validation
Effect Schema validates values and infers their TypeScript types.
```ts
input: Schema.Struct({ query: Schema.String })
```
JSON Schema and Standard Schema are also supported.
### Input and output
Use `input` for the method argument and `output` for its return value. Leave
either one out when there is no value.
```ts
search: {
input: Schema.Struct({ query: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
}
```
### Errors
Add expected failures to `errors`. Each key becomes the error's `type`.
```ts
errors: {
not_found: Schema.Struct({ query: Schema.String }),
}
```
### Events
Add events to the top-level `events` map. Event data must be an object.
```ts
events: {
updated: {
schema: Schema.Struct({ itemID: Schema.String, text: Schema.String }),
},
}
```
## Implement
Register the implementation from the plugin Effect:
```ts title="src/index.ts"
import { Plugin } from "@opencode-ai/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.
## Call
Once the RPC is registered, it can be called over HTTP or from another plugin.
### HTTP
Create the Effect client, then create the RPC subclient:
```ts
import { OpenCode } from "@opencode-ai/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)))
```
### Plugin
Another Effect plugin can create a local subclient from its context:
```ts
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)
```
### Subscribe
RPC events are Streams. Subscribe by event name and run the Stream in a scoped
fiber:
```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.

View file

@ -2,79 +2,129 @@
title: "RPC"
---
Expose typed methods and custom events through a shared RPC definition. Start by
putting the contract in a browser-safe module, separate from plugin setup and
server code.
Plugins can expose custom methods and events that run on the server and can be
called by other plugins or clients.
## Define the contract
## Define
Use `Rpc.define` to declare the RPC ID, methods, errors, and events. The builder
is synchronous and independent of Promise or Effect execution.
Use `Rpc.define` to list the RPC's methods, errors, and events.
```ts title="src/rpc.ts"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { z } from "zod"
export const Acme = Rpc.define({
id: "acme",
methods: {
search: {
input: z.object({ query: z.string() }),
output: z.object({ text: z.string() }),
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: z.object({ query: z.string() }),
not_found: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
},
},
},
events: {
updated: {
schema: z.object({ itemID: z.string(), text: z.string() }),
schema: {
type: "object",
properties: {
itemID: { type: "string" },
text: { type: "string" },
},
required: ["itemID", "text"],
additionalProperties: false,
},
},
},
})
```
## Choose schemas
### Validation
Use Standard Schema, such as Zod, when Promise and Effect consumers share the
contract. It validates at runtime and infers TypeScript types.
An RPC definition describes the shapes of input and output of methods, events
and errors. It supports two schema formats:
Other schema formats have narrower tradeoffs:
- JSON Schema, simple and requires no dependencies.
- Any Standard Schema compliant validator
- Zod
- Valibot
- ArkType
- Plain JSON Schema validates at runtime, but its inferred TypeScript value is `unknown`.
- Plain JSON Schema uses Draft 2020-12 through Effect's JSON Schema importer and decoder.
- Use Standard Schema when you need another JSON Schema dialect or parser.
- Effect Schema is supported only by Effect plugins and clients.
### Input and output
Every method declares `input` and `output`. Omit either to represent no value.
An empty event payload is an object instead:
Each method can define an `input` schema for its argument and an `output` schema
for its return value. Leave either one out if the method does not accept or
return a value.
```ts
events: {
refreshed: { schema: z.object({}) },
search: {
input: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
output: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
}
```
Event schemas must produce JSON objects. Scalars, arrays, `null`, and `undefined`
are invalid event data. Plain JSON Schema events are checked when emitted even
though their payload type remains `unknown`.
JSON Schema values are `unknown` in TypeScript, so narrow them before use.
Standard Schema infers the input and output types.
## Declare errors
### Errors
Add an `errors` map to a method for expected failures. Each key becomes the
literal error `type`, and its schema validates and transforms the error `data`.
error's `type`, and its schema defines the error's `data`.
```ts
errors: {
not_found: z.object({ query: z.string() }),
not_found: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
},
}
```
Names beginning with `rpc.` are reserved for framework failures. Contract schemas
own parsing, transformations, and Effect encoding; RPC does not perform another
generic JSON validation pass.
Error names beginning with `rpc.` are reserved by OpenCode.
## Register the implementation
### Events
Add events to the top-level `events` map. Each event has a schema for the data
sent to subscribers.
Event data must be an object. Use an empty object schema when there is no data:
```ts
events: {
refreshed: {
schema: { type: "object", additionalProperties: false },
},
}
```
Scalars, arrays, `null`, and `undefined` are not valid event data.
## Implement
Register the implementation inside `setup`:
@ -86,42 +136,70 @@ export default Plugin.define({
id: "acme-plugin",
async setup(ctx) {
const registration = await ctx.rpc.register(Acme, {
search: async ({ query }, context) => {
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 }
},
})
const acme = ctx.rpc(Acme)
const result = await acme.search({ query: "hello" })
await registration.events.emit("updated", { itemID: "item-1", text: "ready" })
},
})
```
Promise handlers receive `signal` and a typed `error(type, message, data)`
constructor in their second argument. Return or throw a constructed error to
reject callers with `{ type, message, data? }`.
After registering the RPC, the same plugin can call it through `ctx.rpc(Acme)`.
Registration follows these rules:
The second argument includes `signal` for cancellation and `context.error(...)`
for declared errors. You can return or throw the error.
- RPC IDs are independent of plugin IDs, and one plugin can implement several RPCs.
- A later registration overrides an earlier registration at the same location.
- Disposal or plugin unload removes only that registration and reveals the previous one.
- In-flight calls retain the handler with which they started.
One plugin can register more than one RPC. Disposing the registration removes it.
## Call from another plugin
## Call
Other server plugins can obtain a handle without implementing the RPC:
Once the RPC is registered, it can be called over HTTP or from another plugin.
### HTTP
Create an OpenCode client, then use `client.rpc` to create a subclient for the
RPC:
```ts
const acme = ctx.rpc(Acme)
import { OpenCode } from "@opencode-ai/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" })
```
The handle is available immediately, and each call finds the current registration.
Calls stay within the server plugin's location and cannot override it.
### Plugin
## Subscribe to events
Plugins already have an OpenCode client. For example, a TUI plugin can create
the same RPC subclient from `context.client`:
```ts title="src/tui.ts"
import { Plugin } from "@opencode-ai/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
Use `events.on` for a callback and unsubscribe when the listener is no longer
needed:
@ -142,20 +220,12 @@ for await (const event of acme.events.subscribe("updated")) {
}
```
Event subscriptions have these semantics:
Event subscriptions are live only, so disconnected subscribers miss events.
- Event keys use local names such as `updated` when subscribing.
- Delivered types are prefixed, such as `rpc.acme.updated`.
- Each event includes `id`, `created`, direct `data`, required `location`, and optional `metadata`.
- Events use the normal ephemeral Bus path and are live-only; disconnected subscribers miss events.
- Plugin unload closes its subscriptions. There is no plugin event log or replay API yet.
- The method name `events` is reserved for the subclient event API.
- Subscribe with the local name, such as `updated`.
- The received type is prefixed, such as `rpc.acme.updated`.
- Each event includes `data` and `location`.
- Plugin unload closes its subscriptions.
## Connect external clients
External [clients](/build/client#plugin-rpc) use `client.rpc(Acme)` and receive
that RPC's events across all locations. The native `/api/event` stream and
typed subclients observe the same direct `rpc.<rpcID>.<event>` envelope.
Importing the contract or constructing a handle does not load its server
implementation. Configure the plugin on the server separately.
External clients receive events from every location, so check `event.location`
when needed. The server plugin still needs to be configured and running.

View file

@ -7,6 +7,8 @@ title: "Overview"
calls through its HTTP router in memory. It opens no HTTP listener and adds no
network hop between the client and server.
For Cloudflare Durable Objects, see the [Cloudflare guide](/build/sdk/cloudflare).
<Callout type="warning">
The V2 SDK is beta. Install the current preview with `bun add @opencode-ai/sdk@dev`; its API may change before a
stable release.

View file

@ -0,0 +1,25 @@
import { docsSections } from "./navigation"
export function renderLlmsTxt(site: URL) {
const base = new URL(import.meta.env.BASE_URL, site)
const sections = docsSections.flatMap((section) => [
`## ${section.title}`,
"",
...section.groups.flatMap((group) => [
...(group.title && group.title !== section.title ? [`### ${group.title}`, ""] : []),
...group.items.map((item) => {
const path = item.slug === "index" ? "" : `${item.slug.replace(/\/index$/, "")}/`
return `- [${item.title}](${new URL(`docs/${path}`, base)})`
}),
"",
]),
])
return [
"# OpenCode V2 Documentation",
"",
"> Official documentation for using, configuring, and building with OpenCode V2.",
"",
...sections,
].join("\n")
}

View file

@ -95,25 +95,29 @@ export const docsSections: DocsSection[] = [
items: [
{ title: "Overview", slug: "build/plugins" },
{ title: "RPC", slug: "build/plugins/rpc" },
{ title: "Effect", slug: "build/plugins/effect" },
{ title: "CLI", slug: "build/plugins/cli" },
],
},
{
title: "Client",
items: [
{ title: "JavaScript", slug: "build/client" },
{ title: "Effect", slug: "build/client/effect" },
],
items: [{ title: "JavaScript", slug: "build/client" }],
},
{
title: "SDK",
items: [
{ title: "Overview", slug: "build/sdk" },
{ title: "Effect", slug: "build/sdk/effect" },
{ title: "Cloudflare", slug: "build/sdk/cloudflare" },
],
},
{
title: "Effect",
items: [
{ title: "Plugins", slug: "build/plugins/effect" },
{ title: "RPC", slug: "build/plugins/effect/rpc" },
{ title: "Client", slug: "build/client/effect" },
{ title: "SDK", slug: "build/sdk/effect" },
],
},
],
},
{

View file

@ -0,0 +1,9 @@
import type { APIRoute } from "astro"
import { renderLlmsTxt } from "../docs/lib/llms"
export const prerender = true
export const GET: APIRoute = ({ site }) =>
new Response(renderLlmsTxt(site ?? new URL("https://opencode.ai")), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
})