Audience: SDK users (TypeScript, Go, anything that speaks HTTP)
who need live token-by-token output from an agent. If you only
need a single full reply, the blocking JSON variants (no
Accept: text/event-stream) are simpler — start with
Calling Agents.openapi.beeos.ai exposes. All three endpoints follow the same
Content-Type: text/event-stream framing but use slightly different
event-naming conventions; this guide lays them side-by-side so you
can pick the right one and write a correct reconnect loop.
1. The three SSE surfaces
All three require
Authorization: Bearer <JWT or oag_...>. oag_
keys are user-scoped — any key whose owner owns the underlying task /
conversation can invoke or stream. See
Authentication & API Keys for the
owner-ACL authorization model (per-route scopes were removed in v1.1.0).
2. The invoke SSE flow (one-shot)
event: name — clients dispatch on the JSON type):
agent_reply_error, you get the
same done frame with extra fields:
agent_offline, service_timeout,
agent_rejected, etc.) occurs you get an error frame first,
then done (the stream’s “single shutdown signal” — both frames
carry the same code so callers that only key on done still
dispatch correctly, audit-v4 P1-1):
No
event: lines on this endpoint. Standard
EventSource-style clients receive all frames as the default
"message" event; dispatch on data.type.Required client logic (invoke)
- Append
delta.textto a running buffer as deltas arrive. - Treat the first
doneframe as terminal — close the connection. - If
done.is_error === true, surfacedone.code+done.errorto the caller. Don’t retry blindly onagent_reply_error(in-band) but DO retry onservice_timeout/agent_offline(transport).
done.text field is the full assembled reply — chunks are a
UX nicety, not the truth. SDKs that don’t need streaming UX can
ignore deltas entirely and just consume done.
3. The task / conversation SSE flow (named events)
Bothtasks/{id}/events and conversations/{id}/events use the
same framing:
SSEStreamMessage):
Envelope v3 (ADR-0022 + ADR-0023, GA in v1.1): Agent replies are
“one row, mutating in place”. The same
message_id appears across N
streaming frames (each a cumulative snapshot of body / parts)
followed by exactly one state="completed" (or failed / cancelled)
terminal frame. Legacy per-token agent_reply_delta rows are no
longer emitted by beeos-claw and will not appear on new channels,
but historical rows remain readable via ?include_deltas=true on the
GET /messages polling path.Terminal event: end
Emitted exactly once before the connection closes. The reason
distinguishes the cause:
The
end frame is the only signal — there are no in-band
keepalive comments and no Connection: close semantics. If you see
end, DON’T reconnect with since=lastOffset — the channel is gone
or terminal; reconnecting won’t replay anything new.
Required client logic (task / conversation)
4. The since cursor (drop / reconnect compensation)
since is an integer offset on the channel’s monotonic log.
Both /tasks/{id}/events and /conversations/{id}/events accept it
as a query parameter. The semantics:
since=0(or omitted) — replay the full history of the channel, then keep streaming new events. Useful for late attachers who want the entire transcript.since=N(N > 0) — replay every event at offset> N, then keep streaming. Use this on reconnect: pass the last observedframe.offsetso you don’t see duplicates or miss any frames.
The same cursor doubles as the pagination key on the
non-streaming
GET /messages endpoint:
?since=<lastOffset>&limit=200 returns up to 200 frames at
offset > lastOffset. Mixing streaming + polling is fine.OpenAPI v1.1 (ADR-0022 + ADR-0023): GET /messages default-filters
out ephemeral streaming chunks (agent_reply_delta,
agent_thought_chunk, agent_message_chunk). With v3 envelopes
(ADR-0023) live agent_reply rows carry the full cumulative reply
in body — you no longer need include_deltas=true to reconstruct
the text, and new beeos-claw channels won’t have any
agent_reply_delta rows at all. The flag remains supported for
reading historical pre-v3 channels. latest_offset still
reflects the full server-side max so since=<latest_offset>
resumes from the right place either way.Edge cases
-
Connection dropped before any frame.
lastOffsetis still0— reconnect withsince=0, which replays from the beginning. On invoke SSE (no offsets) you have to redrive the invoke altogether (the underlying chat_message wasn’t durable because you never got amessage_idto retry idempotently against). -
Last frame was
agent_message_chunkthen connection dropped. Reconnect withsince=<that chunk's offset>. You will get every remaining chunk PLUS the finalagent_reply. There is no risk of duplicating already-rendered text — each chunk has a distinctmessage_id. - Same client opens two SSE connections to the same task. Both get the full live stream. Message Service is fan-out — there’s no “you already have a subscription” semantic on the gateway.
-
backfill_truncatedframe on reconnect (since OpenAPI v1.1 / ADR-0022). When the channel has been idle long enough that the ephemeral stream has aged out before yourLast-Event-ID, the server emits a singlebackfill_truncatedevent before the normalreplay_complete. Shape:Recovery options, in order of decreasing fidelity:- Replay surviving durable rows — fetch
GET /messages?since=<since>(durable rows only); you’ll miss the per-token chunks but recover the final reply / non-chunk state. The server only emits this frame for ephemeral types, sochat_message/agent_reply/agent.input_requiredetc. are still in the durable log. - Fast-forward — resume with
since=replay_complete.latest_offsetand accept that the intermediate token chunks are gone. This is what the official SDK does by default since the chunks are rendering UX, not data of record.
backfill_truncatedSHOULD treat the frame as a no-op message — it’s purely informational and doesn’t change the rest of the protocol. - Replay surviving durable rows — fetch
5. Keepalive
Neither SSE handler emits explicit keepalive comments today. Strategies:Browser / EventSource
EventSource automatically reconnects on socket close. Use the
since=<lastOffset> query-string parameter to compensate for the
gap. Note: EventSource cannot set headers, so for oag_ keys you
either:
- Hand-roll the request via
fetch+ manual SSE parsing (recommended for production — gives you precise control over headers, retries, and reconnect timing); or - Embed the token in the URL (
?access_token=...) — avoid this: the URL ends up in CDN logs / proxy access logs / browser history.
Node.js (server-to-server)
Usefetch + response.body as an async iterable, or the
eventsource package
(which supports custom headers). Implement an exponential backoff on
onerror and pass the last observed offset:
Go
github.com/r3labs/sse/v2 handles reconnect + offset compensation
out of the box. Pass since=<offset> and let it resume on its own.
Server-side considerations
If you run your client behind a corporate proxy / NLB / CDN, the idle connection cap might be lower than your turn cadence. Tested limits on common infra:
Your client MUST handle reconnect regardless. The
since= cursor
exists precisely so reconnects are loss-free.
6. Error frames vs done (invoke) / end (task & conversation)
Different endpoints have different shutdown grammars; misreading them
is the most common SDK bug we see.
This is why the matching rule is:
- invoke: branch on
data.type === "done"to decide “stop”. - task: branch on
event === "end"(the named SSE event) to decide “stop”; everything before is amessage. - conversation: same as task, but
endfires only on explicit delete / upstream close, not on any per-turn terminal.
7. Worked example — robust task watcher (TypeScript)
This snippet shows the full reconnect-on-error + offset-resume loop for the task SSE surface. It’s intentionally written without the SDK so the wire mechanics are visible.event === "end"
break with a “stop on explicit unsubscribe” flag, since conversations
don’t terminate on per-turn reply.
8. Frame replay & idempotency
Every frame’smessage_id is unique per channel. Replays caused by
reconnects MAY redeliver frames you’ve already seen if your
lastOffset tracking lost an update — UI code SHOULD dedupe on
message_id.
Internally Message Service uses an offset-only ordering invariant
(monotonic per channel), so seeing two frames with the same
message_id but different offset indicates a bug worth reporting.
9. Common mistakes
- Polling
lastOffset = 0on reconnect. Re-replays the entire channel history, costs you bandwidth, and floods your UI with duplicate frames you’ve already rendered. Always track the latest observedframe.offsetand pass it assince=. - Treating
event: endas a recoverable error. It’s not — it means there will be NO more frames. Reconnecting just yields anotherend(or 404 if the channel was evicted). Close the connection and move on. - Branching on
eventname for invoke SSE. Invoke SSE emits unnamed frames; clients keyed onevent === "message"will treat every frame as ignorable. UseJSON.parse(data).type === "done"for invoke; useevent === "end"for task / conversation. - Mixing
Accept: text/event-streamwithPOST /tasks/.... The task SSE surface is the separateGET /eventsendpoint, not the create call.POST /tasksalways returns a JSON 200 with the task ID; you then open SSE onGET /tasks/{id}/events. - Embedding the token in the URL. Set
Authorization:via a custom-header SSE client (orfetch+ manual parsing). URLs end up in proxy / CDN access logs.
10. See also
- Calling Agents — non-streaming variants
- Conversations vs Tasks — when to pick which SSE surface
- Error Reference — every
codethat can appear inerror/done.codeframes - OpenAPI contract:
backend/openapi/beeos-platform-v1.yaml(searchtext/event-streamfor the schemas) - SSE DTO definitions:
backend/services/openapi-gateway/internal/dto/sse.go