opencode server API
opencode runs as an HTTP server (opencode serve). The @opencode-ai/sdk
package provides a typed client. All arbe commands (loop, do, wait, result)
talk to this server — locally or on a sprite over the network.
import { createOpencodeClient, createOpencodeServer } from '@opencode-ai/sdk'
// local: start embedded serverconst server = await createOpencodeServer({ port: 0 })const client = createOpencodeClient({ baseUrl: server.url })
// sprite: connect to remote server with basic authconst creds = Buffer.from(`opencode:${password}`).toString('base64')const client = createOpencodeClient({ baseUrl: 'http://host:port', headers: { Authorization: `Basic ${creds}` },})Sessions
Sessions are the core unit. Each session is an independent conversation with an agent. Create one, send a prompt, watch it work, read the result.
create
const res = await client.session.create()const session = res.data // { id, title, projectID, directory, time, ... }Optional: { parentID } to fork from an existing session.
list
const res = await client.session.list()const sessions = res.data // Session[]Query params: directory, limit, start (timestamp), search (title),
roots (only root sessions, no forks).
get
const res = await client.session.get({ path: { id: sessionID } })status (all sessions)
const res = await client.session.status()const statuses = res.data // { [sessionID]: SessionStatus }SessionStatus is one of:
{ type: "idle" }— waiting for input{ type: "busy" }— agent is working{ type: "retry", attempt, message, next }— hit an error, retrying
This is the key polling endpoint for a dashboard — one call gives you the status of every session.
update
await client.session.update({ path: { id: sessionID }, body: { title: 'new title' },})delete
await client.session.delete({ path: { id: sessionID } })abort
await client.session.abort({ path: { id: sessionID } })Stops a running session.
Prompting
async (fire and forget)
await client.session.promptAsync({ path: { id: sessionID }, body: { parts: [{ type: 'text', text: 'do the thing' }], agent: 'build', // which agent to use },})Returns 204 immediately. The session starts working in the background. Watch the event stream or poll status to know when it’s done.
This is what arbe loop and arbe do use.
sync (streaming response)
const res = await client.session.prompt({ path: { id: sessionID }, body: { parts: [{ type: 'text', text: 'do the thing' }], agent: 'build', },})Streams the response body. Blocks until the agent is done.
messages (read transcript)
const res = await client.session.messages({ path: { id: sessionID } })for (const msg of res.data) { // msg.info.role === 'user' | 'assistant' // msg.parts — array of text, tool, file, etc.}Event stream (SSE)
The single most important primitive. One subscription gives you real-time visibility into everything happening on the server.
const res = await client.global.event()const stream = res.stream // AsyncGenerator<{ payload: Event }>
for await (const ev of stream) { const p = ev.payload switch (p.type) { case 'session.status': // session went idle/busy/retry case 'session.created': // new session case 'session.updated': // title, summary changed case 'session.deleted': // session removed case 'session.error': // session hit an error case 'message.part.updated': // text chunk, tool call, etc. case 'permission.updated': // permission request pending }}event types we care about
session.status
{ type: 'session.status', properties: { sessionID: string, status: { type: 'idle' } | { type: 'busy' } | { type: 'retry', attempt, message, next } }}The main event for tracking session lifecycle.
session.created / session.updated / session.deleted
{ type: 'session.created', // or updated, deleted properties: { info: Session }}session.error
{ type: 'session.error', properties: { sessionID?: string, error?: Error }}message.part.updated
{ type: 'message.part.updated', properties: { part: Part, // see "parts" below delta?: string // incremental text (for streaming) }}This is the firehose — every text chunk, every tool call, every file edit flows through here.
permission.updated
{ type: 'permission.updated', properties: { id: string, sessionID: string, title: string, // ... }}other event types (less common)
| event | when |
|---|---|
message.updated | full message info changed |
message.removed | message deleted |
message.part.removed | part deleted |
session.diff | file changes available |
todo.updated | task list changed |
file.edited | file was edited |
vcs.branch.updated | branch changed |
permission.replied | permission was answered |
Permissions
When a session needs to run a tool that requires approval, a
permission.updated event fires. Respond to it:
// new APIawait client.permission.reply({ path: { requestID: permissionId }, body: { reply: 'always' }, // 'once' | 'always' | 'reject'})
// legacy API (still works, used in loop.ts currently)await client.postSessionIdPermissionsPermissionId({ path: { id: sessionID, permissionID: permissionId }, body: { response: 'always' },})To skip permissions entirely, configure the server at startup:
const server = await createOpencodeServer({ port: 0, config: { permission: { edit: 'allow', bash: 'allow', webfetch: 'allow', doom_loop: 'allow', external_directory: 'allow', }, },})Agents
List available agents (built-in + custom from agents/ dir):
const res = await client.app.agents()const agents = res.data // Agent[]// each: { name, description, mode, builtIn, permission, model, tools, ... }Agent mode:
primary— can be selected as the main agent (build, plan)subagent— spawned by other agents (explore, general)all— usable in either role
Specify which agent handles a prompt via the agent field in
session.prompt() / session.promptAsync().
Message parts
Messages are composed of parts. The interesting ones:
| part.type | what | key fields |
|---|---|---|
text | LLM output text | text, time.end (null = still streaming) |
tool | tool invocation | tool (name), state.status, state.input, state.output, state.title |
step-start | new inference step | snapshot |
step-finish | step completed | reason, cost, tokens.{input,output,cache} |
file | attached file | filename, mime, url |
agent | agent switch | name |
subtask | spawned subtask | prompt, agent |
For tool parts, state.status is: pending → running → completed | error.
A finalized text part has time.end != null. This is how loop.ts
distinguishes streaming chunks from final text.
step grouping
step-start and step-finish bookend each inference round-trip. All tool
calls between them belong to the same step — this is the natural unit for
grouping tool calls in display (one collapsible group per step).
step-finish.reason is "tool-calls" when the step ended to execute tools,
"end-turn" when the agent finished. Useful for deciding whether more work
is coming.
tool state fields
state.title is a human-readable label for the tool call, already generated
by opencode (e.g. "Check agent vs system-prompt flags", "settings.local.json").
Prefer this over constructing a label from the tool name and input.
state.metadata has tool-specific extras: bash parts have metadata.exit and
metadata.truncated; read parts have metadata.preview.
synthetic text parts
Some text parts have synthetic: true — injected by opencode, not from the
LLM (e.g. “Called the list tool with the following input: …”). Filter these
out when displaying assistant prose.
storage layout
Live data is now persisted in SQLite at ~/.local/share/opencode/opencode.db.
Useful tables: project, session, message, part.
To browse a real session:
# find the databaseopencode db path
# list recent sessionsopencode session list
# inspect messages or parts directlyopencode db "select id, data from message where session_id = '<sessionID>' order by time_created"opencode db "select id, data from part where session_id = '<sessionID>' order by time_created"On a sprite the layout is identical, but the database lives on the remote machine.
Inspect via sprite exec:
sprite exec opencode db pathsprite exec opencode session listsprite exec opencode db "select id, data from message where session_id = '<sessionID>' order by time_created"Or use arbe resume <sessionID> / arbe result <sessionID>.
resume uses the opencode server connection; result fetches remote messages
via sprite exec + curl, so neither path needs direct filesystem access.
Patterns
dashboard: poll all session statuses
const { data } = await client.session.status()for (const [id, status] of Object.entries(data)) { console.log(id, status.type) // 'idle' | 'busy' | 'retry'}dashboard: live updates via SSE
const res = await client.global.event()for await (const ev of res.stream) { if (ev.payload?.type === 'session.status') { const { sessionID, status } = ev.payload.properties updateDashboard(sessionID, status) }}fire-and-forget with result
const session = (await client.session.create()).dataawait client.session.promptAsync({ path: { id: session.id }, body: { parts: [{ type: 'text', text: task }], agent: 'build' },})
// ... later, after session.status shows idle ...
const messages = (await client.session.messages({ path: { id: session.id } })).dataconst text = messages .filter(m => m.info.role === 'assistant') .flatMap(m => m.parts.filter(p => p.type === 'text')) .map(p => p.text) .join('')multi-server monitoring (local + sprites)
Each opencode server is independent. To monitor multiple:
const clients = [ { name: 'local', client: makeClient(localUrl) }, { name: 'sprite-1', client: makeClient(sprite1Url, sprite1Auth) }, { name: 'sprite-2', client: makeClient(sprite2Url, sprite2Auth) },]
// poll statusesfor (const { name, client } of clients) { const { data } = await client.session.status() console.log(name, data)}
// or subscribe to each event streamfor (const { name, client } of clients) { subscribeEvents(name, client) // each runs independently}Connection info for sprites lives in .arbe/opencode.json on each sprite,
fetched via sprite exec cat .arbe/opencode.json.
API discovery
Full OpenAPI 3.1 spec: https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json
Also served at /doc on any running instance. SDK client methods are generated from this spec.