The Agent Loop From Scratch¶
Hands-on
Full runnable recipe: agent-loop-from-scratch/ in the companion cookbook — a real tool-calling loop with the raw Anthropic client, no framework, checked against an independently computed ground truth.
TL;DR — quick revision
- The model never calls your function. At inference time it's given a schema and generates a structured request — a
tool_useblock naming a tool and its arguments. Your orchestration code parses that, runs the real function, and sends the result back as a new message (atool_result) on the next turn. All the "agentic" behavior here is: structured generation, external execution, and a feedback loop — nothing more magical than that. - The model has no guaranteed connection to reality until your code creates one. It can request a tool that doesn't exist, or arguments that don't validate — there's no built-in check that its output matches the schema until your orchestration code checks.
- Tool descriptions are a prompt, not documentation. Vague parameter descriptions produce vague argument extraction; concrete descriptions with worked examples measurably improve it. This is a design surface, not an afterthought.
- A hard iteration cap bounds round-trips, not per-call latency. They're separate risks and need separate limits — a loop that can't run away forever can still stall for a long time on one slow tool call.
- A real run here produced a genuinely counter-intuitive result: the no-tools baseline got every arithmetic step right on its own — what it actually lacked was a live exchange rate, which isn't an arithmetic problem at all.
Mechanism: what "the model calls a tool" actually means¶
The weak version of this explanation — the one that gets corrected in an interview — is "the model calls the function." It doesn't, and there's no version of any major provider's API where it does.
What actually happens: your code sends the model a list of tool schemas (name, description, a JSON Schema for the arguments) alongside the conversation. If the model decides a tool would help, it doesn't execute anything — it generates a structured block saying, in effect, "I'd like to call calculate with expression: '127.50 * 1.18'." That's still just text generation, constrained to a particular shape. Your code reads that block, actually runs calculate("127.50 * 1.18") for real, and sends the return value back to the model as a new message. The model reads that result on its next turn and decides what to do next — answer, or request another tool. Anthropic's response shape makes this explicit: stop_reason: "tool_use" marks a turn as a request rather than an answer, and the result has to travel back as its own tool_result message, not get silently appended anywhere.
This is the entire mechanism. There's no hidden channel, no direct invocation, and critically: no guarantee. The model's tool request is just generated text that happens to be shaped like a function call — it can name a tool that doesn't exist, or supply arguments that don't match the schema, and nothing stops it from doing so except your code checking.
TOOLS = [
{
"name": "calculate",
"description": (
"Evaluate a numeric arithmetic expression using +, -, *, /, ** and parentheses. "
"Example: '127.50 * 1.18' to add an 18% tip to 127.50. "
"Numbers only -- no currency symbols, no words."
),
"input_schema": {
"type": "object",
"properties": {"expression": {"type": "string", "description": "e.g. '150.45 / 4'"}},
"required": ["expression"],
},
},
{
"name": "convert_currency",
"description": (
"Convert an amount from one currency to another using this app's fixed exchange "
"rate table (illustrative rates, not live data). "
"Example: convert_currency(150.45, 'EUR', 'USD')."
),
"input_schema": {
"type": "object",
"properties": {
"amount": {"type": "number"},
"from_currency": {"type": "string", "description": "3-letter code, e.g. 'EUR'"},
"to_currency": {"type": "string", "description": "3-letter code, e.g. 'USD'"},
},
"required": ["amount", "from_currency", "to_currency"],
},
},
]
def run_agent_loop(question: str, client, model: str, tools: list, tool_impl: dict, max_iterations: int = 6) -> dict:
"""The real loop. Each iteration: send the conversation so far, and if the
model asked for a tool (stop_reason == "tool_use"), actually run it and
send the result back as a new message -- the model never touches the
tool directly, this loop is the only thing that does."""
import json
messages = [{"role": "user", "content": question}]
trace = []
for step in range(1, max_iterations + 1):
response = client.messages.create(model=model, max_tokens=1024, tools=tools, messages=messages)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
final_text = "".join(b.text for b in response.content if b.type == "text")
return {"outcome": "answered", "answer": final_text, "trace": trace, "steps": step}
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
fn = tool_impl[block.name]
result = fn(**block.input)
trace.append({"step": step, "tool": block.name, "arguments": block.input, "result": result})
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)})
messages.append({"role": "user", "content": tool_results})
# Hard budget, hit: return honestly rather than looping forever.
return {"outcome": "budget_exhausted", "answer": "(ran out of steps)", "trace": trace, "steps": max_iterations}
Design principles: the tool schema is a prompt¶
Two real tools back this page's demo. The first is a genuinely safe calculator — not eval() on whatever text the model produces, but an AST walk that only permits numeric literals and + - * / **, so a malformed or adversarial expression can't do anything beyond arithmetic:
import ast
import operator
_SAFE_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.Pow: operator.pow, ast.USub: operator.neg,
}
def _safe_eval(node):
"""Walk a parsed expression, allowing only numeric literals and +-*/**."""
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _SAFE_OPS:
return _SAFE_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _SAFE_OPS:
return _SAFE_OPS[type(node.op)](_safe_eval(node.operand))
raise ValueError(f"Disallowed expression: {ast.dump(node)}")
def calculate(expression: str) -> dict:
"""A real calculator. Not eval() on model output -- an AST walk that only
allows numbers and +, -, *, /, ** so a malformed or malicious expression
can't do anything beyond arithmetic."""
try:
tree = ast.parse(expression, mode="eval")
return {"result": _safe_eval(tree)}
except Exception as e:
return {"error": f"Could not evaluate '{expression}': {e}"}
The second is a currency converter over a fixed, illustrative rate table:
# Fixed, illustrative exchange rates -- not live data, and the tool
# description says so, so the model doesn't treat this as authoritative.
_RATES = {
("EUR", "USD"): 1.08, ("USD", "EUR"): 1 / 1.08,
("GBP", "USD"): 1.27, ("USD", "GBP"): 1 / 1.27,
("USD", "USD"): 1.0,
}
def convert_currency(amount: float, from_currency: str, to_currency: str) -> dict:
key = (from_currency.upper(), to_currency.upper())
if key not in _RATES:
return {"error": f"No exchange rate available for {from_currency} -> {to_currency}"}
return {"result": round(amount * _RATES[key], 4)}
Notice both tool descriptions carry a worked example ('127.50 * 1.18' to add an 18% tip, convert_currency(150.45, 'EUR', 'USD')), not just a type signature. That's deliberate: a schema that only says expression: string leaves the model guessing at format; a schema with a concrete example in the description measurably improves how reliably the model extracts the right arguments. The description is doing real work — it's the closest thing this system has to a spec the model actually reads.
The other design choice worth naming: calculate's except branch doesn't crash the loop or swallow the problem silently — it returns a structured {"error": "..."} dict, which becomes a normal tool_result the model can read and act on. A raw stack trace or a silent failure both leave the model unable to tell what went wrong; a structured error message lets it decide whether to retry with a fixed expression, try a different tool, or give up and say so.
Stop conditions and budgets¶
The loop in this recipe is capped at a fixed number of iterations, and if the model still hasn't finished, it returns an honest "ran out of steps" result instead of looping forever:
def run_agent_loop(question: str, client, model: str, tools: list, tool_impl: dict, max_iterations: int = 6) -> dict:
"""The real loop. Each iteration: send the conversation so far, and if the
model asked for a tool (stop_reason == "tool_use"), actually run it and
send the result back as a new message -- the model never touches the
tool directly, this loop is the only thing that does."""
import json
messages = [{"role": "user", "content": question}]
trace = []
for step in range(1, max_iterations + 1):
response = client.messages.create(model=model, max_tokens=1024, tools=tools, messages=messages)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
final_text = "".join(b.text for b in response.content if b.type == "text")
return {"outcome": "answered", "answer": final_text, "trace": trace, "steps": step}
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
fn = tool_impl[block.name]
result = fn(**block.input)
trace.append({"step": step, "tool": block.name, "arguments": block.input, "result": result})
tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": json.dumps(result)})
messages.append({"role": "user", "content": tool_results})
# Hard budget, hit: return honestly rather than looping forever.
return {"outcome": "budget_exhausted", "answer": "(ran out of steps)", "trace": trace, "steps": max_iterations}
Worth being precise about what this cap does and doesn't cover: it bounds how many round-trips the loop can make, which is what keeps a stuck agent from silently burning an unbounded number of API calls. It says nothing about how long any single tool call is allowed to run — a tool that hangs for 90 seconds isn't touched by an iteration cap at all. Production loops need both: a step budget on the loop, and an independent timeout on each tool execution.
A real run — one honest surprise, full input and output
Input, sent identically to both versions: "I'm splitting a €127.50 dinner bill among 4 friends, and we want to add an 18% tip on top before splitting. What does each person owe, in US dollars?" Ground truth, computed independently in plain Python, not by the model: $40.62.
Output, no tools — in full, not trimmed:
Step 1: Calculate the tip — EUR127.50 × 0.18 = EUR22.95
Step 2: Total with tip — EUR127.50 + EUR22.95 = EUR150.45
Step 3: Split among 4 people — EUR150.45 ÷ 4 = EUR37.61 per person
Step 4: Convert to USD — EUR37.61 × current exchange rate
Each person owes approximately $40.80–$41.50 USD (depending on the current EUR/USD exchange rate, which fluctuates daily)
Note: I've used a typical exchange rate around 1.08–1.10 USD per EUR. Check a current converter for the exact amount with today's rate.
Every arithmetic step there is correct. The only thing it got wrong was guessing at an exchange rate instead of having a real one.
Output, with tools — the exact JSON this recipe's loop returns:
{
"outcome": "answered",
"answer": "Each person owes approximately $40.62 USD.\n\nHere's the breakdown:\n- Original bill: EUR 127.50\n- With 18% tip: EUR 150.45\n- Per person (÷4): EUR 37.61\n- Converted to USD: **$40.62 per person**",
"trace": [
{"step": 1, "tool": "calculate", "arguments": {"expression": "127.50 * 1.18"}, "result": {"result": 150.45}},
{"step": 1, "tool": "calculate", "arguments": {"expression": "127.50 * 1.18 / 4"}, "result": {"result": 37.6125}},
{"step": 2, "tool": "convert_currency", "arguments": {"amount": 37.6125, "from_currency": "EUR", "to_currency": "USD"}, "result": {"result": 40.6215}}
],
"steps": 3
}
Reading the trace: both calculate calls landed in step: 1 — the model issued them in parallel, in the same turn, without being asked to. The second call is mildly redundant (it recomputes 127.50 * 1.18 again as part of 127.50 * 1.18 / 4, instead of reusing the first call's 150.45) — correct, but not the shortest path available. step: 2 then converts the correct EUR-per-person figure to USD, landing on the ground truth exactly.
The genuinely useful finding here isn't "the baseline can't do math" — the trace above shows it could. It's that the gap was a live-data problem, not an arithmetic one, and no amount of arithmetic skill closes a live-data gap without a real source for that data.
Interview angle¶
Weak answer to "walk me through exactly what happens between the model deciding to call a tool and the tool's result reaching the model again": "the model calls the API." This skips the entire mechanism and reads as not having thought about it.
Strong answer: name the two-message round trip explicitly — the model generates a structured tool_use request (still just text generation, constrained to a schema), your orchestration code parses and executes it for real, and the result comes back as a distinct tool_result message the model reads on its next turn. Mention that nothing guarantees the request is valid until your code checks.
Follow-up to expect: a version of "your agent has 40 tools registered and keeps picking the wrong one or inventing arguments — what do you do?" A weak instinct is to collapse many tools into one mega-tool with a free-text instruction field. The stronger answer notices that this doesn't remove the selection problem, it just moves it from the model (choosing among well-described, schema-validated tools) into your own code (a keyword matcher choosing among internal actions from unstructured text) — trading a visible, debuggable failure mode for a hidden one, and losing schema validation in the process.
Build it yourself — 30 minutes¶
- Pick two small, deterministic tools whose outputs you can check independently (a calculator and a unit or currency converter both work well — you want a task where "did the model get this right" isn't a matter of taste).
- Write the raw loop: send the conversation, check
stop_reason, execute any requested tool for real, append the result as a new message, repeat with a hard iteration cap. - Ask the same question with no tools at all first, and write down what the model says.
- Run it again with tools, and compare against an answer you computed independently, by hand or in a separate script — not by asking the model to check its own work.
- Look at why the two answers differ, not just whether they differ. It's rarely as simple as "no tools means wrong."
Scenario Check¶
Sources & further reading¶
- Anthropic, Tool use with Claude — the current tool-use API shape this recipe's code follows directly.
- Anthropic, "Writing effective tools for agents — with agents" (2025-09-11) — tool description design, namespacing, and returning model-readable context.
- Anthropic, "Building effective agents" (2024-12-19) — Appendix 2, "Prompt engineering your tools."