The Problem
The engineering works perfectly. The agent takes 8 steps over 45 seconds to complete a research task. The user sees: a blank screen with a spinner.
They wait 10 seconds. 20 seconds. They refresh the page. The agent starts over from step 1. The 30 seconds of completed work is lost.
Or worse: the agent finishes and emails 500 contacts with a draft the user hasn't reviewed. The action was irreversible. There was no confirmation step.
These are not frontend problems. They're engineering problems. The agent doesn't emit progress events — that's an architect's decision. The agent doesn't pause before irreversible actions — that's a control flow decision. UX is baked into how the agent is designed.
This lesson covers the three patterns that make agents usable in production: streaming progress events, human-readable action logs, and confirmation gates before irreversible actions.
The trust contract
Users accept agent autonomy when they have three things: visibility (I can see what it's doing), understanding (I can read what it did), and control (I can stop it before it does something I don't want). Streaming progress provides visibility. Action logs provide understanding. Confirmation gates provide control. Remove any one of these and user trust collapses.
Streaming Agent Progress
Don't make users watch a spinner for 45 seconds. Stream progress events.
Event structure:
@dataclass
class ProgressEvent:
type: EventType # step_start | step_complete | step_failed | agent_complete
step: str # human-readable step description
step_num: int # current step number
total_steps: int # total expected steps
result_preview: str # brief result summary (e.g., "Found 23 papers")
timestamp: float
Event flow for a research task:
{type: "agent_started", step: "Research on: transformers in long-context models"}
{type: "step_start", step: "Searching for relevant papers", step_num: 2, total_steps: 7}
{type: "step_complete", step: "Searching for relevant papers", result_preview: "Found 23 papers"}
{type: "step_start", step: "Filtering papers by recency", step_num: 3, total_steps: 7}
...
{type: "agent_complete", step: "Research summary complete"}
Delivery: Server-Sent Events (SSE) for server-to-client streaming. One persistent HTTP connection, events pushed as they're emitted. No polling, no WebSocket complexity.
# Flask SSE endpoint
@app.route("/events/<agent_run_id>")
def event_stream(agent_run_id):
def generate():
for event in event_queue.listen(agent_run_id):
yield event.to_sse() # "data: {...}\n\n"
return Response(generate(), mimetype="text/event-stream")
The frontend renders each event as it arrives: "Step 3/7: Filtering papers by recency..." When the step completes, the line updates: "Step 3/7: Filtering papers by recency ✓ (8 papers remaining)". The 45-second wait feels much shorter when you can watch the work happening.
Why SSE over WebSockets: SSE is unidirectional (server → client) — exactly what progress streaming needs. WebSockets are bidirectional and require connection management. For streaming progress events, SSE is simpler, more HTTP-friendly, and works through proxies and load balancers without special configuration.
An agent task takes 40 seconds. The best UX is:
Explanation: A spinner is a black box — users don't know if anything is happening, and they can't tell if the system is stuck or working. Streaming events give users a real-time window into the agent's work. Psychologically, the same 40 seconds feels much shorter when you can see progress ("Step 5/8: Drafting report..."). A warning message helps but doesn't solve the blank-screen problem. A percentage bar is better than a spinner but requires knowing the total duration, which agents generally don't.