The first pass was written from the running server's own OpenAPI document and live probes. This adds what the source at tag v1.18.16 says, which changes three things. The names are transitional at BOTH ends. session.next.* is the event family of the rewritten event-sourced engine, landed in 1.15.0 (PR #27415); on the v2 branch all 36 events have already dropped the .next. and some are renamed outright — agent.switched becomes agent.selected, prompted becomes prompt.promoted. Those renames are v2-branch only and the 1.x line we run still emits the old names, so the guidance is to code against them but keep one mapping table. The schema package's own AGENTS.md says the V2 suffix is going too. Upstream calls the /api surface EXPERIMENTAL in its own title — "Experimental HttpApi surface for selected instance routes", version 0.0.1 — while /session/* is what the public docs document and is not deprecated. Worth writing down plainly: the internal direction is unambiguous, the external commitment is nil, and we would be building on a surface its authors have not committed to. The SDK is generated from the exact document we probed: the build script runs opencode's own generate and feeds it to hey-api, and @opencode-ai/sdk/v2 exposes the whole /api surface, takes a directory and injects it as both the header and the location query param. That is our hand-rolled SSE reader, both envelope unwrappers, three type sets and the model-id splitting, deleted. Also corrected by reading rather than guessing: permissions v2 is a real contract change (rules, requests and the reply all change shape, and free-text replies are gone) while questions v2 is a pure re-homing with identical fields — so they are not one piece of work. And the durable cursor's replay-then-live is gap-free by construction: it re-reads the database on every wake instead of draining a buffer, with the prompt response's admittedSeq as the first cursor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26 KiB
OpenCode's newer API — what it is, what it would cost, what it buys
Written 2026-08-11 against opencode 1.18.16, from three sources: the running server's own OpenAPI
document (GET /doc on opencode serve), live probes against a real serve, and upstream docs/npm.
Every claim below is marked by where it came from. Measurements were taken on the local serve
(port 49698, the officer-opencode sidecar's own) and cleaned up afterwards — the session store is
back to the 50 rows it started with.
Read this before starting any opencode work. Two live defects fell out of writing it (§1), and the naming is actively misleading (§2).
1. Two live defects, found while measuring
Neither is a migration concern. Both are broken right now, in production, and both are consequences of being half-migrated.
1a. Every OpenCode conversation created since 2026-08-10 opens EMPTY
Since Phase D, turns run through POST /api/session/{id}/prompt, so the session belongs to the newer
engine. But loadOpenCodeSession reads the transcript through the legacy route
(client.ts:51 → GET /session/{id}/message).
The two surfaces are mutually blind. Measured, both directions, on a session created via /api and
run to completion with a real model reply:
| read | api-created session | legacy-created session |
|---|---|---|
GET /session/{id}/message (what we call) |
[] — 0 messages |
200, full transcript |
GET /api/session/{id}/message |
200, 3 messages | 500 |
GET /session/{id} (the record) |
200, title + directory | 200 |
So the row appears in the list with its title and directory, and opens with nothing in it. And the
inverse is equally true: switching the reader to /api without keeping the old one would empty every
conversation from before 2026-08-10.
The fix is not "swap the endpoint" — it is "route by which engine owns the session", and there is no
field that says so. The one usable discriminator found tonight is that the legacy read returns []
rather than erroring.
1b. The session list silently truncates at 50
GET /api/session defaults to 50 rows and returns a cursor.next. Measured: with 50 sessions in
the store the list returns 50 and still offers a next cursor; adding a 51st and asking ?limit=200
returns 51 (and limit is capped at 100 — 200 is accepted for the list but /history rejects >100
with Expected a value less than or equal to 100).
client.ts:36 sends neither limit nor cursor, so once the store passes 50 sessions the oldest
stop appearing in /chat. The local store is at exactly 50 today. This is in code shipped this
morning (adaaba6).
The same endpoint takes directory= — verified filtering correctly (?directory=/tmp/oc-cap → 11
rows, all in that directory). We fetch everything and filter client-side in opencode-sessions.ts:49.
Pushing the filter down fixes the normal case and brings search=, order=, project= with it.
2. The naming, because "API 2.0" means two different things
There is no version string "2.0" in the running server. GET /doc self-reports
{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}. What actually exists:
| legacy | the /api/* surface |
OpenCode 2.0 beta | |
|---|---|---|---|
| where | in 1.18.16 | in 1.18.16 | separate product, binary opencode2, npm @next |
| routes | 111 paths | 51 paths | ~100 paths, still moving |
| operationIds | session.list |
v2.session.list |
— |
| we use it | reads: transcript, delete, rename | writes: every turn since 2026-08-10 | not at all |
| docs | opencode.ai/docs/server (stale — never mentions /api/*) |
undocumented publicly | opencode.ai/v2/docs |
So "API 2.0" most likely means the /api/* surface — which we already run on for turns. Its
operation ids are literally v2.*. It is not something to adopt; it is something to finish.
Two qualifications, both from the source at tag v1.18.16:
- Upstream calls it experimental.
packages/protocol/src/api.tstitles it"opencode HttpApi", version"0.0.1", described as "Experimental HttpApi surface for selected instance routes", with every group annotated the same way. Meanwhile/session/*is the surface the public docs actually document, and it is not deprecated. The internal direction is unambiguous; the external commitment is nil. session.nextis the event family of that rewritten engine, and the name is already dead upstream. It arrived in 1.15.0 (PR #27415, "Add Effect-native core event system", merged 2026-05-15) as an interim prefix. On thev2branch all 36 session events have dropped.next.—session.step.started,session.text.delta— along with renames:agent.switched→agent.selected,model.switched→model.selected,prompted→prompt.promoted. Those renames are v2-branch only; the 1.x line we run still emitssession.next.*. Code againstsession.next.*today, but put the names behind one mapping table, because they are scheduled to change wholesale.
Same for the v2 suffix itself. packages/schema/AGENTS.md: "V1 coexistence is temporary… delete the
V1 subtree when the legacy runtime is retired" and "Do not preserve V2 as the permanent name for the
replacement architecture." Both halves of today's naming are transitional.
OpenCode 2.0 the product is a different question, and the answer tonight is not yet: the beta docs carry the banner "we may wipe your data, things may break, and APIs, configuration, and plugin APIs may change", releases ship ~6/day, and the migration guide states three intentional breaking changes (plugin API, server API contracts, TUI config), with "Integrations that call the V1 server API must migrate to the V2 API". No deprecation date for the legacy surface is published anywhere.
Two facts worth knowing regardless:
- The repo moved.
github.com/sst/opencode301s togithub.com/anomalyco/opencode. Every npm package now points there. No announcement was found explaining it. - There is already a typed client for the surface we run.
@opencode-ai/sdk@1.18.16ships two generated clients: the default export covers legacy only, and@opencode-ai/sdk/v2covers all 51/api/*routes. We have no opencode dependency at all today — every call is hand-rolledfetch.
3. What we call today
Two of our processes talk to one serve, with no shared client.
Sidecar (src/servers/sidecar/opencode/) — already on /api/*: POST /api/session
(serve-runner.ts:222), POST …/model (:236), POST …/prompt (:266, :199), POST …/interrupt
(:328), GET /api/event (:86), GET /api/health (index.ts:79),
POST /api/integration/{provider}/connect/key (connect-credential.ts:66).
API server (src/servers/api/chat/opencode/client.ts) — still legacy: GET /session/{id} (:45),
GET /session/{id}/message (:51), DELETE /session/{id} (:57), PATCH /session/{id} (:62),
plus GET /config/providers for the model list (list-models.ts:58). The one exception is
GET /api/session for the list (:36), moved this morning.
51 routes exist. We call 7.
4. What the newer surface has that we don't use
4a. Adding context to a turn that is already running
The capability the subprocess path could never have, and the reason the migration happened.
POST /api/session/{id}/prompt
{ "id": "msg_…", "prompt": { "text", "files": [{uri,name,description,source}],
"agents": [{name,source}] },
"delivery": "steer" | "queue", "resume": true|false }
Spec description: "Durably admit one session input and schedule agent-loop execution unless resume is false."
delivery: "steer"injects into the RUNNING turn — the model takes the new text as part of the work in flight. No kill, no restart, no lost context. We already send it (serve-runner.ts:199) but only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI asks for it, and nothing distinguishes "add this to what you're doing" from "here's my next message".delivery: "queue"runs after the current turn. It must be stated explicitly — the field defaults tosteer— or two quick messages merge into one turn (serve-runner.ts:268).prompt.files[]attaches content to that same input; measured last night, it must be adata:URI (afile://one is accepted with 200 and dies inside the provider). Each attachment also takes adescription, which we don't send.prompt.agents[]attaches an agent to the input. Unused, unexplored.idlets the caller mint themsg_…id, which is how a send survives a retry without double-posting. We let the server mint it and therefore can't.
Measured: the POST returns in 22 ms with {"admittedSeq":1,"id":"msg_…","delivery":"queue"}. It is
an admission receipt, not a turn — and admittedSeq is the durable cursor for everything that follows.
4b. Surviving a restart mid-turn — verified working
GET /api/session/{id}/event?after=<seq> "Replay durable events after an aggregate sequence,
then continue with new durable events."
GET /api/session/{id}/history?limit=&after= "Read one finite page of public durable Session events
after an exclusive aggregate sequence."
Driven end to end tonight on a real turn (free model, "Reply with exactly: hi"):
seq 1 session.next.prompt.admitted seq 6 session.next.context.updated
seq 2 session.next.prompted seq 7 session.next.step.started
seq 3 session.next.model.switched seq 8 session.next.text.started
seq 4 session.next.prompt.admitted seq 9 session.next.text.ended
seq 5 session.next.prompted seq 10 session.next.step.ended
?after=5 returned exactly 6–10. GET …/event?after=7 replayed 8, 9, 10 and then held the socket open
for more. Every durable event carries {aggregateID, seq: integer, version}, so after= is that
integer. This is the documented, working answer to the gap Phase B left open and
docs/opencode-testing-checklist.md calls the most likely thing to be broken.
The upstream implementation (packages/core/src/event.ts, durable()) makes three things explicit
that matter for building on it:
afteris an exclusive lower bound on the durable seq, and the aggregate is the session. Omitting it replays the session from 0.- Replay-then-live is gap-free by construction: it reads
WHERE seq > after ORDER BY seq ASC, advances its cursor to the last row, and on every wake re-reads the database rather than draining a pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicitSequence mismatch/Replay divergederrors. - The first cursor is free.
POST …/promptreturns{admittedSeq, id, sessionID, prompt, delivery, timeCreated, promotedSeq?}— measured at 22 ms — andadmittedSeqfeeds straight back asafter.
Note the two cursor kinds are unrelated: the session list uses an opaque base64url cursor
(cursor.previous / cursor.next), this one is a plain integer.
But the two streams are not interchangeable, and the schema says why. SessionDurableEvent is a
oneOf of exactly 28 members, and the five it omits are text.delta, tool.input.delta,
reasoning.delta, compaction.delta and the retry error. Deltas are live-only by design; the
durable log stores whole values. So a client that wants both token streaming and restart recovery
must read both streams: the global live one for deltas, the per-session durable one for the replayable
spine. Last night's 13-vs-21 event count was this same fact, found by counting instead of by reading.
4c. Knowing what is running, without having started it
GET /api/session/active "Retrieve foreground Session drains currently owned by this OpenCode
process. Sessions absent from the result are inactive."
POST /api/session/{id}/wait "Wait for a session agent loop to become idle."
Today "what is running" is an in-memory map in our sidecar (serve-runner.ts:66). Restart the sidecar
and the truth is gone — which is why /chat/live can be wrong after a restart. session/active is the
server's own answer and survives us.
4d. Permissions and questions — nothing in officer models this
GET|POST /api/session/{id}/permission POST …/permission/{requestID}/reply
GET /api/permission/saved DELETE /api/permission/saved/{id}
GET /api/session/{id}/question POST …/question/{requestID}/reply | /reject
Plus permission.v2.asked / question.v2.asked events (the v1 families still exist alongside; the
only deprecated: true operation in the entire document is POST /session/{id}/permissions/{id}).
The two "v2"s are not the same kind of change, which matters if we implement one of them:
- Permissions v2 is a real contract change. A rule goes from
{permission, pattern, action}to{action, resource, effect}; a request from{permission, patterns[], metadata, always[], tool?}to{action, resources[], save?[], metadata?, source?}, with the tool linkage becoming a tagged unionsource: {type:"tool", messageID, callID}; and the reply loses its free-textmessage. The public V2 docs say the same in config terms: "Do not usepermission,bash, ortaskin V2 configuration." - Questions v2 is a re-homing. Field shapes are byte-identical to v1 —
questions[]of{question, header, options[], multiple?, custom?}, answers asstring[][]. Only the namespace and event names changed.
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
/api protocol is built from excludes the v1 families, but the server wires the full manifest
(makeApi({definitions: EventManifest.Latest.values()})), which is why both appear in the /api/event
union on our own /doc.
An opencode agent that wants consent, or that asks a question mid-turn, gets no answer from officer. We
don't subscribe to those events and have no route to reply on. Claude's harness runs
--dangerously-skip-permissions, so this has never been modelled for either harness. Largest single
behavioural gap.
4e. Undo, compaction, context
POST /api/session/{id}/revert/stage {messageID, files?} …/revert/commit …/revert/clear
POST /api/session/{id}/compact GET /api/session/{id}/context
Stage a revert to a message, then commit or discard. Explicit compaction with
compaction.started/delta/ended events, and a readable context state. Officer has none of this.
4f. The rest
GET /api/agent, /api/skill, /api/command, /api/model, /api/provider, /api/fs/{list,find,read},
GET|POST /api/pty (+connect, connect-token), POST /api/session/{id}/agent (switch agent
mid-session), /api/reference, /api/location, /api/integration, /api/credential/{id}.
GET /api/model and /api/provider are the /api equivalents of the /config/providers call our
model list is built on (87 models locally). /api/pty overlaps our own pty sidecar.
5. What the event stream carries that we drop
Our mapper recognises 18 names and maps 7. The server emits 130 event type strings, 32 in the
session.next.* family plus eight plain session.* (idle, status, error, compacted,
created, deleted, updated, diff).
| dropped | what it would give |
|---|---|
reasoning.started/delta/ended |
thinking, streamed — we show none for opencode |
tool.input.delta / .started / .ended |
a tool call rendering as its arguments arrive |
tool.progress |
long tools reporting instead of appearing hung |
shell.started/ended |
shell commands as a first-class thing |
compaction.* |
telling the user the context was compacted |
revert.* |
§4e |
retried |
a retry that currently looks like a stall |
prompt.admitted / prompted |
acknowledgement — the exact window where silence has twice cost an afternoon |
session.idle |
the real turn-end signal (see below) |
We end a turn on step.ended with finish !== 'tool-calls' (serve-runner.ts:139), because there is
no turn-ended event in what we read. session.idle looks like what that rule approximates, and it is
not in our KNOWN set.
6. Two silent-failure modes, both reproduced tonight
Both produce the identical signature — prompt.admitted, prompted, then nothing, forever:
- No credential connected for the
/apisurface. Already known and fixed at boot (connect-credential.ts), but the failure has no error. - No model on the session and no server default. New tonight: my first probe sat at
admitted → promptedand stopped.GET /configreportsmodel: None, and the session had no model because I hadn't set one.POST …/modelthen re-prompting produced the full 10-event turn above.
Our runner only sends POST …/model when params.model is set (serve-runner.ts:231). A turn sent
with no model, against a serve with no configured default, hangs silently. Worth an explicit check.
7. What finishing the migration would cost
- Both readers stay. §1a:
/apireads 500 on legacy-owned sessions, legacy reads[]on api-owned ones. Routing by ownership is required, and no field declares ownership. - Delete and rename cannot move.
/api/session/{sessionID}is GET only;DELETEandPATCHexist only on the legacy route (spec-verified, and a liveDELETEreturned 200). - The transcript shape differs. Legacy items are
{info:{role,…}, parts:[…]}— whatopencode-sessions.ts:81parses./apiitems are{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}], finish, cost, tokens}. A second mapper, or a shared normaliser. - The SSE parser needs to grow up.
serve-runner.ts:89isdata:-only: noevent:, noid:, no comments, noretry:, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream must resume at?after=<last seq>, not restart. - Two envelope unwrappers and three hand-written type sets (
serve-runner.ts:161,client.ts:38; types pinned by comment to two different opencode versions, 1.17.9 and 1.18.16). This is the part a dependency would delete outright — see below. - Stale comments in at least nine files still describe the deleted
opencode runsubprocess path (protocol.ts:198,serve-events.ts:5,connect-credential.ts:24,index.ts:137,websocket.ts:454,chat.ts:127,list-models.ts:75,sidecar-server.ts:8,send-opencode.ts:7). Two of them actively lie: they say turns readauth.jsonand don't depend on the credential connect. They now do.
Unrelated but found while inventorying: the settings UI writes provider keys to ~/.pi/agent/auth.json
(chat-providers.ts:10) while the credential connect reads ~/.local/share/opencode/auth.json
(connect-credential.ts:29). Two different files.
7b. The SDK is generated from the document we have been reading by hand
@opencode-ai/sdk@1.18.16 (published 2026-08-10, versioned in lockstep with the CLI) is built by
packages/sdk/js/script/build.ts, which runs opencode's own generate to produce the OpenAPI document
and feeds it to @hey-api/openapi-ts. It is generated from the same /doc we probed, which is
about as good a guarantee of shape-agreement as exists.
It ships two clients. The default export is the legacy surface. @opencode-ai/sdk/v2 is ours:
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
const client = createOpencodeClient({ baseUrl });
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ }
client.v2.session.* covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
context/history/events/interrupt/message(s); there is also client.v2.event.subscribe,
client.v2.permission.*, client.v2.question.*, fs, model, provider, agent, skill, pty.
createOpencodeClient takes directory and injects it as both the x-opencode-* headers and the
location[directory] query param — the thing we hand-roll in two places.
That would delete: our hand-rolled SSE reader, both envelope unwrappers, three hand-written type sets,
and the model-id string splitting. It is a dependency change, and installs here are frozen, so it is a
deliberate bun install --no-frozen-lockfile plus a read of the lockfile diff. Worth noting the
package's only dependency is cross-spawn.
Not to be confused with two siblings the v2 docs mention: @opencode-ai/sdk-next is marked private and
is not on npm, and @opencode-ai/client is a private generation target for the beta line.
8. What we'd gain immediately
Ordered by value over effort. 1–3 are bug fixes, not features.
- Transcripts that aren't empty — route the read by session ownership. This is broken in production now. (§1a)
- A list that doesn't stop at 50, filtered server-side by
?directory=. One call site. (§1b) - A turn that can't hang silently — set a model explicitly, or check
/configfor a default, and say so out loud when neither exists. (§6) - "Add to what you're doing" as a real control —
delivery: "steer"on a deliberate trigger rather than only when a message happens to land mid-turn. The plumbing already exists. (§4a) - Idempotent sends — mint our own
msg_…. One field. (§4a) - Restart recovery — subscribe
?after=<seq>alongside the live stream. Verified working. (§4b) - A truthful live panel —
GET /api/session/active. (§4c) - Richer streaming for free — reasoning deltas, tool-input deltas, tool progress, retries. Already
arriving on the socket we already read, and dropped in a
default:case. (§5) - Explicit compaction and context instead of a long conversation quietly getting more expensive.
Then the two that are real features needing UI: permissions/questions (§4d) and revert (§4e).
9. What this does NOT get us
- It does not retire the legacy surface: delete, rename and every pre-2026-08-10 transcript stay there, with no deprecation date published.
- It does not touch the Claude harness — a different sidecar, a different protocol. Every gain above lands on one harness only, while the chat UI assumes the two behave alike.
- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta removes
/api/session/{id}/historyand/api/session/{id}/event— the two durable routes item 6 depends on — replacing them withGET /api/experimental/session/{id}/log?after=&follow=. Same idea, new path,experimental/prefix. So item 6 is worth doing and worth writing behind one function.
10. Open questions
- Is there a field that says which engine owns a session? Tonight's only discriminator is behavioural
(legacy returns
[]). If not, we need our own record — we already storesessionKey → ses_…inopencode/state.tsand could record the surface with it. - Which permission/question family does a 1.18.16 agent actually emit? Both are declared and both
appear in our
/doc, because the server wires the full manifest. Measure before building UI. - What is
/api/*'s auth story? The spec declares nosecuritySchemesyet every route declares a401. The v1 docs describe HTTP Basic viaOPENCODE_SERVER_PASSWORD; we run with none, on loopback. In the 2.0 beta this is formalised as basic auth read from~/.local/state/opencode/service.json. - Does
@opencode-ai/sdk/v2work against 1.18.16 exactly? It is generated from this exact server's OpenAPI output and versioned in lockstep, so it should — but nobody here has run it. - When did
/api/*first appear in the 1.x line? UNKNOWN; the changelog names no/api/additions. The event family underneath it landed in 1.15.0. And no dated removal plan exists for/session/*,permission.asked/repliedorquestion.*— only the undated internal intent quoted in §2. - Will the
v2-branch event renames reach the 1.x line, or only ship with OpenCode 2.0? No merge found, no statement either way. This decides whether the mapping table in §2 is a one-off or a permanent seam. - Is a durable
seqstable across a server restart or a session move? It is a database column, so it should be, but no durability guarantee is documented and we have not tested it. Item 6 in §8 depends on the answer.
Version state at the time of writing: 1.18.16 is the newest release (2026-08-10) and contains nothing API-facing. The active stream is the 2.0 beta, cutting releases continuously — the most recent was published hours before this file was written.
11. Sources
- Live:
GET /doconopencode serve1.18.16 (162 paths, 51 under/api/), plus the probes recorded above against the local sidecar's serve on port 49698. - Code:
src/servers/sidecar/opencode/*,src/servers/api/chat/opencode/*,opencode-sessions.ts,list-models.ts,send-opencode.ts. - Upstream, docs: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/v2/docs/permissions, opencode.ai/docs/server, opencode.ai/changelog.
- Upstream, source at tag
v1.18.16in github.com/anomalyco/opencode (formerlysst/opencode, which 301s):packages/protocol/src/api.tsandgroups/session.ts(the surface's own "experimental" self-description, theafterparameter),packages/schema/src/session-event.ts(DurableDefinitionsvsDefinitions— the delta exclusion),packages/schema/src/{permission,question}.tsand theirv1/counterparts,packages/schema/src/session-input.ts(admittedSeq),packages/schema/AGENTS.md(the V1/V2 naming intent),packages/core/src/event.ts(replay-then-live),packages/sdk/js/script/ build.tsandsrc/v2/client.ts. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229 (the renames). - npm:
@opencode-ai/sdk1.18.16,@opencode-ai/client@next. - Prior art in this repo:
docs/opencode-parity.md,-fork-decision.md,-serve-migration-plan.md,-serve-path.md,-testing-checklist.md,-phase0-review.md,-phase1-report.md,-phase1-review.md.