Agent SDK Streaming
Streaming is the difference between "this agent works" and "this agent feels alive". Stream text as it generates, show tool activity, and never block the UI on a long tool call.
When to Use
- Building any interactive agent UI
- Long-running agents where users need to see progress
- Debugging "why does my agent hang?" (often: you're not reading the stream)
Event Types
The SDK emits a typed stream of events. Core types:
| Event | When | UI treatment |
|---|---|---|
assistant with text block |
Model generates text | Append to current message bubble |
assistant with thinking |
Extended thinking | Show "thinking..." spinner |
assistant with tool_use |
Model decides to call tool | Show tool badge ("calling web_fetch...") |
user with tool_result |
Tool returned | Show result or just acknowledge |
system with progress |
Tool progress notification | Update progress bar |
result |
Turn complete | Remove spinner, finalize |
TypeScript Streaming
import { query } from "@anthropic-ai/claude-agent-sdk";
const ui = new ChatUI();
for await (const msg of query({ prompt, options: { model: "claude-sonnet-4-6" } })) {
switch (msg.type) {
case "assistant": {
for (const block of msg.message.content) {
if (block.type === "text") ui.appendText(block.text);
if (block.type === "thinking") ui.showThinking(block.thinking);
if (block.type === "tool_use") ui.showToolCall(block.name, block.input);
}
break;
}
case "user": {
for (const block of msg.message.content) {
if (block.type === "tool_result") ui.showToolResult(block.tool_use_id, block.content);
}
break;
}
case "result": {
ui.finalize(msg.result);
break;
}
}
}
Text Deltas vs Messages
Some SDK versions emit full messages (with cumulative text) rather than deltas. Handle both:
let lastText = "";
// on assistant text:
const newText = block.text.slice(lastText.length);
ui.appendText(newText);
lastText = block.text;
Check your SDK version's docs; deltas reduce UI work but complete messages simplify state.
Streaming to a Web Client
For web UIs, bridge Node stream → SSE:
app.get("/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.flushHeaders();
for await (const msg of query({ prompt: req.query.q, options: {...} })) {
res.write(`data: ${JSON.stringify(msg)}\n\n`);
if (typeof (res as any).flush === "function") (res as any).flush();
}
res.end();
});
Gotchas:
- Disable proxy buffering (
X-Accel-Buffering: no) if behind nginx - Flush after every write — otherwise the browser sees nothing for 30s
- Use heartbeat comments (
: ping\n\n) every 15s to prevent proxy idle timeouts
React Hook Pattern
function useAgent() {
const [messages, setMessages] = useState([]);
const [streaming, setStreaming] = useState(false);
const send = async (prompt: string) => {
setStreaming(true);
const res = await fetch("/chat?q=" + encodeURIComponent(prompt));
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const events = buf.split("\n\n");
buf = events.pop()!;
for (const evt of events) {
if (evt.startsWith("data: ")) {
const msg = JSON.parse(evt.slice(6));
setMessages((m) => reduceAgentMsg(m, msg));
}
}
}
setStreaming(false);
};
return { messages, streaming, send };
}
Backpressure
If the UI renders slower than events arrive:
- Coalesce adjacent text deltas into one render frame (throttle to 30 FPS)
- Batch tool-use and tool-result events into grouped UI updates
- For huge responses, consider virtualization (react-virtuoso) so DOM stays light
Cancellation
Users change their mind. Support interrupting a running agent:
const controller = new AbortController();
for await (const msg of query({
prompt,
options: { abortController: controller },
})) { /* ... */ }
// elsewhere:
cancelButton.onclick = () => controller.abort();
Always cancel gracefully — show "Stopped by user" instead of a broken state.
Error Handling
try {
for await (const msg of query({ prompt, options })) { /* ... */ }
} catch (err) {
if (err.name === "AbortError") ui.showStopped();
else if (err.status === 429) ui.showRateLimit();
else ui.showError(err.message);
}
Don't silently fail — users need to know whether to retry.
Showing Tool Activity
Raw tool calls are noisy. Map them to user-friendly UI:
const TOOL_LABELS: Record<string, string> = {
web_fetch: "Looking up the web",
code_execution: "Running code",
Task: "Delegating research",
};
ui.showToolCall = (name, input) => {
ui.addStatusLine(TOOL_LABELS[name] ?? `Running ${name}...`);
};
Collapse details into an expandable disclosure — power users want to see the raw call; others don't.
Anti-Patterns
- Awaiting the full response then rendering — defeats streaming entirely
- No flush on server — browser gets nothing until buffer fills
- No cancellation path — users stuck watching a runaway agent
- Rendering every text delta synchronously — UI jank on long responses
- Logging nothing on stream end — can't diagnose partial failures
Best Practices
- Stream everything — text, thinking, tool calls, results
- Flush after every SSE write; set
X-Accel-Buffering: noat proxies - Coalesce text deltas to 30 FPS rendering
- Support abort with a cancel button
- Map tool calls to user-friendly labels; hide internals behind disclosure
- Emit heartbeats on long tool waits so connections don't die
- Show distinct visual states for thinking vs text vs tool use