...
Back

Rendering an Agent Is Not Rendering a Chat

A coding agent emits tool calls, diffs, terminal output and task lists — not messages. Building the UI taught us that the interesting design problem is what to show while it is still deciding.

Rendering an Agent Is Not Rendering a Chat

Rendering an Agent Is Not Rendering a Chat 💬

We built the interface for talking to a coding agent from a phone. The obvious starting point was a chat UI, and the obvious starting point was wrong in a specific way: a chat is a sequence of messages, and an agent does not emit messages. It emits a stream of events — reasoning, a tool invocation, the tool's output, a file diff, a task list, a revision of that task list — and only occasionally something that is actually addressed to you.

Flattening that stream into chat bubbles produces something technically correct and unusable.


Events are not messages, in three ways that matter

They have types with different display needs. A shell command with its output wants monospace, a scroll region, and an exit status. A file edit wants a diff view with syntax colour. A task list wants checkboxes that update in place. Rendering all of it as prose throws away every affordance that makes the content readable.

They mutate. A task list is revised as work proceeds. A tool call is pending, then running, then finished with a result. In a chat, a message is immutable once sent — so an agent stream rendered as chat either appends a new copy of the list each time it changes, burying the current state under its own history, or updates silently and loses the sense of progression.

Most of them are not for you. The ratio of internal steps to things a human needs to read is high. Showing everything at full weight makes the actual answer impossible to find; hiding everything makes the agent a black box that appears frozen. The correct answer is neither — it is summarize by default, expand on demand, with the summary carrying enough information to decide whether to expand.

What worked was giving each event type its own compact card: a terminal card, a diff card, a task-list card that updates in place. Each collapsed to one line with the essential fact — the command, the file and line count, the number of tasks remaining — and each expandable. The conversation then reads as a work log rather than a transcript, which is what it is.


The hard part is latency, not layout

A coding agent thinks for a long time between visible outputs. On a phone, ten seconds of nothing is indistinguishable from a crash, and the user's recourse is to background the app — which is the worst possible moment for a connection to drop.

Three things that mattered more than any visual decision:

Stream something immediately. Even an acknowledgement that the request was received, with a visible state change. The gap between "sent" and "the model started producing" is where abandonment happens.

Show the current activity, not a spinner. "Reading src/auth/session.ts" is infinitely better than an indeterminate progress indicator, because it is evidence of progress rather than an assertion of it. Agents already emit this information; the work is surfacing it rather than buffering it until something "finishes."

Make interruption first-class and instant. Users need to stop an agent heading the wrong direction, and the stop must feel immediate even though the backend takes a moment to unwind. Acknowledge locally, then reconcile.


The thing we got wrong first

We initially rendered the agent's text output as plain text, and it was full of markdown syntax the user had to read around — literal asterisks, hash marks, code fences. Rendering markdown fixed the symptom.

The deeper mistake was assuming the output was prose at all. Once you accept that the stream is structured events rather than a conversation, a series of decisions get easier: what to persist, what to show by default, what a "message" even is in the history, and what it means to scroll back. We rebuilt around events and stopped fighting the chat metaphor, and most of the remaining problems dissolved.


The generalizable claim

When a new interaction model arrives, the first interface for it is always borrowed from the closest familiar thing. That is the right way to start — but the borrowed metaphor has assumptions baked in, and the moment your content stops satisfying them, the metaphor starts costing more than it provides.

Chat assumes messages are discrete, immutable, addressed to someone, and roughly equal in importance. An agent stream violates all four. Recognizing which assumption is being violated is what points at the redesign — and for us that redesign was not a nicer chat, it was admitting we were not building a chat.