How to Stream Harness Output to a UI
Users bail on silent spinners fast. An agent harness streaming ui streams structured events — tool calls, results, tokens — so a long run shows its work as it happens.
@app.post("/run")
def run_endpoint(req):
result = agent.run(req.prompt) # blocks for 5-90 seconds
return {"answer": result.text} # user sees nothing until this returnsUsers abandon a task at a rate that climbs sharply after about ten seconds of staring at a spinner with no feedback — and an agent run that calls three tools and thinks in between routinely takes far longer than that. That single fact is why an agent harness streaming ui isn't a nice-to-have polish layer; it's often the difference between a tool people trust and one they close before it finishes. This teardown starts with the way most harnesses first wire up their UI — wait for everything, then dump it — shows exactly why that fails users, and rebuilds it to stream the run as it happens.
The mistake underneath the bad version is treating an agent run like a normal API call: send request, wait, get response. But an agent run is a process, sometimes a long one, and a process that shows nothing until it's done feels broken even when it's working perfectly.
Before: The Weak Prompt
Here's the pattern almost everyone ships first — run the whole agent, return the final answer.
[object Object],
,[object Object], ,[object Object],(,[object Object],):
result = agent.run(req.prompt) ,[object Object],
,[object Object], {,[object Object],: result.text} ,[object Object],What this does: Runs the entire agent loop server-side and returns only when it's completely finished, then hands the UI a single final blob. For a one-step run this is fine. For a run that calls tools, reasons, and calls more tools, the user sits watching a loading indicator for a minute with zero signal that anything is happening — or whether it's hung.
Why It Fails
The obvious problem is perceived performance: the run isn't slow, but it feels slow because nothing visible happens until the end. Perceived latency is the latency users actually judge you on, and a silent minute reads as a failure even when the work is proceeding fine.
The subtler problem is trust and debuggability. When the agent finally returns, the user has no idea what it did to get there — did it search? which tools? did something error and get retried? A run that shows its work as it goes lets the user follow the reasoning, catch a wrong turn early, and trust the result because they watched it form. A run that only shows the answer asks the user to trust a black box — and users are right to be wary of black boxes that took a full minute to produce something.
And there's a real failure mode hiding in the block: if the connection drops at second 45 of a 60-second run, the user gets nothing and the work is lost. There was no partial result to salvage because nothing was ever sent — the entire run existed only in the server's memory, invisible and unrecoverable, until the moment it chose to reveal everything at once.
⚠️ Common mistake: Streaming only the final model tokens and calling it "streaming." Token streaming makes the answer appear gradually, which helps, but it does nothing for the 40 seconds of tool calls before the answer starts. A real agent harness streaming ui streams events — every tool call, every result, every state change — not just the closing paragraph.
After: The Improved Prompt
The rebuilt version streams a structured event for everything the agent does, over Server-Sent Events, so the UI can render a live timeline of the run.
[object Object],
,[object Object], ,[object Object],(,[object Object],):
,[object Object], ,[object Object],():
,[object Object], event ,[object Object], agent.run_streaming(req.prompt):
,[object Object], ,[object Object],
,[object Object], ,[object Object],
,[object Object], ,[object Object],
,[object Object], Response(event_stream(), mimetype=,[object Object],)What this does: Iterates the agent as a stream of typed events and pushes each one to the browser immediately as an SSE message, tagged with a sequence ID and an event type. The moment the agent decides to call a tool, the UI knows. The moment a result comes back, the UI knows. The user watches the run unfold instead of watching a spinner.
The agent loop yields events at every meaningful point rather than returning once at the end.
[object Object], ,[object Object],(,[object Object],):
state = RunState(prompt)
,[object Object], Event(,[object Object],, seq=state.,[object Object],(), payload={})
,[object Object], ,[object Object], state.done:
,[object Object], chunk ,[object Object], ,[object Object],.model.stream(state.messages):
,[object Object], Event(,[object Object],, seq=state.,[object Object],(), payload={,[object Object],: chunk})
,[object Object], call ,[object Object], state.pending_tool_calls:
,[object Object], Event(,[object Object],, seq=state.,[object Object],(),
payload={,[object Object],: call.name, ,[object Object],: call.args})
result = ,[object Object],.dispatch(call)
,[object Object], Event(,[object Object],, seq=state.,[object Object],(),
payload={,[object Object],: call.name, ,[object Object],: result.ok})
,[object Object], Event(,[object Object],, seq=state.,[object Object],(), payload={,[object Object],: state.answer})What this does: Emits a distinct event when the run starts, for each token as the model streams, when each tool starts and finishes, and when the run completes. The UI gets a running narrative — "searching docs… found 3 results… writing answer…" — built from real events, not a fabricated progress bar. Every event carries a sequence number, which matters for the reconnection trick below.
Breaking Down Each Element
Each event type maps to something the user cares about seeing.
tokentool_startedtool_finishedrun_startedrun_finishedThe
seq[object Object], es = ,[object Object], ,[object Object],(,[object Object],);
es.,[object Object], = ,[object Object], { lastSeq = e.,[object Object],; ,[object Object],(e); };What this does: The browser's
EventSource⚡ Pro tip: Buffer recent events server-side keyed by sequence number so you can actually honor a reconnect. The client promising to send
last-event-idVariations for Different Contexts
SSE is the right default for agent runs because they're one-directional — the server talks, the client mostly listens — and SSE reconnects automatically and rides over plain HTTP. Reach for it first.
Use WebSockets when the user needs to interject mid-run — approving an action, answering a clarifying question, canceling. That two-way channel is worth the extra complexity only when interaction during the run is a real requirement, not just streaming output.
For a CLI or a backend consumer rather than a browser, stream newline-delimited JSON (
ndjson⚠️ Common mistake: Sending the model's raw internal reasoning tokens straight to the UI without a filter. Streaming everything can leak scratch work, tool arguments containing sensitive values, or half-formed statements the model later corrects. Decide which event types are user-facing and stream only those; keep the rest for your logs.
⚡ Pro tip: Send a heartbeat event every few seconds during long tool calls, even when nothing changed. Some proxies and load balancers silently close a connection that goes quiet, and a periodic no-op event keeps the pipe warm — and doubles as a signal to the UI that the run is alive and working, not stuck.
Turning the Stream Into Observability
The same event stream that drives your UI is, almost for free, a live view for your operators — and building an agent harness streaming ui that also feeds monitoring means you get debugging visibility without a second pipeline. The events a user sees as "🔍 searching…" are the same events an on-call engineer can watch to spot a run stuck retrying the same tool five times.
The trick is to emit richer events on an operator channel than on the user channel. Users see friendly, filtered progress; operators see timing, token counts, retry attempts, and error details on every step. Both come from the same underlying event generator — you're just choosing which fields each audience gets.
[object Object], ,[object Object],(,[object Object],):
,[object Object], audience == ,[object Object],:
event.payload |= {,[object Object],: ,[object Object],.step_latency,
,[object Object],: ,[object Object],.step_tokens,
,[object Object],: ,[object Object],.attempt}
,[object Object],.channels[audience].send(event)What this does: Enriches events destined for the operator channel with timing, token, and retry metadata while keeping the user channel clean. One event source, two views — the user's stays reassuring and readable, the operator's becomes a real-time diagnostic feed. When a run misbehaves, you're already watching it happen instead of reconstructing it from logs after the fact.
⚡ Pro tip: Emit a
step_summarySave and Reuse This
The event model — the set of event types, the sequence numbering, the reconnection contract — is the part worth getting right once and reusing everywhere, because every agent you build will want to stream, and inconsistent event schemas across services make shared UI components impossible. A
tool_startedKeep your streaming event definitions and the SSE wiring versioned alongside your prompts and tool schemas in a library like PromptABCD, so every new agent streams the same event types your UI already knows how to render, and "add a live view" becomes wiring, not a rebuild.
Continue Reading
Save the prompts from this post
PromptABCD is a free prompt manager. Paste, organize, and reuse your best AI prompts — no more hunting through chat history.
