Streaming
Watch a run live over SSE — tokens, tools, approvals, re-attach.
In short: you watch the agent work in real time instead of staring at a loading spinner — what it's looking up, what it's doing, what it's writing, and the moment it needs a person. If your connection drops, you rejoin the run already in progress rather than starting it over.
Every run is watchable live over Server-Sent Events, using the open agent-stream protocol. Not just tokens — tool calls starting and finishing, turn economics, the production layer's own work, and the moment a run pauses for a human.
The events
| Event | Fires when |
|---|---|
| run_started | The run is created — carries the run_id |
| token | Assistant text, as it's produced |
| thinking | Extended reasoning, where the SDK exposes it |
| tool_use | A tool is about to execute |
| tool_result | It finished — with duration and status |
| turn | A model call completed — tokens, cost, latency |
| progress | Production-layer work: complexity assessed, memory recalled, guardrail applied |
| approval_required | The run paused for a human (agentFast extension) |
| error | Structured failure |
| done | Always last |
tool_use and tool_result share a tool_use_id, so a client can pair them even when one turn
calls the same tool twice.
Consuming it
curl -N -X POST localhost:8321/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"message":"I need a refund for ord_1001 — it arrived damaged."}'
event: run_started
data: {"run_id": "run_b4110ebd…", "agent": "support", "sdk": "langgraph"}
event: progress
data: {"step": "planning", "message": "assessed task complexity: medium"}
event: tool_use
data: {"tool_name": "kb_search", "tool_use_id": "call_kb_1", "status": "running"}
event: tool_result
data: {"tool_name": "kb_search", "tool_use_id": "call_kb_1", "duration_ms": 6, "status": "done"}
event: approval_required
data: {"run_id": "run_b4110ebd…", "approval_ids": ["appr_…"], "tool_name": "refund_request"}
event: done
data: {"status": "paused", "paused": true, "run_id": "run_b4110ebd…"}
In React, the vendored hook handles parsing, reconnection and tool state:
const { text, activeTools, pausedForApproval, startStream } = useAgentStream();
await startStream("/api/chat/stream", { message }, {
onApprovalRequired: (e) => showApprovalCard(e.approvalIds),
});
Two things worth knowing
A pause is never the last event. approval_required is always followed by a terminating
done with paused: true. agentFast's own client reads the first and renders an approval card; a
stock agent-stream client — which has no case for the event — ignores it and terminates cleanly on
the done. Without that, a paused run would render as finished.
Disconnecting does not cancel the run. A run may be mid-tool-call with real side effects, and
killing it because a browser tab closed is exactly the failure agentFast exists to prevent. The run
continues; re-attach with GET /api/runs/{id}/stream, which replays the step tree so far and then
follows live. To actually stop a run, use the explicit cancel path.
Retrying POST /api/chat/stream after a dropped connection starts a second run — re-executing
tools and re-spending money. Reconnect against the run's own stream endpoint instead. The vendored
client does this for you; if you write your own, this is the bug to avoid.
Redaction is structural
The stream is fed from the same redacted step records the durable trace is built from — never from
raw hook arguments. A tool called with a customer's email address puts [EMAIL_1] on the wire, not
the address, because the only values the stream can see are values the stored trace already saw.
What each SDK gives you
Step-level events — tools, approvals, progress, turn economics — come from the runtime hooks and are identical on all five SDKs. Token-level deltas are adapter-specific:
| SDK | Token streaming |
|---|---|
| Vanilla | Yes — messages.stream |
| LangGraph | Yes — astream |
| Claude Agent SDK | Yes — partial messages |
| OpenAI Agents SDK | Yes — Runner.run_streamed |
| CrewAI | No native seam — text arrives one chunk per turn |
CrewAI's kickoff() returns only when the whole crew is done, so there's nowhere to publish deltas
from. It still streams everything else, and the assistant text is backfilled from the model step —
so every adapter produces readable streaming text, only the granularity differs.
Scope limit
The event bus is in-process. Under uvicorn --workers N, GET /api/runs/{id}/stream only follows
runs owned by the worker handling the request. That's safe rather than silently wrong — the replay
still comes from Postgres and the client falls back to polling GET /api/runs/{id}. Redis pub/sub
is the multi-worker upgrade and needs no change to callers.