Claude agentic tool-use loop
Run-until-stop agent loop in TypeScript — tool calls, results, halt conditions. The core of every production agent.
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_STEPScap is non-negotiable. Trackusageper 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: truefor 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.