Skip to content
agentFast
The production layer

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.

CarefulNever reconnect by re-POSTing

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.