---
title: "Build a tool-calling agent loop from scratch"
description: "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."
url: https://articles.sythra.ai/articles/tool-calling-agent-loop-from-scratch
slug: tool-calling-agent-loop-from-scratch
author: "vaibhavkothari"
author_url: https://articles.sythra.ai/writers/vaibhavkothari
date_published: 2026-11-12T21:11:00.153Z
date_modified: 2026-11-12T21:11:00.153Z
topics: ["Ai", "Agents", "Python", "Llm"]
reading_time_minutes: 8
publisher: "Sythra"
publisher_url: https://sythra.ai
access: free
language: en
---

# 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.

Source: https://articles.sythra.ai/articles/tool-calling-agent-loop-from-scratch · Author: vaibhavkothari · Published: 2026-11-12 · Reading time: 8 min · Topics: Ai, Agents, Python, Llm

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 calling 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:

```python
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](https://articles.sythra.ai/articles/mcp-and-tool-calling-llm) for how to write them well.

## Step 2 — Implement the tools safely

```python
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

```python
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:

```python
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

```python
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](https://articles.sythra.ai/articles/llm-token-counting-context-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.

```python
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](https://articles.sythra.ai/articles/prompt-injection-agent-security).

## Step 5 — Observe everything

You cannot debug an agent you cannot replay. Log each step as structured data:

```python
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](https://articles.sythra.ai/articles/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

- [Prompt injection: how agents get hijacked](https://articles.sythra.ai/articles/prompt-injection-agent-security)
- [Agentic AI architecture](https://articles.sythra.ai/articles/agentic-ai-architecture)
- [MCP and tool calling for LLMs](https://articles.sythra.ai/articles/mcp-and-tool-calling-llm)

## Glossary (terms defined in this article)
- **Tool calling** (medium) — The model returns a structured request to run a named function with arguments; your code runs it and returns the result
