Skip to content
fellowcoder
All tutorials

Build a streaming tool loop with the Claude API

A production-shaped agent loop in TypeScript: typed tools, streamed output, human approval gates, and the pause_turn case that silently truncates answers.

fellowcoder9 min read1,790 wordsadvanced

The tool-use loop is conceptually four lines: call the model, if it asks for a tool run the tool, send the result back, repeat. Every tutorial shows those four lines.

What the four lines omit is everything that makes the loop usable: streaming tokens to a UI while tools execute, gating destructive operations behind approval, handling the pause that server-side tools introduce, and bounding the whole thing so a bad prompt can't run forever.

This builds the loop with those pieces in place.

Define tools with schemas you don't hand-write#

The SDK's tool runner generates JSON Schema from Zod, which means the tool's input type, its runtime validation, and its schema all come from one declaration. No drift between the schema you send and the type you handle.

tools.ts
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";
 
export const searchDocs = betaZodTool({
  name: "search_docs",
  description:
    "Search the internal documentation index. Call this whenever the user asks " +
    "about product behavior, configuration, or API details — do not answer " +
    "from memory. Returns up to 5 passages with source URLs.",
  inputSchema: z.object({
    query: z.string().describe("Natural-language search query"),
    limit: z.number().int().min(1).max(5).default(3),
  }),
  run: async ({ query, limit }) => {
    const results = await docIndex.search(query, limit);
    return results
      .map((r, i) => `[${i + 1}] ${r.title}${r.url}\n${r.excerpt}`)
      .join("\n\n");
  },
});

That description is doing real work. Recent Claude models are conservative about reaching for tools — they'll reason from context rather than call something unless the description says when to call it. "Searches the docs" gets called half as often as "Call this whenever the user asks about product behavior… do not answer from memory."

Note what's not in the description: no CRITICAL:, no YOU MUST. Prompts written for older models used that language to overcome reluctance; current models follow the system prompt closely enough that the emphasis causes over-triggering instead.

Run the basic loop#

agent.ts
import Anthropic from "@anthropic-ai/sdk";
import { searchDocs } from "./tools";
 
const client = new Anthropic();
 
const finalMessage = await client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 16000,
  thinking: { type: "adaptive" },
  output_config: { effort: "high" },
  system: "You are a support engineer. Cite sources as [n] matching the tool output.",
  tools: [searchDocs],
  messages: [{ role: "user", content: "Why am I getting 429s on the batch endpoint?" }],
});

toolRunner drives the whole cycle — request, detect tool use, execute your run functions, feed results back — and resolves when the model stops calling tools.

Two parameters worth understanding rather than copying.

thinking: { type: "adaptive" } lets the model decide how much to reason per request. On Claude Opus 5 it's the default, so omitting the field gives you the same behavior — but be aware that max_tokens caps thinking and response text together, so a value tuned for a non-thinking model can truncate.

output_config: { effort } is the main cost and latency dial. high is the default; xhigh suits hard agentic work; low and medium are stronger than their names suggest and are the first thing to try when a route is too slow.

Stream tokens while tools run#

Set stream: true and the runner's shape changes: iterating yields a stream per turn rather than a message per turn. The outer loop walks turns, the inner loop walks events within a turn.

agent.ts
const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 16000,
  thinking: { type: "adaptive" },
  tools: [searchDocs],
  messages,
  stream: true,
});
 
for await (const messageStream of runner) {
  for await (const event of messageStream) {
    switch (event.type) {
      case "content_block_start":
        if (event.content_block.type === "tool_use") {
          ui.toolStarted(event.content_block.name);
        }
        break;
 
      case "content_block_delta":
        if (event.delta.type === "text_delta") {
          ui.appendText(event.delta.text);
        }
        // input_json_delta is a tool's arguments arriving in fragments —
        // it is NOT parseable mid-flight. Wait for the block to close.
        break;
 
      case "content_block_stop":
        ui.blockComplete(event.index);
        break;
    }
  }
}

The input_json_delta note is a real trap. Those fragments concatenate into things like {"query": "batch endpoint rate li — not JSON, and JSON.parse will throw. Show a "Searching…" indicator while the block streams and read the complete input after content_block_stop.

Gate the destructive tools#

The runner executes run automatically. For a search tool that's fine. For anything that sends email, writes to a database, or spends money, you want a human in the path.

You do not need a manual loop for this — gate inside run itself. The tool returns a "declined" result, the model sees it, and the conversation continues naturally.

tools.ts
export const sendEmail = betaZodTool({
  name: "send_email",
  description: "Send an email to a customer. Requires human approval.",
  inputSchema: z.object({
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  run: async (input) => {
    const decision = await ui.requestApproval({
      title: `Send email to ${input.to}?`,
      preview: `${input.subject}\n\n${input.body}`,
    });
 
    if (!decision.approved) {
      // A normal tool result, not a throw. The model reads this and adapts.
      return `The user declined to send this email. Reason: ${
        decision.reason ?? "not given"
      }. Do not retry without addressing it.`;
    }
 
    await mailer.send(input);
    return `Sent to ${input.to}.`;
  },
});

Returning a declined result rather than throwing matters. A throw is an error the model has to interpret; a result is information it can act on — usually by asking what to change, which is the behavior you want.

Handle pause_turn, or lose answers silently#

This one is easy to miss because there is no error.

Server-side tools — web search, web fetch, code execution — run inside a server-side sampling loop with an iteration cap. Hit the cap and the response comes back with stop_reason: "pause_turn", meaning "not finished, send this back to continue."

The tool runner does not resume automatically. It only continues after a client tool produces a result. A paused turn ends the loop and is returned as the final message, with no error and no warning — just a truncated answer.

If you mix server tools into the runner, check stop_reason on every turn and push the paused assistant turn back:

agent.ts
const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 16000,
  tools: [searchDocs, { type: "web_search_20260209", name: "web_search" }],
  messages,
  stream: true,
});
 
for await (const messageStream of runner) {
  for await (const event of messageStream) {
    /* render as above */
  }
 
  const message = await messageStream.finalMessage();
  if (message.stop_reason === "pause_turn") {
    runner.pushMessages({ role: "assistant", content: message.content });
  }
}

Note the non-streaming form differs: without stream: true each iteration yields a message directly, so you check message.stop_reason on the loop variable. With streaming you must resolve finalMessage() first — a bare stop_reason check on the stream object never fires, which is a bug that looks exactly like the model occasionally stopping early.

Bound the loop and propagate cancellation#

Two failure modes remain: loops that don't terminate, and work that continues after the user has left.

agent.ts
const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 16000,
  tools,
  messages,
  stream: true,
  max_iterations: 12,
});

max_iterations is your circuit breaker. A model stuck alternating between two tools will otherwise run until your budget notices. Twelve is a reasonable default for a support agent; long-horizon coding agents want more.

Remember that pause-and-resume consumes an iteration, so a capped run can end paused. Check the final stop_reason before trusting the result.

For cancellation, thread the request's abort signal through to the SDK:

route.ts
export async function POST(req: Request) {
  const controller = new AbortController();
  req.signal.addEventListener("abort", () => controller.abort());
 
  const runner = client.beta.messages.toolRunner(
    { model: "claude-opus-5", max_tokens: 16000, tools, messages, stream: true },
    { signal: controller.signal },
  );
  // ...
}

Test it for real: start a long generation, kill the client, and confirm your usage metrics stop climbing. Most implementations that believe they handle cancellation drop the signal at some framework boundary and keep generating into the void.

Know when to drop to a manual loop#

The runner covers approval gates, result inspection, streaming, and retries. The cases that genuinely need a hand-written loop are narrower than people assume: a custom transport, request shapes the SDK can't build, control flow that interleaves unrelated work mid-loop, or avoiding the beta dependency.

If you're in one of those, the shape is:

manual.ts
let messages: Anthropic.MessageParam[] = [{ role: "user", content: input }];
 
for (let i = 0; i < MAX_ITERATIONS; i++) {
  const response = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: 16000,
    tools: toolDefinitions,
    messages,
  });
 
  if (response.stop_reason === "end_turn") break;
 
  if (response.stop_reason === "pause_turn") {
    messages.push({ role: "assistant", content: response.content });
    continue;
  }
 
  // Append the FULL content — tool_use blocks must survive the round trip.
  messages.push({ role: "assistant", content: response.content });
 
  const toolUses = response.content.filter(
    (b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
  );
 
  // Parallel calls: execute concurrently, return ALL results in ONE user message.
  const results = await Promise.all(
    toolUses.map(async (block) => {
      try {
        return {
          type: "tool_result" as const,
          tool_use_id: block.id,
          content: await execute(block.name, block.input),
        };
      } catch (error) {
        return {
          type: "tool_result" as const,
          tool_use_id: block.id,
          content: String(error),
          is_error: true,
        };
      }
    }),
  );
 
  messages.push({ role: "user", content: results });
}

Two rules in there are not optional. Append the entire response.content, not just the text — dropping tool_use blocks breaks the tool_use_id pairing and the next request 400s. And return all tool results in a single user message; splitting them across messages teaches the model to stop issuing parallel calls, which quietly halves your throughput.

Failed tools get is_error: true and an informative message, not a dropped result. A missing tool_result for an issued tool_use is a malformed conversation.

What you ended up with#

A loop that streams while it works, asks before it does anything irreversible, survives the server-side pause, stops when it should, and dies when the user leaves.

The pieces that took the longest to get right — pause_turn, not parsing partial JSON, returning parallel results together — all share a property: they fail silently. Nothing throws. The answer is just quietly worse than it should have been.

That's the argument for logging stop_reason and iteration counts from day one. The bugs in agent loops rarely announce themselves; they show up as a support ticket saying the answer seemed incomplete, three weeks later, with no way to reproduce it.