A single tool call is not an agent. What turns a model-and-a-function into an agent is the loop: the model asks for a tool, you run it, and — the part that matters — you feed the result back into the conversation so the model can act on it. Without that feedback, the model is blind to what its own tool returned; it cannot use the answer, correct a bad call, or know the task is done.
This lesson builds that loop from scratch. No real model runs here, so we stand in for one with a small stub — an object whose respond method returns scripted replies one after another. The loop itself is the real thing: it reads each reply, executes any tool calls, appends the results as messages, and stops when the model has nothing left to ask for. That is testable with zero model calls, because the interesting logic is the loop, not the reply.
flowchart LR M["model reply"] --> CHK["has tool_calls?"] CHK -->|"yes"| EX["execute each tool"] EX --> FB["append results as messages"] FB --> M CHK -->|"no"| DONE["done: final answer"] style DONE fill:#166534,color:#fff
The parts of the loop
The conversation is a list of messages, each tagged with a role. A user message is the prompt; an assistant message is the model's turn — either plain content (a final answer) or a tool_calls array listing the tools it wants run. Each entry in tool_calls carries an id, a name, and arguments. When you run a tool, you append a tool message back: role tool, a tool_call_id that links it to the request, and content holding the result as text.
That linkage is not decorative. The tool_call_id is how the conversation stays coherent when one turn asks for several tools at once — each result is matched back to the call that produced it. Stringifying the result into content is deliberate too: the model consumes text, so numbers and objects become their string form before they re-enter the conversation.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoop(model, tools, messages) {
while (true) {
const reply = model.respond(messages);
messages.push(reply);
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
}
return messages;
}
const model = scriptedModel([
{ role: 'assistant', tool_calls: [{ id: 'c1', name: 'add', arguments: { a: 2, b: 3 } }] },
{ role: 'assistant', content: 'The sum is 5' }
]);
const messages = [{ role: 'user', content: 'add 2 and 3' }];
runLoop(model, { add: ({ a, b }) => a + b }, messages);
console.log(messages.length);
console.log(messages[messages.length - 1].content);
How one turn unfolds
Read the loop top to bottom. It asks the model for a reply and appends that reply to the conversation — even a reply that asks for tools becomes part of the record, because the model's request is itself context. If the reply carries no tool_calls, the model has given a final answer and the loop breaks. If it does carry tool_calls, the loop runs each one, appends a tool message for each result, and loops back to ask the model again now that those results are visible.
The exit condition is doing real work: a model that always returns tool calls would loop forever. Termination rests on the model eventually producing a turn with plain content and no calls — which is why a well-prompted agent is told to stop calling tools once it has the answer.
flowchart TD U["user: add 2 and 3"] --> A1["assistant: tool_calls add"] A1 --> T1["tool c1: 5"] T1 --> A2["assistant: The sum is 5"] style A2 fill:#166534,color:#fff
function executeCall(tools, call) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
return { role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) };
}
const tools = { double: ({ n }) => n * 2 };
console.log(executeCall(tools, { id: 'c1', name: 'double', arguments: { n: 21 } }).content);
console.log(executeCall(tools, { id: 'c2', name: 'missing', arguments: {} }).content);
Results become the next input
Whatever a tool returns is stringified into the content of the tool message and handed straight back to the model as its newest piece of context. That means a tool's failure is also a message, not a crash: when the name is unknown or the function throws, the loop records an error object as the content and carries on, so the model can read the failure and try a different call. Crashing the whole loop on one bad tool would take down an otherwise healthy conversation.
This mirrors the tool-level failure idea you met earlier — a tool that ran but failed is reported as data, not as a protocol error. Inside the loop, that means catching problems per call and folding them into the message stream, never letting one tool's exception escape and abort the run.
Termination and the step budget
A loop that can run forever eventually will. Real agent loops carry a step budget — a cap on how many model turns may happen — so a misbehaving model cannot run up an unbounded bill. When the budget is spent, the loop stops whether the task finished or not, and the caller decides whether to retry or report partial progress.
The budget also makes the loop honest to test. Because our model is a stub returning scripted replies, and because the budget bounds the turn count, every run is fully determined by the scripts and the budget — no timers, no randomness, no network. That determinism is why we can assert exactly which messages the loop produces instead of guessing at what a live model might do.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoopWithBudget(model, tools, messages, maxSteps) {
let steps = 0;
while (steps < maxSteps) {
const reply = model.respond(messages);
messages.push(reply);
steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
}
return messages;
}
const scripts = [
{ role: 'assistant', tool_calls: [{ id: 'c1', name: 'a', arguments: {} }] },
{ role: 'assistant', tool_calls: [{ id: 'c2', name: 'a', arguments: {} }] },
{ role: 'assistant', content: 'done' }
];
const out = runLoopWithBudget(scriptedModel(scripts), { a: () => 1 }, [], 1);
console.log(out.length);
console.log(out[out.length - 1].role);
flowchart TD GOOD["result appended as a message"] --> NXT1["model sees it, continues"] BAD["result dropped"] --> NXT2["model is blind, repeats or stalls"] style GOOD fill:#166534,color:#fff style BAD fill:#b91c1c,color:#fff
What does this print? The tool result is stringified into a tool message. JSON.stringify(42) is the string 42.
const tools = { double: ({ n }) => n * 2 };
const call = { id: 'c1', name: 'double', arguments: { n: 21 } };
const fn = tools[call.name];
const result = fn(call.arguments);
const toolMsg = { role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) };
console.log(toolMsg.role);
console.log(toolMsg.content);
double(21) is 42, and JSON.stringify turns it into the string '42'.
The message's role is 'tool'.
Write executeCall(tools, call) that looks up call.name in tools, runs it with call.arguments, and returns a tool message { role: 'tool', tool_call_id: call.id, content: <stringified result> }. If the name is unknown, the content is the stringified object { error: 'unknown tool' }.
function executeCall(tools, call) {
// run tools[call.name](call.arguments); unknown name -> { error: 'unknown tool' }
}
Read tools[call.name]; if undefined, the result is { error: 'unknown tool' }.
Return the message with JSON.stringify(result) as content.
function executeCall(tools, call) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
return { role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) };
}
Write runLoop(model, tools, messages). Each turn, append the model's reply; if the reply has no tool_calls, stop. Otherwise run each call (unknown name yields { error: 'unknown tool' }) and append a tool message for its result, then ask the model again. The scriptedModel helper is already defined for you.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoop(model, tools, messages) {
// append each reply; on tool_calls, execute each and append a tool message; stop when none
}
Use a while (true) loop: get the reply, push it, then break if it has no tool_calls.
Otherwise loop over reply.tool_calls, run each, and push a { role: 'tool', ... } message per result.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoop(model, tools, messages) {
while (true) {
const reply = model.respond(messages);
messages.push(reply);
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
}
return messages;
}
This loop runs each tool but never appends the result as a message, so the model never sees what its tools returned and the conversation has a hole where the answer should be. Add the one missing step: push a tool message (role tool, tool_call_id, stringified content) for each call.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoop(model, tools, messages) {
while (true) {
const reply = model.respond(messages);
messages.push(reply);
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
// BUG: result is computed but never added to the conversation
}
}
return messages;
}
The result variable is already computed inside the loop body.
Push { role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) } before the loop iterates.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoop(model, tools, messages) {
while (true) {
const reply = model.respond(messages);
messages.push(reply);
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
}
return messages;
}
Write runLoopWithBudget(model, tools, messages, maxSteps) — the same loop as before, but it stops after maxSteps model turns even if tool calls remain. Count one step per respond. The scriptedModel helper is already defined for you.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoopWithBudget(model, tools, messages, maxSteps) {
// like runLoop, but stop after maxSteps model turns even if tool_calls remain
}
Track steps starting at 0 and loop while steps < maxSteps.
Increment steps once per respond, then run the tool calls as usual.
function scriptedModel(scripts) {
let i = 0;
return { respond() { return scripts[i++]; } };
}
function runLoopWithBudget(model, tools, messages, maxSteps) {
let steps = 0;
while (steps < maxSteps) {
const reply = model.respond(messages);
messages.push(reply);
steps++;
if (!reply.tool_calls || reply.tool_calls.length === 0) break;
for (const call of reply.tool_calls) {
const fn = tools[call.name];
const result = fn ? fn(call.arguments) : { error: 'unknown tool' };
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) });
}
}
return messages;
}
Recap
- The dispatch loop is what makes a tool-using system an agent: read a reply, run any tool calls, append the results, repeat.
- A tool message has role
tool, atool_call_idlinking it to its request, and stringifiedcontentthe model can consume. - The loop stops when a turn has no
tool_calls; a step budget caps the turns so a runaway model cannot loop forever. - A tool failure is a message, not a crash — record the error in
contentand let the model recover. - Never drop a result: every tool call must produce exactly one appended tool message, or the model goes blind to its own actions.
Next you will see why a registry cannot simply expose every tool at once — too many definitions bloat the prompt, cost tokens, and invite misuse.