DEV Community

Cover image for Your First Tool-Calling Agent With No Framework, Just the Bare SDK
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your First Tool-Calling Agent With No Framework, Just the Bare SDK


You reach for a framework on day one. LangGraph, the OpenAI Agents SDK, whatever the top result was that week. The demo works. Three weeks later the agent loops forever in production on one customer's input, and you open the stack trace and realize you have no idea what the framework is actually doing between the model call and the tool call. You never saw that part. It was abstracted away before you understood it.

So write it once without the abstraction. A tool-calling agent is a while loop around one API call, a dictionary that maps tool names to functions, and three conditions that decide when to stop. That is the whole thing. Everything a framework adds sits on top of this. If you can write this loop, you can debug any framework built on it.

An agent is a loop around one call

Here is the shape before any code. You send the model a message and a list of tools. The model either answers, or it asks to run a tool. If it asks, you run the tool, hand back the result, and send the whole conversation again. Repeat until the model stops asking.

The API is stateless. The server keeps nothing between calls. Every turn you resend the entire conversation, and the model picks up where it left off because the history tells it what already happened. That single fact explains most of what you will later learn about memory and cost.

Set up first. These versions are what the code below was written against.

mkdir first-agent && cd first-agent
python3 -m venv .venv
source .venv/bin/activate
pip install "anthropic==0.94.1"
export ANTHROPIC_API_KEY="sk-ant-..."
Enter fullscreen mode Exit fullscreen mode

Define the tools: two halves

A tool is two things that must stay in sync. A Python function that does the work, and a JSON schema that tells the model the function exists. The model never sees your code. It sees the schema, and it decides whether to call the tool based on the description you wrote.

Start with the functions. Give the agent a calculator and a date lookup.

# tools.py
import ast
import datetime
import operator as op

_OPS = {
    ast.Add: op.add, ast.Sub: op.sub,
    ast.Mult: op.mul, ast.Div: op.truediv,
    ast.Pow: op.pow, ast.USub: op.neg,
}


def _eval(node):
    if isinstance(node, ast.Constant):
        return node.value
    if isinstance(node, ast.BinOp):
        return _OPS[type(node.op)](
            _eval(node.left), _eval(node.right)
        )
    if isinstance(node, ast.UnaryOp):
        return _OPS[type(node.op)](_eval(node.operand))
    raise ValueError("unsupported expression")
Enter fullscreen mode Exit fullscreen mode

That walker is the tool's whole safety story. The two public functions wrap it and register themselves under the names the model will call:

# tools.py (continued)
def calculator(expression: str) -> str:
    tree = ast.parse(expression, mode="eval")
    return str(_eval(tree.body))


def today() -> str:
    return datetime.date.today().isoformat()


TOOLS_IMPL = {
    "calculator": calculator,
    "today": today,
}
Enter fullscreen mode Exit fullscreen mode

Notice the calculator walks the AST instead of calling eval. The moment a tool is a Python function, a hostile input becomes code execution. eval("__import__('os').system('rm -rf /')") runs. Treat every tool input as untrusted, because the model will eventually be steered into sending something you did not expect.

Every tool returns a string. The API's tool_result block wants text, so serialize at the boundary and the model reads it back without trouble.

Now the schemas. The description is the part that decides whether the model ever calls your tool.

# schemas.py
TOOLS = [
    {
        "name": "calculator",
        "description": (
            "Evaluate an arithmetic expression. "
            "Use this for ANY math, including "
            "simple sums. Do not compute in your head."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "e.g. '2 * (3 + 4)'",
                }
            },
            "required": ["expression"],
        },
    },
    {
        "name": "today",
        "description": (
            "Return today's date in ISO-8601 format. "
            "Use it whenever the question involves "
            "'today', 'now', or a relative date."
        ),
        "input_schema": {
            "type": "object",
            "properties": {},
        },
    },
]
Enter fullscreen mode Exit fullscreen mode

Write the description the way you would brief a new hire. Say what the tool does, when to reach for it, and what it returns. A vague description gets ignored. An explicit "use this when" clause gets the tool picked.

The loop

Two files support one loop. This is the agent.

# agent.py
import anthropic

from schemas import TOOLS
from tools import TOOLS_IMPL

client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
MAX_STEPS = 8


def run(prompt: str) -> str:
    messages = [{"role": "user", "content": prompt}]

    for step in range(MAX_STEPS):
        resp = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            tools=TOOLS,
            messages=messages,
        )
        messages.append(
            {"role": "assistant", "content": resp.content}
        )

        if resp.stop_reason == "end_turn":
            return "".join(
                b.text for b in resp.content
                if b.type == "text"
            )
Enter fullscreen mode Exit fullscreen mode

That is the top of the loop: call the model, record what it said, and return early when it stops asking for tools. The rest of the loop dispatches the tools it did ask for and feeds the results back:

# agent.py (continued, inside the for loop)
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            impl = TOOLS_IMPL.get(block.name)
            try:
                out = impl(**block.input)
            except Exception as e:
                out = f"ERROR: {type(e).__name__}: {e}"
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": out,
            })

        messages.append(
            {"role": "user", "content": results}
        )

    raise RuntimeError("agent hit the step limit")


if __name__ == "__main__":
    print(run(
        "What is 17 * 23 plus 31, and what "
        "is today's date?"
    ))
Enter fullscreen mode Exit fullscreen mode

Run it and read what happens. The model returns content blocks. Some are text, some are tool_use. When stop_reason is tool_use, the model wants a tool run before it continues. You run every tool_use block, package each result with its matching tool_use_id, and send them back in one user message. The model reads the results and either answers or asks for more.

Feeding results back is where people slip

The one rule that breaks most first attempts: for every tool_use block in the assistant turn, there must be exactly one tool_result block with the same tool_use_id in the next user turn. One in, one out, same ID.

The model can emit two tool_use blocks in a single turn when it decides the calls are independent. The loop above handles that because it iterates all of them and returns all the results together. Grab only the first block and the API rejects your next call with an error about missing tool_result blocks. So iterate the whole list, always.

The messages list is the entire memory of the agent. You append the assistant's raw resp.content and then a user turn with the results. Drop a turn and the model forgets it happened. Edit a turn and the model believes the edit. That property is what makes an agent debuggable, and it is why memory becomes a separate problem later.

The three ways it stops

An agent that cannot stop is a bug generator. There are exactly three exits, and you need all three.

  1. stop_reason == "end_turn". The model decided it is done. This is the only exit that returns a real answer.
  2. The step limit. MAX_STEPS caps how many times the loop runs. If a tool keeps returning something the model cannot make progress on, the model will retry, and without the cap it retries forever. Eight is a reasonable start for interactive agents.
  3. The token limit. Each call caps output at max_tokens. If the model hits that cap mid-tool-call, stop_reason comes back as max_tokens and the tool call is truncated and unparseable. In production you either raise the cap or handle that case explicitly.

Anything else is a bug you have not met yet.

Errors become strings, not crashes

Look at the try/except around the tool call. If a tool raises, the loop turns the exception into a string and hands it back as the result. The model sees ERROR: KeyError: 'expression', understands it called the tool wrong, and corrects itself on the next step. Let the exception propagate instead and the loop dies, the conversation is lost, and the user gets a 500. Catching and stringifying is the difference between an agent that self-corrects and one that falls over.

That is a working tool-calling agent in under eighty lines. It does everything a framework does on the hot path: call the model, dispatch tools, handle parallel calls, recover from errors, and stop cleanly. What it is missing is everything around that path: memory across sessions, tracing, evals, cost caps, retries. Now that you have seen the loop, those additions make sense as choices rather than magic.


Once the loop is this clear, the next questions are about what flows through it: how to trace every tool call so you can see why the agent looped, how to evaluate whether it picked the right tool, and how to cap cost before it surprises you. Agents in Production is the build-and-ship side of that; Observability for LLM Applications is the tracing, evals, and cost side. Together they are The AI Engineer's Library, and both start from the loop you just wrote.

The AI Engineer's Library — Observability for LLM Applications and Agents in Production, side by side

Top comments (0)