Context and Tools
Agents and Tool Use

Build a Minimal Tool-Using Agent in Python

Build a Minimal Tool-Using Agent in Python
Learn how to build a minimal tool-using agent in Python using the Anthropic Messages API without heavy frameworks. This tutorial walks through defining a single tool, implementing a manual agent loop with hard iteration limits, handling tool errors gracefully, and tracing every decision to prevent runaway loops and unexpected API costs.

The first tool-using agent I shipped looked impressive in a demo and then quietly called the same function seventeen times in a row. The model had decided the tool result was incomplete, so it kept asking for the same data. There was no upper bound on the loop, no logging of intermediate steps, and no human approval gate. By the time I noticed the runaway requests, the usage dashboard had already recorded a bill I did not enjoy explaining.

That episode is why this post exists. We are going to build the smallest useful tool-using agent that actually works, then deliberately examine the places it can still break. No frameworks. No automatic tool runners. Just the Messages API, a single tool, and a loop you can read in one sitting.

The method remains the same:

Build the smallest useful version, then test what breaks.

What You Will Have Working by the End

  • A clear definition of one tool (name, description, input schema)

  • A manual agent loop that detects tool_use, executes the tool, and returns a tool_result

  • A hard iteration limit so the loop cannot run forever

  • Logging of every turn so you can see exactly what happened

  • Three realistic failure modes and how to notice them early

This is the foundation every later agent post on this site will assume you already understand.

The Core Idea in One Paragraph

A tool-using agent is not magic. It is a conversation in which the model is allowed to pause and request an external action. When the model returns a content block of type tool_use, your code executes the corresponding function and sends the result back as a tool_result block. The model then continues. The loop ends when the model finally returns a normal text reply (or when you hit a safety limit you set yourself).

Everything else—routing, memory, multi-tool planning, human approval—is built on top of this basic cycle.

Step 1: Define a Single, Boring Tool

We will give the model one tool: a function that adds two integers. It is deliberately simple so the mechanics stay visible.

A code editor displaying a Python dictionary defining a tool schema with inputs and descriptions.

Python

TOOLS = [
    {
        "name": "add_numbers",
        "description": "Add two integers and return their sum. Use this whenever the user asks for the sum of two numbers.",
        "input_schema": {
            "type": "object",
            "properties": {
                "a": {
                    "type": "integer",
                    "description": "The first number"
                },
                "b": {
                    "type": "integer",
                    "description": "The second number"
                }
            },
            "required": ["a", "b"]
        }
    }
]

The schema follows JSON Schema conventions. The model sees the name, the description, and the expected inputs. It does not see the Python implementation; that lives only in your code.

Step 2: The Tool Implementation

Python

def add_numbers(a: int, b: int) -> str:
    """Actual implementation the agent will call."""
    result = a + b
    return str(result)

Returning a string is convenient because the tool_result content field expects text (or a list of content blocks). In real systems you often return JSON strings so the model can parse structured data.

Step 3: The Minimal Agent Loop

Here is the complete working script. It assumes you already have authentication set up from the earlier Foundations posts.

Python

import os
from dotenv import load_dotenv
from anthropic import Anthropic

load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

TOOLS = [
    {
        "name": "add_numbers",
        "description": "Add two integers and return their sum. Use this whenever the user asks for the sum of two numbers.",
        "input_schema": {
            "type": "object",
            "properties": {
                "a": {"type": "integer", "description": "The first number"},
                "b": {"type": "integer", "description": "The second number"}
            },
            "required": ["a", "b"]
        }
    }
]

def add_numbers(a: int, b: int) -> str:
    return str(a + b)

TOOL_MAP = {
    "add_numbers": add_numbers
}

def run_agent(user_message: str, max_iterations: int = 5) -> str:
    messages = [
        {"role": "user", "content": user_message}
    ]

    for iteration in range(1, max_iterations + 1):
        print(f"\n=== Iteration {iteration} ===")

        response = client.messages.create(
            model="claude-sonnet-5",  # verify current ID in official docs
            max_tokens=512,
            tools=TOOLS,
            messages=messages
        )

        # Always append the full assistant response (includes any tool_use blocks)
        messages.append({
            "role": "assistant",
            "content": response.content
        })

        print(f"Stop reason: {response.stop_reason}")

        # If the model is done, extract the final text
        if response.stop_reason != "tool_use":
            final_text = "".join(
                block.text for block in response.content if block.type == "text"
            )
            return final_text

        # Collect tool results for every tool_use block
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                tool_name = block.name
                tool_input = block.input
                tool_use_id = block.id

                print(f"Tool requested: {tool_name}")
                print(f"Input: {tool_input}")

                if tool_name not in TOOL_MAP:
                    result = f"Error: unknown tool {tool_name}"
                else:
                    try:
                        result = TOOL_MAP[tool_name](**tool_input)
                    except Exception as e:
                        result = f"Error executing tool: {e}"

                print(f"Tool result: {result}")

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                })

        # Send the tool results back as a user message
        messages.append({
            "role": "user",
            "content": tool_results
        })

    return "Agent stopped: max iterations reached."


if __name__ == "__main__":
    answer = run_agent("What is 1984 + 2026? Use the add_numbers tool.")
    print("\n=== Final Answer ===")
    print(answer)

Run it. You should see one iteration that requests the tool, a second iteration that produces the final answer, and a clear printout of every step.

Why This Shape Matters

Several details are easy to get wrong the first time:

  • You must append the entire assistant content (including thinking or text blocks if present) before you send tool results. Rebuilding the assistant message from only the tool_use blocks can produce API errors on some model versions.

  • Tool results are sent as a user message whose content is a list of tool_result blocks.

  • Each tool_result must carry the matching tool_use_id so the model can associate the result with the request.

  • A hard max_iterations limit is not optional. Without it a confused model can burn tokens indefinitely.

I still keep the first version of this loop in my failed-experiments repository. It had no iteration limit. That was the version that called the same tool seventeen times.

Three Ways This Agent Breaks (and How to Notice)

1. The Model Requests a Tool That Does Not Exist

If the model invents a tool name, TOOL_MAP will miss it. In the code above we return an explicit error string. The model then has a chance to recover or admit it cannot proceed. Logging the requested name makes the problem obvious.

2. The Tool Raises an Exception

Network timeouts, invalid arguments, or permission errors should never crash the agent loop. Catch the exception, turn it into a clear string, and send that string back as the tool result. The model can then decide what to do next (retry, ask the user, or give up).

3. The Loop Never Terminates

Even with a correct tool, a poorly worded system prompt or an ambiguous user request can cause the model to keep calling tools. The max_iterations guard is the last line of defense. When it fires, log the full message history so you can see why the model never produced a final text answer.

These three cases cover the majority of the quiet failures I have seen in early agent prototypes.

A Few Practical Habits

  • Print the stop reason and the tool name on every iteration while you are developing.

  • Keep the tool list short until the loop is solid.

  • Prefer tools that return short, deterministic results while you are learning the mechanics.

  • When something feels wrong, dump the entire messages list. The history is usually more informative than the final text.

What This Agent Does Not Do Yet

It does not:

  • Support multiple tools with complex dependencies

  • Ask a human for approval before executing side-effecting actions

  • Persist memory across separate runs

  • Stream partial results

  • Handle image or document inputs

  • Enforce cost or rate budgets beyond the iteration limit

Each of those capabilities will appear in later posts. The goal today is a loop you can understand completely and trust not to run forever.

Verification Checklist

  1. Run the script with the addition request and confirm the tool is called once and the final answer is correct.

  2. Change the user message to something that does not need a tool (“What is the capital of Idaho?”) and confirm the model answers directly without calling the tool.

  3. Temporarily break the tool implementation so it raises an exception; confirm the error string appears in the next model turn.

  4. Set max_iterations=1 and confirm the agent stops cleanly with the safety message.

  5. Inspect the printed iteration log and confirm you can follow every decision.

If all five steps behave as expected, you have a working minimal agent.

Update Note

The Messages API tool-use shape (tools parameter, tool_use / tool_result content blocks, stop_reason) has been stable for a long time. Model identifiers and exact error messages change. Always verify the current recommended model ID and any beta tool helpers in the official Anthropic documentation before you copy examples into longer-lived code. Newer SDK versions also offer higher-level tool runners; those are convenient once you understand the underlying loop.

A computer screen displaying terminal output for agent iterations, tool requests, and execution results.

Looking Ahead

The next post examines the agent loop itself in more detail: decide, call, observe, respond. After that we add a human approval step so the agent cannot take irreversible actions without a person saying yes. Those two pieces turn a simple tool caller into something you can begin to trust with real work.

Keep the script. When a larger agent later misbehaves, you will already know how to reduce the problem to a single tool and a visible loop.

Build the smallest useful version, then test what breaks.

— Caleb Fletcher

Boise, Idaho

Updated · 2026-10-17 16:28
Feedback

No feedback yet — submit the first.

Submit feedback
© 2026 Context & Tools. All rights reserved. data-driven, published weekly ♥