Skip to content

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 server
const server = await createOpencodeServer({ port: 0 })
const client = createOpencodeClient({ baseUrl: server.url })
// sprite: connect to remote server with basic auth
const 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)

eventwhen
message.updatedfull message info changed
message.removedmessage deleted
message.part.removedpart deleted
session.difffile changes available
todo.updatedtask list changed
file.editedfile was edited
vcs.branch.updatedbranch changed
permission.repliedpermission was answered

Permissions

When a session needs to run a tool that requires approval, a permission.updated event fires. Respond to it:

// new API
await 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.typewhatkey fields
textLLM output texttext, time.end (null = still streaming)
tooltool invocationtool (name), state.status, state.input, state.output, state.title
step-startnew inference stepsnapshot
step-finishstep completedreason, cost, tokens.{input,output,cache}
fileattached filefilename, mime, url
agentagent switchname
subtaskspawned subtaskprompt, agent

For tool parts, state.status is: pendingrunningcompleted | 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:

Terminal window
# find the database
opencode db path
# list recent sessions
opencode session list
# inspect messages or parts directly
opencode 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:

Terminal window
sprite exec opencode db path
sprite exec opencode session list
sprite 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()).data
await 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 } })).data
const 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 statuses
for (const { name, client } of clients) {
const { data } = await client.session.status()
console.log(name, data)
}
// or subscribe to each event stream
for (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.