TokenPad

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.

The looptypescript
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.';
}
Keep the loop from bankrupting youtypescript
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