Description
I tried to implement some sse for the opencode serve to monitor and orchestrate some sessions but it doesn't seem to be working.
Lets face it, I'll be honest I do not know how the webevents work, and my claude couldn't get it to work, says the SSE stream is not running properly, and its instead set up for a long poll instead of persistent event stream
Maybe we set it up wrong, but he coudln't figure it out how to get it running - we did get a proper sse once, at version 1.17.13 - now its dead he doesn't how how to reproduce that anymore - I was super green with agentic coding back then and did not document anything
opencode serve: /api/event SSE stream closes mid-turn (not persistent as documented); most bus event types never arrive; durable event/wait layer unreachable from HTTP-created sessions
TL;DR
On opencode serve1.18.4 there is no event-stream endpoint that both stays open and delivers
session/turn events:
- v1
GET /event (stable, documented as persistent) stays open for the whole turn but delivers
onlyserver.connected + server.heartbeat — nosession.* / message.* / turn events. This
looks like a regression (community reports say SyncEvent publishes stopped reaching /event
since ~1.14.42). - v2
GET /api/event (the /api/* surface) does deliver session.updated / message.updated /
message.part.updated, but the server closes the connection ~0.1–1.4 s after each flush during an
active turn — not persistent, contrary to the docs. A normal single-connection client gets the first
burst and then goes silent. GET /global/event stays open but its frames carry no parseable type.
So following a turn forces the /api/event stream plus constant reconnects, which is lossy (no
Last-Event-ID replay). Additionally, most declared V2Event types never emit on /api/event, and the
durable/wait layer that would make this reliable is unreachable for HTTP-created sessions (details in
Issues 2–3).
All findings below are reproduced with plain curl (neutral client), so they are not specific to any SDK.
Plugins
no plugins - only self made that's gate the agent from reading .env files, reading stuff outside the wsl (windows is set on ro) and docker access to -v
OpenCode version
1.18.4
Steps to reproduce
Environment
| |
|---|
| opencode | 1.18.4 |
| launch | opencode serve --port <P> --hostname 127.0.0.1 (headless) |
| OS | Linux (WSL2) |
| client | curl 8.5.0 and a raw httpx reader — both reproduce it |
| models | free opencode/deepseek-v4-flash-free, paid opencode-go/deepseek-v4-flash (both behave identically) |
| auth | basic auth via OPENCODE_SERVER_PASSWORD (-u <user>:<pass>) |
Issue 1 (primary bug): no stream both stays open AND delivers turn events (v1 /event regressed to empty; v2 /api/event closes mid-turn)
The three event streams, measured over ONE live multi-step turn (single connection, no reconnect)
| Endpoint | Surface | Stays open for the turn? | Delivers session.*/message.* turn events? |
|---|
GET /event | v1 (stable) | YES — server.connected + server.heartbeat every ~10 s | NO — zero turn events |
GET /api/event | v2 (/api/*) | NO — server closes ~0.1–1.4 s after each flush | YES — session.updated, message.updated, message.part.updated |
GET /global/event | — | YES | frames carry no parseable type |
MEASURED 1.18.4 (free + paid deepseek-v4-flash), one ~12 s multi-step turn, one connection each:
/event [STILL OPEN] types = { server.connected@0.04s, server.heartbeat@10.05s } # nothing else
/api/event [CLOSED @1.42s] types = { server.connected@0.05s, session.updated@1.06s,
message.updated@1.41s, message.part.updated@1.41s }
/global/event [STILL OPEN] frames present but type = null (untyped firehose)
So the only stream that carries turn events is /api/event, and it closes mid-turn. The stable v1
/event stays open but no longer carries session.* / message.* events — consistent with community
reports that SyncEvent publishes stopped reaching /event since ~1.14.42. Net: there is currently no
way to follow a turn over a single persistent connection. The /api/event mid-turn-close detail follows.
Documented behavior (https://opencode.ai/docs/server): /event is a "Server-sent events stream.
First event is server.connected, then bus events" — i.e. a persistent SSE stream.
Actual behavior: the stream is persistent only while idle. As soon as a turn is producing events,
the server sends a burst and then cleanly closes the response (HTTP 200, clean EOF), repeatedly.
Reproduction
# 1. Start a serve (choose any password).
OPENCODE_SERVER_PASSWORD=pw opencode serve --port 4096 --hostname 127.0.0.1 &# 2. BASELINE — idle stream stays open for the full 20s (correct):
curl -N -s -u opencode:pw -o /dev/null \
-w 'idle: closed_after=%{time_total}s http=%{http_code} exitcode=%{exitcode}\n' \
--max-time 20 http://127.0.0.1:4096/api/event
# -> idle: closed_after=20.00s http=200 exitcode=28 (28 = curl hit --max-time; stayed OPEN)# 3. Create a session and start a multi-step turn (adjust body per GET /doc if needed):
SID=$(curl -s -u opencode:pw -X POST http://127.0.0.1:4096/session \ -H 'content-type: application/json' -d '{"title":"repro"}'| grep -o '"id":"[^"]*"'| head -1 | cut -d'"' -f4)
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/session/$SID/prompt_async?directory=$PWD" \
-H 'content-type: application/json' \
-d '{"parts":[{"type":"text","text":"Write notes.txt containing BANANA, read it back, list the directory, then reply one sentence."}], "model":{"providerID":"opencode","modelID":"deepseek-v4-flash-free"}}'# 4. WHILE the turn runs, subscribe — the stream closes ~0.3s in, instead of staying open:
curl -N -s -u opencode:pw -o /dev/null \
-w 'active: closed_after=%{time_total}s http=%{http_code} size=%{size_download} exitcode=%{exitcode}\n' \
--max-time 30 http://127.0.0.1:4096/api/event
# -> active: closed_after=0.33s http=200 size=2904 exitcode=0 (0 = server CLOSED before max-time)Observed vs expected
| Idle stream | Active-turn stream |
|---|
| Expected | stays open | stays open, streams all bus events |
| Actual | stays open (curl hit --max-time, exit 28) | server closes cleanly at ~0.1–0.4 s after flushing a burst (exit 0, HTTP 200) |
A reconnecting reader (new connection on every close) does eventually capture the turn's events, but only
via dozens of reconnects in a single 10 s turn (measured: STREAM-ENDED after {1,2,6,50} events,
each reconnect replays server.connected). A standard single-connection SSE consumer captures only the
first ~0.3 s and then receives nothing for the remainder of the turn.
Impact
Any client that opens /api/event once and reads it as a normal SSE stream silently stops receiving
events mid-turn. The server keeps working; the client never catches up. This is invisible (no error, a
clean 200 close), so it presents as a stalled UI / lost progress.
Issue 2 (related): only 4 of 88 declared V2Event types are ever observed on /api/event
With a reconnecting reader (so Issue 1 is not the cause), across a healthy multi-step turn, a permission
ask, and a question, the only event types seen on the session-filtered /api/event are:
session.created, session.updated, message.updated, message.part.updated
Never observed, though all are declared in V2Event (GET /doc):
session.idle (turn completed — no idle event in 25 s of observation)session.status (as an event)- the entire
session.next.* family (step.started|ended|failed, tool.called|success|failed,
text.*, reasoning.*, prompt.admitted, model.switched, agent.switched, retried, …) permission.asked / permission.v2.asked (a permission ask was pending — confirmed via
GET /permission at ~3 s — but no event fired)question.asked / question.v2.asked (a question was pending — confirmed via GET /question at
~2.5 s — but no event fired)
Question for maintainers
Are these event types supposed to publish on the session-filtered /api/event stream? Consumers currently
have no way to learn about turn completion, rate-limit/backoff, permission asks, or
questions from the event bus, and must poll GET /session/status, GET /permission, and
GET /question instead. (This may be related to a SyncEvent-publish path not reaching the HTTP SSE
stream.)
Issue 3 (question): the durable/resumable event + wait layer is unreachable for HTTP-created sessions
GET /doc exposes a durable layer that would solve Issue 1's gap-loss and Issue 2's missing signals:
GET /api/session/{id}/event?after=<seq> — "Replay durable events after an aggregate sequence, then
continue with new durable events" (resumable — the reconnect-safe stream)POST /api/session/{id}/wait — "Wait for a session agent loop to become idle" (completion without polling)POST /sync/{history,replay,start,steal} — the durable SyncEvent store
But for a session created via POST /session + POST /session/{id}/prompt_async, all of it is inert:
curl -s -u opencode:pw "http://127.0.0.1:4096/api/session/active?directory=$PWD"# -> {"data":{}} # process owns NO session drain
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/sync/start?directory=$PWD"# -> true # loop starts, but confers no ownership
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/sync/steal?directory=$PWD" \
-H 'content-type: application/json' -d "{\"sessionID\":\"$SID\"}"# -> 400 {"_tag":"BadRequest"}
curl -s -u opencode:pw -X POST "http://127.0.0.1:4096/api/session/$SID/wait?directory=$PWD"# -> 503 {"_tag":"ServiceUnavailableError","message":"Session wait is not available yet"}
curl -N -s -u opencode:pw "http://127.0.0.1:4096/api/session/$SID/event?after=0&directory=$PWD"# -> (no events; times out)Question for maintainers
How does a plain HTTP client (not the TUI / opencode attach) take ownership of a session it created
via the API, so that durable event replay (?after=<seq>), POST /api/session/{id}/wait, and the
/sync/* history become available? Is there a supported handshake, or are these endpoints intended only
for attach-owned foreground sessions? If the latter, is there a recommended reliable-events path for
headless serve integrations?
Why this matters
Headless integrations against opencode serve need a reliable way to observe a turn — progress,
completion, permission/question prompts. Today the only dependable signals are the polls
(GET /session/status, GET /session/{id}/message, GET /permission, GET /question); the event bus
(/api/event) drops out mid-turn and omits most event types, and the durable layer that would fix this is
unreachable from the HTTP API. A fix to any one of the three (persistent /api/event, full event
publishing, or a documented ownership handshake for the durable layer) would make event-driven integration
viable.
What we would consider a concrete answer
- Which endpoint is the intended, stable way for a headless HTTP client to follow a turn's events?
Did v1 GET /event stop delivering session.* / message.* events by design or by regression
(community reports point to ~1.14.42)? If /api/event is the intended one, is it stable or beta? - Is the
/api/event mid-turn close (Issue 1) a bug, and is a persistent stream (or documented
auto-reconnect with Last-Event-ID replay) the intended contract? - Should
session.idle / session.next.* / permission.asked / question.asked (Issue 2) publish on
/api/event? If not, what is the intended discovery mechanism? - Is there a supported way for an HTTP-created session to reach the durable/
wait layer (Issue 3)?
Reproduction scripts and raw output logs (Python httpx + curl) are available on request.
Screenshot and/or share link
No response
Operating System
No response
Terminal
No response
Description
I tried to implement some sse for the opencode serve to monitor and orchestrate some sessions but it doesn't seem to be working.
Lets face it, I'll be honest I do not know how the webevents work, and my claude couldn't get it to work, says the SSE stream is not running properly, and its instead set up for a long poll instead of persistent event stream
Maybe we set it up wrong, but he coudln't figure it out how to get it running - we did get a proper sse once, at version 1.17.13 - now its dead he doesn't how how to reproduce that anymore - I was super green with agentic coding back then and did not document anything
opencode serve:/api/eventSSE stream closes mid-turn (not persistent as documented); most bus event types never arrive; durable event/waitlayer unreachable from HTTP-created sessionsTL;DR
On
opencode serve1.18.4 there is no event-stream endpoint that both stays open and deliverssession/turn events:
GET /event(stable, documented as persistent) stays open for the whole turn but deliversonly
server.connected+server.heartbeat— nosession.*/message.*/ turn events. Thislooks like a regression (community reports say
SyncEventpublishes stopped reaching/eventsince ~1.14.42).
GET /api/event(the/api/*surface) does deliversession.updated/message.updated/message.part.updated, but the server closes the connection ~0.1–1.4 s after each flush during anactive turn — not persistent, contrary to the docs. A normal single-connection client gets the first
burst and then goes silent.
GET /global/eventstays open but its frames carry no parseabletype.So following a turn forces the
/api/eventstream plus constant reconnects, which is lossy (noLast-Event-IDreplay). Additionally, most declaredV2Eventtypes never emit on/api/event, and thedurable/
waitlayer that would make this reliable is unreachable for HTTP-created sessions (details inIssues 2–3).
All findings below are reproduced with plain
curl(neutral client), so they are not specific to any SDK.Plugins
no plugins - only self made that's gate the agent from reading .env files, reading stuff outside the wsl (windows is set on ro) and docker access to -v
OpenCode version
1.18.4
Steps to reproduce
Environment
opencode serve --port <P> --hostname 127.0.0.1(headless)curl 8.5.0and a rawhttpxreader — both reproduce itopencode/deepseek-v4-flash-free, paidopencode-go/deepseek-v4-flash(both behave identically)OPENCODE_SERVER_PASSWORD(-u <user>:<pass>)Issue 1 (primary bug): no stream both stays open AND delivers turn events (v1
/eventregressed to empty; v2/api/eventcloses mid-turn)The three event streams, measured over ONE live multi-step turn (single connection, no reconnect)
session.*/message.*turn events?GET /eventserver.connected+server.heartbeatevery ~10 sGET /api/event/api/*)session.updated,message.updated,message.part.updatedGET /global/eventtypeMEASURED 1.18.4 (free + paid
deepseek-v4-flash), one ~12 s multi-step turn, one connection each:So the only stream that carries turn events is
/api/event, and it closes mid-turn. The stable v1/eventstays open but no longer carriessession.*/message.*events — consistent with communityreports that
SyncEventpublishes stopped reaching/eventsince ~1.14.42. Net: there is currently noway to follow a turn over a single persistent connection. The
/api/eventmid-turn-close detail follows.Documented behavior (
https://opencode.ai/docs/server):/eventis a "Server-sent events stream.First event is
server.connected, then bus events" — i.e. a persistent SSE stream.Actual behavior: the stream is persistent only while idle. As soon as a turn is producing events,
the server sends a burst and then cleanly closes the response (HTTP 200, clean EOF), repeatedly.
Reproduction
Observed vs expected
--max-time, exit 28)A reconnecting reader (new connection on every close) does eventually capture the turn's events, but only
via dozens of reconnects in a single 10 s turn (measured:
STREAM-ENDED after {1,2,6,50} events,each reconnect replays
server.connected). A standard single-connection SSE consumer captures only thefirst ~0.3 s and then receives nothing for the remainder of the turn.
Impact
Any client that opens
/api/eventonce and reads it as a normal SSE stream silently stops receivingevents mid-turn. The server keeps working; the client never catches up. This is invisible (no error, a
clean 200 close), so it presents as a stalled UI / lost progress.
Issue 2 (related): only 4 of 88 declared
V2Eventtypes are ever observed on/api/eventWith a reconnecting reader (so Issue 1 is not the cause), across a healthy multi-step turn, a permission
ask, and a question, the only event types seen on the session-filtered
/api/eventare:Never observed, though all are declared in
V2Event(GET /doc):session.idle(turn completed — no idle event in 25 s of observation)session.status(as an event)session.next.*family (step.started|ended|failed,tool.called|success|failed,text.*,reasoning.*,prompt.admitted,model.switched,agent.switched,retried, …)permission.asked/permission.v2.asked(a permission ask was pending — confirmed viaGET /permissionat ~3 s — but no event fired)question.asked/question.v2.asked(a question was pending — confirmed viaGET /questionat~2.5 s — but no event fired)
Question for maintainers
Are these event types supposed to publish on the session-filtered
/api/eventstream? Consumers currentlyhave no way to learn about turn completion, rate-limit/backoff, permission asks, or
questions from the event bus, and must poll
GET /session/status,GET /permission, andGET /questioninstead. (This may be related to aSyncEvent-publish path not reaching the HTTP SSEstream.)
Issue 3 (question): the durable/resumable event +
waitlayer is unreachable for HTTP-created sessionsGET /docexposes a durable layer that would solve Issue 1's gap-loss and Issue 2's missing signals:GET /api/session/{id}/event?after=<seq>— "Replay durable events after an aggregate sequence, thencontinue with new durable events" (resumable — the reconnect-safe stream)
POST /api/session/{id}/wait— "Wait for a session agent loop to become idle" (completion without polling)POST /sync/{history,replay,start,steal}— the durable SyncEvent storeBut for a session created via
POST /session+POST /session/{id}/prompt_async, all of it is inert:Question for maintainers
How does a plain HTTP client (not the TUI /
opencode attach) take ownership of a session it createdvia the API, so that durable event replay (
?after=<seq>),POST /api/session/{id}/wait, and the/sync/*history become available? Is there a supported handshake, or are these endpoints intended onlyfor
attach-owned foreground sessions? If the latter, is there a recommended reliable-events path forheadless
serveintegrations?Why this matters
Headless integrations against
opencode serveneed a reliable way to observe a turn — progress,completion, permission/question prompts. Today the only dependable signals are the polls
(
GET /session/status,GET /session/{id}/message,GET /permission,GET /question); the event bus(
/api/event) drops out mid-turn and omits most event types, and the durable layer that would fix this isunreachable from the HTTP API. A fix to any one of the three (persistent
/api/event, full eventpublishing, or a documented ownership handshake for the durable layer) would make event-driven integration
viable.
What we would consider a concrete answer
Did v1
GET /eventstop deliveringsession.*/message.*events by design or by regression(community reports point to ~1.14.42)? If
/api/eventis the intended one, is it stable or beta?/api/eventmid-turn close (Issue 1) a bug, and is a persistent stream (or documentedauto-reconnect with
Last-Event-IDreplay) the intended contract?session.idle/session.next.*/permission.asked/question.asked(Issue 2) publish on/api/event? If not, what is the intended discovery mechanism?waitlayer (Issue 3)?Reproduction scripts and raw output logs (Python
httpx+curl) are available on request.Screenshot and/or share link
No response
Operating System
No response
Terminal
No response