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`);
}

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.