Two pipelines, one timeline: rendering agent activity across live and historical views
Every AI agent UI is secretly two UIs: one renders events live over SSE, the other rebuilds the same timeline from the database on reload. They have to match exactly, and five separate bugs, from parsing to null handling to ordering, proved they easily don't.
Build a UI for an AI agent and you’re actually building two UIs. One renders events as they stream in live; the other reconstructs those same events from the database when the user reloads the page. They have to produce identical output, and they fail in completely different ways; none of that was obvious to me until it broke. These are the bugs I found in both pipelines, from API response parsing to SSE event emission to database reconstruction, and the mental model that came out of keeping them in sync.
The setup
The agent runs as a background task on a Python FastAPI server. When a user submits a task, the agent enters a loop: call Claude, execute tool calls, feed results back, repeat. Progress streams to the browser via Server-Sent Events (SSE). When the run completes, the conversation, including tool outcomes, is persisted to PostgreSQL.
The user sees a timeline of activity: “Executing code…”, “Edited intro.md”, “Read config.json”. During a live run, these items arrive as SSE events. After a page reload, they’re reconstructed from the database. Same timeline, two completely different code paths.
Pipeline 1: the live stream
The live pipeline looks straightforward. A tool executes, the backend emits an SSE event, the frontend renders it. Three steps. Each one has a trap.
Trap 1: the API doesn’t return what you think it returns
I added a code execution tool: the agent writes Python code, Anthropic runs it in a sandbox, and the user sees the code and the output. It worked, except the code snippet was always empty.
The Anthropic code execution API (code_execution_20250825) is a server-side tool. Unlike
user-defined tools where the model returns a tool_use block with structured input,
server-side tools execute internally and return server_tool_use blocks in the response.
I expected these blocks to have a consistent shape. They don’t.
The API returns two different server_tool_use variants:
text_editor_code_execution: the model writes a Python file. The code lives ininput.file_text.bash_code_execution: the model runs a shell command. The command lives ininput.command.
My parser looked for input.code. It was always empty.
# What I wrote
code_text = tool_input.get("code", "")
# What I needed
code_text = (
tool_input.get("code", "")
or tool_input.get("file_text", "")
or tool_input.get("command", "")
)
There’s a second parsing issue layered on top. The SDK (v0.79.0) doesn’t have typed models
for code execution response blocks. A code_execution_tool_result block gets deserialized
by the SDK’s fallback logic, and its content field ends up as a raw Python dict rather
than a typed SDK object.
My original code used getattr(sub_block, "text", "") to extract fields from sub-blocks.
That works on SDK model objects but silently returns "" on dicts. I added a helper that
handles both:
def _get(obj: object, key: str) -> Any:
"""Get a field from an object that may be a dict or an SDK model."""
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
The lesson: when you’re working with API features newer than your SDK version, the typed models may not exist. The SDK still parses the response; it just gives you raw dicts wrapped in fallback types. Your parsing code needs to handle both shapes.
Trap 2: null is not undefined
With parsing fixed, I expected to see “Executing code…” in the activity stream while the tool ran. Instead: nothing. The tool appeared to hang for 20-30 seconds with no indication it had started.
Every tool in the system emits two SSE events: an in_progress event when execution
starts (showing a spinner), and a complete event when it finishes (showing the result).
The in_progress event includes an optional label field for display text:
await sse_manager.emit(run_id, "code_execution", {
"status": "in_progress",
"tool_call_id": block_id,
"tool": "run_code",
"label": None, # run_code has no label
})
On the frontend, the Zod schema for parsing this event had:
const toolInProgressBase = z.object({
status: z.enum(["in_progress", "error"]),
tool_call_id: z.string(),
tool: z.string(),
label: z.string().optional(), // accepts undefined, NOT null
});
Python serializes None as JSON null. Zod’s .optional() accepts undefined but
rejects null. Every in_progress event with a null label was silently failing Zod
validation: not just code execution, but every tool without a display label. The
safeParse wrapper returned null, the handler returned early, and no timeline item was
ever created.
The fix was one word: .nullish() instead of .optional().
label: z.string().nullish(), // accepts undefined AND null
This is insidious because it’s a silent failure. No error in the console, no exception thrown, no visual sign that something went wrong. The event just disappears.
And it didn’t affect all tools: edit_file and search_content had non-null labels, so
they worked fine. The bug only showed up on tools where the label happened to be null.
Trap 3: streaming order depends on abstraction level
This one predates the code execution work, but it’s part of the same theme. When I added extended thinking to the agent, “thinking” activity events appeared in the timeline after the text content, even though Claude thinks before it writes.
The cause: I was using the Anthropic SDK’s stream.text_stream convenience API, which
yields only text deltas. Thinking blocks were invisible during streaming; they only
appeared in the final message object, which I processed after all text had already been
emitted.
# BEFORE: text_stream hides thinking blocks
async with client.messages.stream(**kwargs) as stream:
async for text in stream.text_stream: # only text deltas
await emit(run_id, "content", {"delta": text})
return await stream.get_final_message()
# thinking events emitted here — too late
The fix was switching to the lower-level event stream, which yields events for all block types in their natural order:
# AFTER: full event stream preserves ordering
async for raw_event in stream:
if evt.type == "content_block_stop" and block_was_thinking:
await on_thinking(thinking_text) # fires BEFORE text
elif evt.type == "content_block_delta" and block_is_text:
await emit(run_id, "content", {"delta": text})
The lesson: streaming APIs often expose multiple abstraction levels. High-level
convenience methods like text_stream are great until your feature depends on ordering
across block types. When it does, you need the raw event stream.
Pipeline 2: database reconstruction
Everything above affects the live stream. Now the user reloads the page. The SSE connection is gone. The timeline needs to be rebuilt from the database.
How tool outcomes are stored
When a tool executes, the backend records a summary in the agent run’s metadata:
tool_outcome = {
"tool": "run_code",
"tool_call_id": "toolu_01CF58g...",
"status": "success",
"summary": "Executed python code",
"file_name": None,
"detail": {
"language": "python",
"file_count": 0,
"code": "import statistics\nnumbers = [12, 45, ...]"
}
}
The detail dict is tool-specific: each tool puts whatever it needs for reconstruction.
The frontend’s dbMessageTransformer reads these outcomes and converts them back into the
same SSEEvent types the live stream produces.
Trap 4: missing reconstruction branches
The transformer had branches for edit_file, create_file, read_file,
search_content, glob, delete_file, move_file, and create_folder. It did not have
a branch for run_code. Unrecognized tools fell through to a generic catch-all:
// The catch-all for unknown tools
const event: ActivityEvent = {
id,
step: "tool_call",
tool: outcome.tool,
};
return { type: "activity", id, event };
This rendered as “Running tool…”, a generic label that tells the user nothing. The live stream showed “Executed Python code” with a collapsible code snippet. The reload showed “Running tool…”. Same data, different rendering.
The fix was adding a run_code branch that reconstructs a typed code_execution event:
if (outcome.tool === "run_code") {
return {
type: "code_execution",
id,
status: "complete",
tool_call_id: tcId,
task: outcome.summary,
language: outcome.detail?.language ?? "python",
file_count: outcome.detail?.file_count ?? 0,
success: outcome.status === "success",
code: outcome.detail?.code ?? "",
} satisfies SSEEvent;
}
But this only works if the data is there. Initially, build_detail for run_code didn’t
include the code field; it stored the language and file count but not the actual code.
The “Show code” disclosure worked during live streams (the code came from the SSE event)
but was empty on reload (the code was never persisted). I had to go back and add it to the
backend’s build_detail.
Trap 5: ordering is inverted
Even with the right data and the right reconstruction branch, the timeline order was wrong on reload. During the live stream, the user sees:
▸ Executing code... ← in_progress event
◆ Executed Python code ← complete event (replaces in_progress)
▸ Show code
The standard deviation is... ← assistant text
On reload:
The standard deviation is... ← text rendered first
◆ Executed Python code ← tool outcome rendered second
▸ Show code
The transformer was appending tool outcomes after the assistant content. But tools execute before the agent writes its summary, which is often about what they did. Swapping the order fixed it:
// BEFORE: text first, tools after
items.push(assistantContent);
for (const outcome of outcomes) {
items.push(outcomeToTimelineItem(outcome));
}
// AFTER: tools first, then text
for (const outcome of outcomes) {
items.push(outcomeToTimelineItem(outcome));
}
items.push(assistantContent);
The checklist
Every new tool I add now goes through a three-layer verification:
1. Backend emission. Does build_sse_data include all the fields the frontend needs?
Does build_detail persist everything needed for reconstruction? Are field names and
types correct?
2. Frontend live rendering. Does the Zod schema match the wire format, including
nulls? Does the in_progress event render a meaningful label? Does the complete event
render correctly? Does the in_progress item get replaced by the complete item (not
duplicated)?
3. Frontend reload rendering. Does dbMessageTransformer have a branch for this tool?
Does it reconstruct the same SSEEvent shape as the live stream? Is the ordering correct
relative to assistant content?
The mental model
“Display this tool call” is not one problem. It’s three:
- Parse the API response correctly (SDK typed models, raw dicts, different field names).
- Stream it correctly (emit
in_progressbeforecomplete, handle nulls, preserve ordering across block types). - Reconstruct it correctly (persist all needed data, add a reconstruction branch, get the ordering right).
Each layer can silently succeed while producing wrong output. The live stream can look perfect while the reload is broken. The reload can render the right items in the wrong order. A null in a field you assumed was optional can silently drop an event with no error.
The hardest part isn’t any individual fix. It’s remembering that every tool call travels through two completely separate rendering pipelines, and both need to produce the same result.