Deep dive

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.

Open diagram in a new tab ↗

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 in input.file_text.
  • bash_code_execution: the model runs a shell command. The command lives in input.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:

  1. Parse the API response correctly (SDK typed models, raw dicts, different field names).
  2. Stream it correctly (emit in_progress before complete, handle nulls, preserve ordering across block types).
  3. Reconstruct it correctly (persist all needed data, add a reconstruction branch, get the ordering right).
Open diagram in a new tab ↗

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.

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