SuperAPI AI Directory

agent-sdk-streaming

Agents & models

View repository

Updated 10-15 23:49

About this skill

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

  1. Awaiting the full response then rendering — defeats streaming entirely
  2. No flush on server — browser gets nothing until buffer fills
  3. No cancellation path — users stuck watching a runaway agent
  4. Rendering every text delta synchronously — UI jank on long responses
  5. Logging nothing on stream end — can't diagnose partial failures

Best Practices

  1. Stream everything — text, thinking, tool calls, results
  2. Flush after every SSE write; set X-Accel-Buffering: no at proxies
  3. Coalesce text deltas to 30 FPS rendering
  4. Support abort with a cancel button
  5. Map tool calls to user-friendly labels; hide internals behind disclosure
  6. Emit heartbeats on long tool waits so connections don't die
  7. Show distinct visual states for thinking vs text vs tool use