Sythra

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.

vaibhavkothari· Nov 12, 2026· 8 min readAdvanced
ShareXLinkedIn
Build a tool-calling agent loop from scratch cover

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.content is appended as-is. It contains the tool_use blocks that the next tool_result messages must refer to.
  • tool_use_id links 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 user message. The conversation alternates user and assistant turns; tool results are the "user" side.
  • is_error: True tells 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

FailureCauseRemedy
Never calls the toolDescription vague, or the model thinks it can answer directlySharpen the description; say when to use it
Calls the wrong toolOverlapping descriptionsMerge or differentiate tools; fewer is better
Invents argumentsRequired info missing from the taskTell it to ask the user when information is missing
Loops on errorsError message unhelpfulReturn specific, actionable errors
Ignores a resultResult too long or noisyReturn only what is needed
Takes 15 steps for a simple taskTools too low-levelProvide 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.

Next reading on Sythra Articles

Keep reading

Related articles

Newsletter

Get new articles

Free essays on learning, Python, and ML — no account required. We’ll only email when there’s something worth reading.