Deep dive

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.

Open diagram in a new tab ↗

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.

Open diagram in a new tab ↗

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.

Found a mistake, or want to argue about an invariant? eng@shannon.dev or send a PR on the blog repo.