Build your first agent in 100 lines.

A tool-use loop you can read in five minutes, with a sandbox, an approval prompt and a step limit. Run it free with a fake model, then add one API key.

Electronic components and a small circuit board laid out on a table
Photo by Robin Glauser on Unsplashdithered by Cyborb

To build an AI agent, you need three pieces: a model that can ask for tools, a few functions it is allowed to call, and a loop. The loop sends the conversation to the model, runs any tool it asks for, sends back the result, and repeats until the model says it is done.

With Anthropic’s Python SDK, that loop fits in about 100 lines, guardrails included. We ran every line below with anthropic 1.8.0 on Python 3.14. We had no API key, so we tested with a fake model that returns the SDK’s own message objects. One marked line switches to the real Claude.

The short version
  • An agent is a model, some tools and a loop: the model asks for a tool, your code runs it, and the result goes back.
  • A tool is a name, a description and a JSON schema. The description tells the model when to use it.
  • End the loop when the model stops asking for tools, and always cap the number of steps.
  • Guardrails live in your code: a sandbox folder, approval before any write, and errors sent back instead of crashes.
  • Test the loop with a fake model first. It costs nothing and can misbehave on command.

What a tool call actually is

In the API, the model’s reply contains a tool_use block, and your next message answers it with a tool_result block that carries the same ID. Here is one real pair from our test run:

The model asks
{"type": "tool_use", "id": "toolu_3", "name": "read_file", "input": {"path": "notes/launch.md"}}
Your code answers
{"type": "tool_result", "tool_use_id": "toolu_3", "content": "# Launch plan\n\nLaunch date: October 14\nOwner: Sam\n", "is_error": false}

That exchange, repeated until the job is done, is the whole idea. For the bigger picture, see what an AI agent is.

Set up the project

You need Python 3.10 or newer, which the SDK requires. Create a folder, a virtual environment and a tiny workspace for the agent to explore:

Terminal
mkdir first-agent && cd first-agent
python3 -m venv .venv && source .venv/bin/activate
pip install anthropic
mkdir -p workspace/notes
printf '# Launch plan\n\nLaunch date: October 14\nOwner: Sam\n' > workspace/notes/launch.md
printf '# To do\n\n- Book the venue\n' > workspace/notes/todo.md

The agent’s job: find the launch date in the notes and add it to the to-do list.

Build an AI agent loop in 100 lines

Save this as agent.py. It defines three tools, runs them safely, and loops:

agent.py
import sys
from pathlib import Path

import anthropic

MODEL = "claude-opus-5-5"
MAX_STEPS = 10  # guardrail: never loop forever
WORKSPACE = Path("workspace").resolve()  # guardrail: the only folder the agent can touch

TOOLS = [
    {
        "name": "list_files",
        "description": "List every file in the workspace. Call this first to see what exists.",
        "input_schema": {"type": "object", "properties": {}},
    },
    {
        "name": "read_file",
        "description": "Read a text file from the workspace. Call this when you need its contents.",
        "input_schema": {
            "type": "object",
            "properties": {"path": {"type": "string", "description": "Path inside the workspace"}},
            "required": ["path"],
        },
    },
    {
        "name": "append_line",
        "description": "Add one line to the end of a workspace file. The user must approve it.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path inside the workspace"},
                "line": {"type": "string", "description": "The line to add"},
            },
            "required": ["path", "line"],
        },
    },
]


def safe_path(relative: str) -> Path:
    path = (WORKSPACE / relative).resolve()
    if not path.is_relative_to(WORKSPACE):
        raise ValueError(f"{relative} is outside the workspace")
    return path


def run_tool(name: str, args: dict) -> str:
    if name == "list_files":
        files = sorted(p for p in WORKSPACE.rglob("*") if p.is_file())
        return "\n".join(str(p.relative_to(WORKSPACE)) for p in files)
    if name == "read_file":
        return safe_path(args["path"]).read_text()[:10_000]
    if name == "append_line":
        path = safe_path(args["path"])
        answer = input(f"Allow: add {args['line']!r} to {args['path']}? [y/N] ")
        if answer.strip().lower() != "y":
            return "The user declined."
        with path.open("a") as f:
            f.write(args["line"] + "\n")
        return "Line added."
    raise ValueError(f"Unknown tool: {name}")


def run_agent(client, task: str) -> None:
    messages = [{"role": "user", "content": task}]
    for step in range(1, MAX_STEPS + 1):
        response = client.messages.create(
            model=MODEL, max_tokens=16000, tools=TOOLS, messages=messages
        )
        messages.append({"role": "assistant", "content": response.content})
        for block in response.content:
            if block.type == "text" and block.text:
                print(f"[model] {block.text}")

        if response.stop_reason != "tool_use":  # end_turn, max_tokens, refusal...
            print(f"[stop] {response.stop_reason} after {step} steps")
            return

        results = []
        for block in response.content:
            if block.type == "tool_use":
                print(f"[tool] {block.name} {block.input}")
                try:
                    output, failed = run_tool(block.name, block.input), False
                except Exception as err:
                    output, failed = f"Error: {err}", True
                    print(f"[error] {err}")
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": output, "is_error": failed})
        messages.append({"role": "user", "content": results})

    print(f"[stop] hit the {MAX_STEPS}-step limit")


if __name__ == "__main__":
    if "--fake" in sys.argv:
        from fake_model import FakeClient
        client = FakeClient()
    else:
        client = anthropic.Anthropic()  # the one line that needs your API key
    run_agent(client, "When is the launch? Add the date to notes/todo.md.")
  1. Describe the tools

    Each tool has a name, a description and an input_schema in JSON Schema. Say when to use the tool, not only what it does, because the model picks tools by reading these descriptions.

  2. Call the model with everything so far

    client.messages.create gets the whole conversation and the tool list on every step. The API keeps no memory between calls, so the messages list is the agent’s memory.

  3. Keep the model’s reply whole

    Append response.content as it is, including the tool_use blocks. Each tool_result must point back to one of them by ID.

  4. Run the tools, then go around again

    Every requested tool runs through run_tool, and all results go back together in one message. Failures return as results with is_error set, so the model can read what went wrong and try another way.

Test it with a fake model first

A fake model makes the loop free to run, and it can misbehave on demand. Ours returns the SDK’s real Message objects and reads the results the loop sends back, so it also proves the data flows. Save this as fake_model.py:

fake_model.py
"""A scripted stand-in for Claude, so agent.py runs without an API key.

It returns the SDK's own Message objects, so agent.py cannot tell the
difference, and it reacts to the tool results the loop sends back.
"""
import re

from anthropic.types import Message, TextBlock, ToolUseBlock, Usage


def reply(*blocks, stop_reason):
    return Message(id="msg_fake", type="message", role="assistant", model="fake",
                   content=list(blocks), stop_reason=stop_reason,
                   usage=Usage(input_tokens=0, output_tokens=0))


def call(n, name, **args):
    return ToolUseBlock(type="tool_use", id=f"toolu_{n}", name=name, input=args)


class FakeMessages:
    def create(self, *, messages, **request):
        results = [block for m in messages if m["role"] == "user"
                   and isinstance(m["content"], list) for block in m["content"]]
        for n, result in enumerate(results, start=1):  # each result must answer its call
            assert result["tool_use_id"] == f"toolu_{n}", "tool_result does not match"

        step = len(results)
        if step == 0:
            return reply(TextBlock(type="text", text="I'll check which files exist."),
                         call(1, "list_files"), stop_reason="tool_use")
        if step == 1:  # misbehave on purpose, to show the sandbox at work
            return reply(call(2, "read_file", path="../secrets.txt"), stop_reason="tool_use")
        if step == 2:
            launch = next(p for p in results[0]["content"].splitlines() if "launch" in p)
            return reply(call(3, "read_file", path=launch), stop_reason="tool_use")
        date = re.search(r"Launch date: (.+)", results[2]["content"]).group(1)
        if step == 3:
            return reply(call(4, "append_line", path="notes/todo.md", line=f"- Launch: {date}"),
                         stop_reason="tool_use")
        done = results[3]["content"] == "Line added."
        text = f"The launch is on {date}. " + ("I added it to notes/todo.md." if done
                                                 else "You declined, so I changed nothing.")
        return reply(TextBlock(type="text", text=text), stop_reason="end_turn")


class FakeClient:
    def __init__(self):
        self.messages = FakeMessages()

Run python agent.py --fake. This is our terminal, answering y at the prompt:

Output
[model] I'll check which files exist.
[tool] list_files {}
[tool] read_file {'path': '../secrets.txt'}
[error] ../secrets.txt is outside the workspace
[tool] read_file {'path': 'notes/launch.md'}
[tool] append_line {'path': 'notes/todo.md', 'line': '- Launch: October 14'}
Allow: add '- Launch: October 14' to notes/todo.md? [y/N] y
[model] The launch is on October 14. I added it to notes/todo.md.
[stop] end_turn after 5 steps

The sandbox blocked the escape attempt, the model got the error and moved on, and the write waited for a human. Answering n left todo.md untouched. We also ran the loop through the real anthropic.Anthropic() client with only the network faked, and checked the requests it built: every result carried the right tool ID.

When should an AI agent stop?

Every reply carries a stop_reason. Our loop keeps going only on tool_use and stops on anything else, which is the safe default:

stop_reasonWhat it meansWhat the loop does
tool_useThe model wants tools runRuns them and loops
end_turnThe model finished its answerStops
max_tokensThe reply hit the length capStops, because the answer is cut off
refusalThe model declined the requestStops. The reply’s stop_details says why
Step limitOur own cap, MAX_STEPSStops after 10 rounds

We tested two of these with fakes built to fail. A model that never stopped was called exactly 10 times before [stop] hit the 10-step limit. A reply cut off by max_tokens ended the run after one step instead of acting on half an answer.

Guardrails that make it safe to run

The model decides what to try. Your code decides what actually happens, and that is where guardrails belong:

Agent guardrails0 of 6

Where to go next

  • Let the SDK write the loop. Once you understand it, the SDK’s Tool Runner (client.beta.messages.tool_runner, with the @beta_tool decorator) runs the same cycle for you. It is in beta.

  • Plug in ready-made tools. Instead of writing every tool, connect MCP servers. Our tutorial shows how to build an MCP server and a client for it.

  • Split big jobs. When one loop gets crowded, several focused agents can share the work. See multi-agent systems.

FAQ

Do I need a framework to build an AI agent?

No. A loop, a few functions and a model API are enough, as this tutorial shows. Frameworks add conveniences such as retries, tracing and memory, which help once you know what you need.

Why test an agent with a fake model?

It is free, fast and repeatable, and it can fail on command. A real model rarely tries to leave the sandbox or loops forever exactly when you want to test for it.

How much does running an agent cost?

Each step is one API call that resends the whole conversation, so cost grows faster than the step count. Cap the steps and keep tool output short. Our guide to cutting AI costs covers the rest.

Can I use a different model or provider?

Yes. The loop is the same everywhere: send messages and tools, run what the model asks for, send results back. The client call and the message format change between providers, but the loop does not.

Read next: agentic workflow examples worth automating first, and whether it is safe to let AI control your computer.

Sources
  1. Tool use with Claude, Anthropic documentation, accessed September 2026
  2. Stop reasons and fallback, Anthropic documentation, accessed September 2026
  3. Models overview, Anthropic documentation, accessed September 2026
  4. Tool runner (SDK), Anthropic documentation, accessed September 2026
  5. anthropic on PyPI, Python Package Index, version 1.8.0, September 2026
cyborb.ai

Stop reading about it. Build it.

Describe what you want in plain words. Cyborb plans the work, writes and runs the code, makes the assets, and puts the result online.

Download Cyborb

Free to start. No card required.