Article
Build a tool-calling agent loop from scratch
An agent is a loop, not magic. Build one in plain Python with the Anthropic API: define tools, run them, feed results back, then add step limits and logging.
On this page0%
- The loop in one picture
- Step 1 — Define the tools
- Step 2 — Implement the tools safely
- Step 3 — The loop
- The details that make it work
- Step 4 — Guard the loop
- A step limit
- A repeat detector
- A token or cost budget
- Output size limits
- Permission tiers
- Step 5 — Observe everything
- Common failure modes
- When to use a framework
- FAQ
- What is an agent loop?
- Does the model actually run the tools?
- How do I stop an agent from looping forever?
- Why should I never use eval on model output?
- Next reading on Sythra Articles
Frameworks make agents look complicated. Underneath, nearly all of them are the same thirty-line loop: ask the model what to do, run the tool it picks, show it the result, repeat until it answers. Write that loop yourself once and every framework becomes readable. This guide builds a working agent with two tools, then hardens it with the guards a real one needs.
The loop in one picture
┌──────────────────────────────────────────┐
▼ │
user task ─► model ─► wants a tool? ── yes ─► run tool ─┘
│ (result goes back to the model)
no
▼
final answer
Tool callingThe model returns a structured request to run a named function with arguments; your code runs it and returns the result means the model never executes anything. It only asks. Your code decides whether and how to run the request. That boundary is where all the safety lives.
Step 1 — Define the tools
Each tool has a name, a description the model reads to decide when to use it, and a JSON Schema for its inputs:
TOOLS = [
{
"name": "calculator",
"description": "Evaluate a basic arithmetic expression using + - * / and parentheses. "
"Use for any calculation instead of doing arithmetic yourself.",
"input_schema": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "e.g. '(12.5 * 4) / 3'"},
},
"required": ["expression"],
},
},
{
"name": "read_note",
"description": "Read a saved note by its name. Returns the note text or an error.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Note name, e.g. 'budget'"},
},
"required": ["name"],
},
},
]
The description is the interface. Vague descriptions produce wrong tool choices; see MCP and tool design for how to write them well.
Step 2 — Implement the tools safely
import ast
import operator
NOTES = {
"budget": "Rent 1200, food 450, transport 90, savings 300.",
"todo": "Write article, review pull request, book dentist.",
}
_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.operand))
raise ValueError("unsupported expression")
def calculator(expression: str) -> str:
tree = ast.parse(expression, mode="eval")
return str(_eval(tree.body))
def read_note(name: str) -> str:
if name not in NOTES:
raise KeyError(f"no note named '{name}'. Available: {', '.join(NOTES)}")
return NOTES[name]
REGISTRY = {"calculator": calculator, "read_note": read_note}
Notice the calculator parses the expression into a tree and only allows arithmetic nodes. Never pass model-written text to eval() — that turns the model, and anyone who can influence it, into a remote code runner.
Step 3 — The loop
import json
import os
import anthropic
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
MODEL = "claude-sonnet-5-5"
SYSTEM = (
"You are a careful assistant with access to tools. "
"Use tools for calculations and for reading notes. "
"If a tool returns an error, read it and try a corrected call or explain the problem."
)
def run_agent(task: str, max_steps: int = 8) -> str:
messages = [{"role": "user", "content": task}]
for step in range(1, max_steps + 1):
response = client.messages.create(
model=MODEL,
max_tokens=1000,
system=SYSTEM,
tools=TOOLS,
messages=messages,
)
# keep the assistant turn exactly as returned
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if b.type == "text")
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"[step {step}] {block.name}({json.dumps(block.input)})")
output, is_error = execute(block.name, block.input)
print(f" -> {output[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
"is_error": is_error,
})
messages.append({"role": "user", "content": results})
return "Stopped: step limit reached before the task finished."
def execute(name: str, args: dict):
fn = REGISTRY.get(name)
if fn is None:
return f"Unknown tool '{name}'", True
try:
return str(fn(**args))[:4000], False
except Exception as error:
return f"{type(error).__name__}: {error}", True
Try it:
print(run_agent("Read my budget note and tell me what percentage of the total goes to rent."))
A typical trace: the model calls read_note("budget"), receives the text, calls calculator("1200 / (1200 + 450 + 90 + 300) * 100"), then answers in prose. You did not script that sequence; the model chose it from the tool descriptions.
The details that make it work
response.contentis appended as-is. It contains thetool_useblocks that the nexttool_resultmessages must refer to.tool_use_idlinks each result to its request. The model may request several tools in one turn, so every result needs its own ID.- Results go in a
usermessage. The conversation alternates user and assistant turns; tool results are the "user" side. is_error: Truetells the model the call failed, so it can recover instead of treating an error string as data.stop_reason == "tool_use"means "I need a tool result before continuing". Anything else (end_turn,max_tokens) means the turn is over.
Step 4 — Guard the loop
The toy version above can loop forever, burn money, or do something you did not intend. A production loop adds:
A step limit
Already present: max_steps. Agents get stuck repeating the same failing call. Without a cap they run until your bill or the context window stops them.
A repeat detector
seen = set()
def is_repeat(name, args):
key = (name, json.dumps(args, sort_keys=True))
if key in seen:
return True
seen.add(key)
return False
If the same call with the same arguments appears twice, return an error like "You already called this with these arguments; try something different or answer." Repeated identical calls almost always mean the model is stuck.
A token or cost budget
Track response.usage.input_tokens + response.usage.output_tokens across steps and stop at a limit. The history is resent every step, so cost grows faster than linearly; see token counting and cost.
Output size limits
Truncate tool results ([:4000] above). One huge result can fill the context and crowd out everything else.
Permission tiers
Not all tools are equal. Reading a note is harmless; sending an email is not.
READ_ONLY = {"calculator", "read_note"}
NEEDS_APPROVAL = {"send_email", "delete_file"}
def execute_guarded(name, args):
if name in NEEDS_APPROVAL:
answer = input(f"Allow {name} {args}? [y/N] ").strip().lower()
if answer != "y":
return "The user declined this action.", True
return execute(name, args)
Put a human in the loop for anything irreversible or outward-facing. This matters most when the agent reads untrusted content — see prompt injection.
Step 5 — Observe everything
You cannot debug an agent you cannot replay. Log each step as structured data:
import time
def log_step(step, name, args, output, is_error, path="agent_log.jsonl"):
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps({
"t": time.time(), "step": step, "tool": name,
"args": args, "error": is_error, "output": output[:500],
}) + "\n")
When an agent misbehaves, the log answers "what did it see, and what did it decide?" without guessing.
Common failure modes
| Failure | Cause | Remedy |
|---|---|---|
| Never calls the tool | Description vague, or the model thinks it can answer directly | Sharpen the description; say when to use it |
| Calls the wrong tool | Overlapping descriptions | Merge or differentiate tools; fewer is better |
| Invents arguments | Required info missing from the task | Tell it to ask the user when information is missing |
| Loops on errors | Error message unhelpful | Return specific, actionable errors |
| Ignores a result | Result too long or noisy | Return only what is needed |
| Takes 15 steps for a simple task | Tools too low-level | Provide a higher-level tool |
When to use a framework
Once you need persistence, parallel tool calls, streaming, multi-agent handoffs or tracing dashboards, a framework or the provider's agent SDK earns its place. You will then recognise exactly what it is doing for you. For the bigger architectural picture — planning, memory and multi-agent patterns — read agentic AI architecture.
FAQ
What is an agent loop?
An agent loop repeatedly sends the conversation to a language model, runs any tool the model requests, appends the tool result, and calls the model again until it replies without requesting a tool.
Does the model actually run the tools?
No. The model returns a structured request naming a tool and its arguments. Your code runs the tool and returns the result, so your code controls what is allowed.
How do I stop an agent from looping forever?
Set a maximum number of steps, detect repeated identical calls, track token or cost budgets, and return clear errors so the model can change approach.
Why should I never use eval on model output?
eval executes arbitrary Python. Anyone who can influence the model's input could make it run harmful code on your machine. Parse and validate inputs with a restricted parser instead.