Claude agentic tool-use loop

Run-until-stop agent loop in TypeScript — tool calls, results, halt conditions. The core of every production agent.

Claude agentic tool-use loop

Every production agent boils down to: call the model, run any tools it requested, append results, repeat. Here is that loop without a framework.

The loop

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

type Tool = {
  name: string;
  description: string;
  input_schema: Record<string, unknown>;
  run: (input: any) => Promise<unknown>;
};

const MAX_STEPS = 12;

export async function runAgent(opts: {
  system: string;
  task: string;
  tools: Tool[];
}) {
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: opts.task },
  ];

  for (let step = 0; step < MAX_STEPS; step++) {
    const response = await client.messages.create({
      model: "claude-opus-4-7",
      max_tokens: 4096,
      system: opts.system,
      tools: opts.tools.map((t) => ({
        name: t.name,
        description: t.description,
        input_schema: t.input_schema as Anthropic.Tool.InputSchema,
      })),
      messages,
    });

    messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason === "end_turn") return { messages, response };
    if (response.stop_reason !== "tool_use") {
      throw new Error(`Unexpected stop_reason: ${response.stop_reason}`);
    }

    const toolResults: Anthropic.ToolResultBlockParam[] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;
      const tool = opts.tools.find((t) => t.name === block.name);
      try {
        const out = tool ? await tool.run(block.input) : { error: "unknown tool" };
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: JSON.stringify(out),
        });
      } catch (err) {
        toolResults.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: String(err),
          is_error: true,
        });
      }
    }
    messages.push({ role: "user", content: toolResults });
  }

  throw new Error(`Agent did not finish within ${MAX_STEPS} steps`);
}

How it works

The loop maintains a single messages array that grows with every turn — the API is stateless, so the full conversation is resent on each call. Each tool is described to the model by name, description, and a JSON Schema input_schema; the same object also carries a run function the harness will call locally, which keeps the schema the model sees and the code that executes it in one place.

Inside the loop, every response is appended to messages as an assistant turn so the model always sees its own prior tool calls. The branch point is stop_reason. When it's end_turn, the model is done and the loop returns. When it's tool_use, the assistant message contains one or more tool_use blocks; the code finds each matching tool, runs it, and collects a tool_result block keyed by the block's id. All of those results go back in a single user message — that ordering, tool_use blocks in then tool_result blocks out, is what the API expects. Failures are caught and returned as a result with is_error: true rather than thrown, so the model is told the tool failed and can recover instead of the whole loop crashing. Any other stop_reason is unexpected here and raises. The MAX_STEPS counter bounds the whole thing: if the model never reaches end_turn, the loop throws rather than spinning forever.

Use it

const tools: Tool[] = [
  {
    name: "search_docs",
    description: "Search project docs by query string.",
    input_schema: {
      type: "object",
      properties: { query: { type: "string" } },
      required: ["query"],
    },
    run: async ({ query }) => searchDocs(query),
  },
];

await runAgent({
  system: "You are an engineering assistant. Use tools when needed; otherwise answer.",
  task: "How does our auth flow refresh tokens? Cite the file.",
  tools,
});

Production notes

  • Halt conditions matter. A hard MAX_STEPS cap is non-negotiable. Track usage per step and short-circuit on token budgets too.
  • Stream the final assistant message for UX, but the loop itself runs non-streaming so tool dispatch is deterministic.
  • Always echo is_error: true for tool failures — Claude recovers gracefully when it knows a result was an error vs. legitimate empty data.
  • Idempotency. Tools that mutate state should accept a deterministic key from the model so retries don't double-write.

When to use it

Write the loop yourself when you want fine-grained control over the agentic cycle — custom logging, per-step token accounting, human-in-the-loop approval before a tool runs, or conditional execution based on which tool was called. The SDKs also ship a higher-level tool runner that drives this exact cycle for you; reach for the manual loop when you need to intercept it, and the runner when you don't.

The pieces most people get wrong are the ones this snippet is careful about. Return every tool result in one user message, not several — splitting them across messages quietly teaches the model to stop making parallel calls. Always echo the model's failures back with is_error: true instead of swallowing them; the model handles "that lookup failed" far better than silence or a fabricated empty result. Keep a hard step cap so a confused model can't loop indefinitely, and consider tracking cumulative token usage as a second halt condition. Finally, give tools that change state a deterministic idempotency key, because the model may legitimately retry a call it thinks failed, and you don't want a double-write as a result.