Translating an SDK loop into one stream
A Shannon agent run looks like one continuous stream in the browser. Underneath it's a loop of separate Anthropic API calls with tool execution wedged between them. The fix is a server-side translation layer that hides every seam behind a small, fixed vocabulary of UI events.
Watch a Shannon agent run from the browser and you see one stream: a thinking bubble
filling in, tool spinners flipping to success, edits landing in the editor, then a terminal
complete. It looks like one long response. It never is. Underneath, every run is a loop
of separate Anthropic API calls with my own tool execution wedged between them, and none of
those seams are allowed to reach the screen. The decision is where to hide them.
I chose to build a translation layer: the server consumes Anthropic’s SDK stream, holds the run state, and emits a small fixed vocabulary of UI-shaped rows over Server-Sent Events (SSE, the browser’s one-way streaming channel). The stream the browser reads is not a mirror of the vendor’s stream. It is a protocol designed around what the screen should do next, and that framing is what the rest of the decision hangs on.
One run on screen is many API calls underneath
An agent run starts when the browser POSTs to /v1/agent-runs and opens an EventSource
(the browser API for consuming SSE) on /v1/agent-runs/:id/stream. From that point the
frontend expects a single ordered stream of semantic rows until the run ends: thinking,
tool-call status, editor decoration events, then exactly one of complete, error, or
cancelled.
No single API call produces that. A run is a loop: assemble context, open an Anthropic
messages.stream, receive content_block_* events, run whatever client tools the model
called, build a new messages array, open another stream, and repeat until the model stops
calling tools. Server-side tools like web_search and code_execution are executed by
Anthropic mid-stream and come back as result blocks inside the same call.
Anthropic’s wire vocabulary is content blocks: thinking, text, tool_use,
server_tool_use, the tool-result blocks, redacted_thinking. That vocabulary has no
concept of an agent run. It doesn’t know which tool is a domain mutation versus a read. It
has no lifecycle events like starting, resuming, or cancelled, no rollback semantics
on failure, and no answer for a subscriber who connects late: the POST returns before the
EventSource opens, so the first events fire with nobody listening.
That mismatch is the whole problem. Somebody has to own the run abstraction, and the only question is whether it lives on my server or in every client.
Forwarding the vendor’s stream pushes the loop into the browser
The tempting option is a thin forwarder: proxy every content_block_* event to the browser
verbatim and let the frontend assemble blocks, match tool_use to results, and render.
About 300 lines of translation code disappear, and there is no server-side buffering and no
wire protocol of my own to design and maintain.
I rejected it because every cost lands on the frontend permanently. The browser has to
learn Anthropic’s block model, so two vocabularies ship with the product and drift as
Anthropic evolves theirs. Every messages.stream call ends with a message_stop, so the
browser sees a sequence of “API call ended” signals and has to infer “agent run ended” from
out-of-band state.
Client-executed tools are worse: the browser watches a tool_use block
close, then silence while my server runs the tool, then results arriving in a later turn it
has to stitch back together. My own domain events (edit, create, lifecycle rows,
rollback metadata) have no home at all. And late-joiner replay becomes impossible, because
the browser can’t fabricate SDK events it never received.
The verdict I wrote in the decision record: a thin forwarder collapses the agent-run abstraction into per-API-call chatter and leaks the vendor’s wire format into the frontend.
Eight event types the browser can trust
The chosen design inverts that. The server consumes the SDK events, maintains per-run
orchestration state, and emits eight semantic SSE event types: activity, content,
thinking_delta, edit, create, complete, error, cancelled. Loop iterations are
invisible; the browser sees one stream from run start to run end.
Here is the translation for a single edit_file call inside one turn. Anthropic streams this:
content_block_start index=0 type=thinking
content_block_delta index=0 thinking_delta="I should edit..."
content_block_stop index=0
content_block_start index=1 type=tool_use id=toolu_abc name=edit_file
content_block_delta index=1 input_json_delta="{\"path\":\"README.md\"..."
content_block_stop index=1
message_stop
The browser receives this:
thinking_delta {index:0, delta:"I should edit...", done:false}
thinking_delta {index:0, delta:"", done:true}
activity {step:"tool_call", tool:"edit_file", tool_call_id:"toolu_abc", status:"in_progress", file_name:"README.md"}
activity {step:"tool_call", tool:"edit_file", tool_call_id:"toolu_abc", status:"success", summary:"Updated 2 lines"}
edit {file_id, change_id, ...}
Look at what’s missing. Anthropic’s content_block_stop for the tool_use block is never
forwarded, because it fires mid-stream, before the tool has actually run. The two
tool_call activity rows are emitted by my dispatcher after Anthropic’s turn ends, as my
code walks the assistant message’s tool_use blocks and executes each one. The spinner the
user watches tracks my execution, not Anthropic’s block boundary.
Anthropic-executed tools get the opposite treatment. For web_search, in_progress is
emitted the moment the server_tool_use block closes, because from that moment Anthropic
is running the search remotely, and success fires when the matching
web_search_tool_result block closes later in the same stream. Same vocabulary, different
trigger points, one rule underneath: status rows track whoever is actually executing.
The layer also owns the plumbing this vocabulary implies. High-frequency deltas
(thinking_delta and text content) are batched into ~50ms flush windows so the browser
event loop stays healthy without the frontend knowing. Every emitted event lands in a
bounded replay buffer, so a late-joining EventSource gets what it missed. And on an
exception mid-run, the server rolls back the tracked changes it had applied and emits a single error event, not
a half-finished block stream.
The layer exists for tool execution and lifecycle, not multi-turn
When I wrote the decision record I separated the arguments that sound strong from the ones
that are. “This hides Anthropic’s API so I could swap providers” is overstated: the
frontend is decoupled from Anthropic’s wire format, but the persisted tool-outcome shape,
the tool_use/tool_result pairing, and the thinking-block model are all Anthropic-shaped,
so a real provider swap would still mean frontend work. Even multi-turn stitching (the
justification I’d reach for first) only partly holds. A thin forwarder plus a
run_boundary marker could push stitching to the frontend.
What can’t be done any other way is narrower: client-side tool execution, which has no
representation in the SDK stream at all, and run-level lifecycle (starting, resuming,
cancelled, rollback), which is my domain, not Anthropic’s. Those two are why the layer
exists.
The costs are real, and I pay them. Roughly 500 lines of translation code across six modules, against the ~300 a forwarder would have saved. Two frontend rendering pipelines, live SSE and DB reload, that must produce identical UI from different inputs. Every new tool needs three coordinated changes (backend in-progress and complete events, a frontend Zod schema that accepts the wire shape including nulls, a DB transformer branch for reload), and missing any one leaves the tool invisible in one pipeline.
And the SSE manager is a
process-local singleton. With two FastAPI workers, the load balancer can route the POST to
one and the EventSource to another, so the stream goes dark until a Postgres
LISTEN/NOTIFY layer (Postgres’s built-in pub/sub) replaces the in-memory buffer.
Six bugs later, the invariants are written down
Every streaming bug I’ve hit lives in the gap between what the SDK tells me and what the
frontend needs to render. Thinking events arrived after all the text because the original
turn loop only read stream.text_stream and emitted thinking post-hoc. Thinking flickered
instead of accumulating because the wire contract never said “append, don’t replace.” A
perf change emitted complete before write-backs finished and broke the implicit contract
that the EventSource closes on complete.
The replay buffer is a 500-event deque (a
bounded queue), and one long reasoning turn emitted enough thinking_delta events to evict
the terminal complete before the late joiner connected, so I excluded thinking_delta
from replay entirely. A cleanup race freed the replay buffer before a
late subscriber could read it; the fix defers cleanup by 60 seconds.
And the live and reload pipelines ordered tool outcomes differently.
That’s why the decision record doesn’t end with a verdict. It ends with the invariants the
next transport has to preserve: events stay ordered per run, thinking_delta never evicts
terminal events from replay, a complete/error/cancelled row is never droppable, the
60-second post-terminal replay window survives, and the client-tool lifecycle stays on the
server.
Writing them down is what made the design portable, because when the in-memory buffer becomes a Postgres-backed one for multi-worker deployment, the transport changes and the promises don’t. The SSE stream is UI-shaped because every event answers “what should the screen do next.” The invariants are what keep that answer true when everything underneath it moves, and losing any one of them brings the six bugs back with new flavors.