Tool Calling· Lesson 3 of 7
The Tool-Call Loop
In one line · The real work is a loop: the model requests a tool, you run it, you feed the result back, and it continues — until it reaches a final answer.
The model itself never runs any tool; you own this loop. Miss it and your app stalls after the first tool request.
Like a back-and-forth call: the model says 'get me X', you fetch and return it, and it may then ask for Y. You keep looping until it says 'done, here's the answer'.
Like a cook requesting ingredients one after another from the pantry, deciding the next need as each arrives, until the dish is done.
A user asks 'was customer 7's invoice paid?' — the model calls get_invoice(7), you return 'paid', and it replies to the user in plain words 'yes, the invoice was paid'.
Each loop turn is a fresh, full request: (1) you send the conversation plus tool definitions. (2) The model replies with either final text or a tool request (name + inputs + id). (3) Your code validates the inputs, executes, and appends the result as a message tied to that same id. (4) You resend the whole conversation and loop. The conversation grows inside the context window every turn, and each turn is a paid inference call — which is why a max-iterations cap must stop the loop from spinning forever.
The loop skeleton in simplified TypeScript:
let messages = [{ role: "user", content: userQuestion }];
for (let turn = 0; turn < MAX_TURNS; turn++) {
const reply = await llm.chat({ messages, tools });
if (reply.type === "text") return reply.text; // final answer
// The model requested a tool — you validate and execute
const input = validate(reply.toolName, reply.input);
const result = await runTool(reply.toolName, input);
messages.push(assistantToolRequest(reply));
messages.push(toolResult(reply.id, result)); // tied to the same id
}
throw new Error("hit max turns");🧪 Try it yourself: a user asks 'send a reminder to the customer whose invoice is overdue'. Trace the loop on paper step by step: turn 1, what does the model request? (find_overdue_invoice) — what do you return? Turn 2, what does it request? (send_reminder with the customer's email) — and turn 3, what does the final reply say? Write out the messages array at each turn.
Top mistakes: (1) treating the first reply containing a tool call as the final answer and showing it to the user. (2) Forgetting to return the tool result, leaving the model waiting forever. (3) No max iterations, so a lost model loops endlessly and burns money. (4) Returning the result without the request's id, so the API rejects it or the model gets confused. (5) Executing without validation — the model's request is not a command.
Any app with tools needs this loop — one question may need a chain of tools: find the customer, fetch their invoice, email them.
If the task is always a single, known-in-advance call (structured JSON extraction, say), you don't need a full loop — handle the one request and skip the complexity.