Recipe · Agents
Write a tool-calling loop
The agent loop in about sixty lines: dispatch tool calls, return every result including failures, and stop before the cost runs away.
The problem
You want the model to call your functions and continue with the results. The mechanics are simple; the failure modes are not.
Two rules carry most of the reliability: every tool call must be answered, and the loop must have a ceiling.
type Handler = (args: Record<string, unknown>) => Promise<unknown>;
export async function runAgent(
userMessage: string,
handlers: Record<string, Handler>,
maxIterations = 5,
): Promise<string> {
const messages: any[] = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
];
for (let i = 0; i < maxIterations; i++) {
const response = await client.chat.completions.create({
model: 'claude-sonnet-5',
messages,
tools: TOOL_SCHEMAS,
max_tokens: 1024,
});
const choice = response.choices[0];
messages.push(choice.message);
if (choice.finish_reason !== 'tool_calls') {
return choice.message.content ?? '';
}
// Every call needs a result — including the ones that fail. A missing
// result is an invalid conversation and the next request will be rejected.
for (const call of choice.message.tool_calls ?? []) {
const handler = handlers[call.function.name];
let content: string;
try {
content = handler
? JSON.stringify(await handler(JSON.parse(call.function.arguments)))
: `No handler registered for ${call.function.name}.`;
} catch (error) {
// The model recovers from "that failed" and cannot recover from silence.
content = `Tool failed: ${error instanceof Error ? error.message : 'unknown error'}`;
}
messages.push({ role: 'tool', tool_call_id: call.id, content });
}
}
// Hitting the ceiling is a result, not a crash. Say so.
return 'Could not complete within the iteration limit.';
}let spent = 0;
const BUDGET_PER_TASK = 0.25;
// Inside the loop, after each response:
spent += costOf(response.usage, pricing);
if (spent > BUDGET_PER_TASK) {
return 'Stopped: task exceeded its cost budget.';
}
// History grows every pass, because the API is stateless and the whole
// conversation is resent. Total input grows with the square of the iteration
// count, which is why the ceiling matters more than it looks.Why it is written this way
Answer every call, including failures
An unanswered tool call makes the conversation invalid and the next request fails with an unhelpful error. Wrapping the handler so an exception becomes a result is the single most important line in this loop.
The ceiling is not a formality
Each iteration carries the full fixed overhead — system prompt plus every tool schema — plus the accumulated history. Agents that cannot finish in four passes rarely finish in ten; they just cost more to fail.
Handle parallel calls
A model can request several tools in one message. Iterate over all of them — answering only the first fails the same way as answering none.
What breaks the naive version
- Pushing the assistant message after the tool results instead of before. Order is part of the contract.
- Mismatched tool_call_id between the call and the result.
- Returning enormous tool results. They are resent on every subsequent iteration and become the dominant cost.
Check your numbers
- AI Agent GeneratorChoose the agent type and get a finished config, not an empty form. Four blueprints.
- Agent Loop Cost SimulatorPer-iteration breakdown showing why agents cost several times the naive estimate.
- Function Call Message FormatterValid tool_use / tool_result blocks with matching ids, both providers.