Skip to content

Build your first agent with the Claude API in Python

By SunnyKumar Jonwal 11 min read

You don't need a framework to build an agent. You need a model, a couple of tools, and a loop, and in Python that's about fifty lines. This tutorial builds one from scratch: a small agent that can explore a project folder and answer questions about it.

It's deliberately boring. The tools are read-only, the task is low stakes, and every line is visible. That's the right way to learn the pattern, because once you've seen it work you can swap in fancier tools without changing the shape.

If the words "tool use" or "agent loop" are new, skim how the agent loop works first. This post is the hands-on companion.

What you'll need

  • Python 3.10 or newer.
  • The official SDK: pip install anthropic.
  • An API key from the Anthropic console, exported as an environment variable so the SDK can find it:
export ANTHROPIC_API_KEY="your-key-here"

On Windows PowerShell the equivalent is $env:ANTHROPIC_API_KEY = "your-key-here". Never paste the key into source code or commit it. A .env file that's listed in .gitignore is the usual approach.

One note on model names. They change over time, so treat the string below as a placeholder and copy the current identifier from Anthropic's model documentation. Keeping it in a constant makes it a one-line change.

The whole agent

Save this as agent.py. We'll go through it piece by piece afterward.

import sys
from pathlib import Path

import anthropic

MODEL = "claude-sonnet-5"      # check the docs for current model names
ROOT = Path(".").resolve()     # the agent may only look inside this folder
MAX_STEPS = 12

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

SYSTEM = (
    "You are a careful code-reading assistant. Use the tools to look at "
    "files before answering. Never guess file contents. If you can't find "
    "something, say so."
)

TOOLS = [
    {
        "name": "list_files",
        "description": "List files and folders in a directory inside the project. "
                       "Use this first to see what exists. Returns one path per line.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string",
                         "description": "Directory relative to the project root, e.g. 'src'. Use '.' for the root."}
            },
            "required": ["path"],
        },
    },
    {
        "name": "read_file",
        "description": "Read a text file inside the project. Returns up to 20,000 characters.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string",
                         "description": "File path relative to the project root, e.g. 'src/main.py'."}
            },
            "required": ["path"],
        },
    },
]


def safe_path(rel: str) -> Path:
    p = (ROOT / rel).resolve()
    if not p.is_relative_to(ROOT):
        raise ValueError("Path is outside the project folder.")
    return p


def list_files(path: str) -> str:
    p = safe_path(path)
    if not p.is_dir():
        raise ValueError(f"{path} is not a directory.")
    names = sorted(x.name + ("/" if x.is_dir() else "") for x in p.iterdir())
    return "\n".join(names[:200]) or "(empty directory)"


def read_file(path: str) -> str:
    p = safe_path(path)
    if not p.is_file():
        raise ValueError(f"{path} is not a file.")
    return p.read_text(errors="replace")[:20_000]


IMPLEMENTATIONS = {"list_files": list_files, "read_file": read_file}


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

    for step in range(1, MAX_STEPS + 1):
        response = client.messages.create(
            model=MODEL,
            max_tokens=2048,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})
        print(f"[step {step}] stop_reason={response.stop_reason} "
              f"in={response.usage.input_tokens} out={response.usage.output_tokens}")

        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"    -> {block.name}({block.input})")
            try:
                output, is_error = IMPLEMENTATIONS[block.name](**block.input), False
            except Exception as exc:
                output, is_error = f"{type(exc).__name__}: {exc}", True
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
                "is_error": is_error,
            })
        messages.append({"role": "user", "content": results})

    return "Stopped: hit the step limit before finishing."


if __name__ == "__main__":
    question = " ".join(sys.argv[1:]) or "What does this project do, and what does it depend on?"
    print(run_agent(question))

Run it from the root of any small project:

python agent.py "Which files handle authentication?"

You'll see each step printed as it happens: the stop reason, token counts, and every tool the model asked for. That trace is the most useful part of the whole exercise.

Walking through it

The constants. ROOT is the sandbox. Everything the agent touches must live inside it. MAX_STEPS is the fuse that stops a confused run from spending your money.

The tool definitions. Each has a name, a description written as instructions, and a JSON schema. Notice the descriptions say when to use the tool ("Use this first to see what exists") and give a format example for the path. That small effort pays off in the choices the model makes. If you'd like to go deeper on this craft, designing tools an LLM can use covers it properly.

safe_path. This is the one security-relevant function, and it's worth understanding. The model supplies the path, and models can be tricked into supplying ../../.ssh/id_rsa. Resolving the path and checking it stays under ROOT blocks that. Whatever tools you build later, treat every model-provided argument as untrusted input.

The loop. Each iteration sends the full history, appends the model's reply exactly as returned, and checks the stop reason. If the model didn't ask for a tool, we're done and we return its text. If it did, we run each requested tool, collect the results, and send them back in a single user message.

Errors as results. The try/except turns a failure into a tool result flagged with is_error. The model reads "ValueError: src/mian.py is not a file" and, more often than not, fixes the typo and tries again. Crashing the whole run on the first bad argument would throw that ability away.

The usage print. Watching input_tokens climb from step to step teaches you something no tutorial can: every extra step re-reads the whole history. That's the reason long runs cost more than you'd guess.

What a run looks like

A typical run on a small web project goes something like this:

  1. The model calls list_files on . and sees README.md, package.json, src/, and tests/.
  2. It calls read_file on package.json and README.md, probably in the same turn, since the two calls don't depend on each other.
  3. It calls list_files on src to find the entry point, then reads one or two files.
  4. It stops calling tools and writes an answer summarizing the purpose and dependencies.

Four or five model calls, none of them scripted by you. If you change the question to "Where is the login form validated?", the path changes. That's the agent part working.

What to watch in the token counts

The per-step usage line is more educational than it looks. On the first call, input_tokens covers your system prompt, the two tool definitions, and the question, which might be a few hundred tokens in total. By step four, it also includes every file the agent has read so far. If one of those files was long, the number jumps, and every later step pays for it again.

Watch for three patterns. A steady climb is normal. A sudden jump means a tool returned something large, and that's your cue to trim output. Flat numbers with no tool calls mean the model answered directly, which is fine for a question it could handle from the request alone.

Keep a rough budget in your head. If a typical question costs a few thousand input tokens across all its steps, you're in a healthy range for a small project. If it's hundreds of thousands, something is being re-read far more than it needs to be, and the fixes usually come from context engineering rather than a different model.

Before you point it at real code

This version is read-only and sandboxed, which makes it safe to try on almost any folder. Still, a few habits are worth building now, because they get harder to add later.

Keep secrets out of reach. A project folder often contains .env files, private keys, and credentials. The agent will happily read them if it's asked to, or if it decides they look relevant, and their contents then travel to the API as part of the conversation. Add an explicit deny list to safe_path for files like .env, *.pem, and anything under a secrets folder.

Remember what the model reads. Text inside files is input, and a repository can contain instructions aimed at an AI ("ignore your previous rules"). It's rare today, but you should get used to the idea that content the agent reads can try to steer it. Prompt injection is the name for that problem.

Run it somewhere disposable first. Try it on a copy, or a throwaway checkout, before you point it at a project you care about.

Choosing what your first real agent should do

Once the toy version works, the temptation is to build something grand. Resist it. A good second project has a narrow purpose, a check you can run automatically, and a cost of failure close to zero.

Some candidates that fit: a script that reads a folder of meeting notes and drafts a weekly summary; a helper that reviews a pull request against a short checklist and writes comments for you to approve; a tool that reads error logs and suggests the likely cause with the relevant lines quoted. Each has an obvious input, a bounded set of tools, and an output a person reads before anything happens.

Avoid anything that spends money, sends messages to customers, or deletes data until you've built the approval flow to go with it. There's no rush, and the boring projects teach you the same lessons at a fraction of the risk.

Break it on purpose

Before adding features, poke at what you've built. Some experiments worth five minutes each:

  • Ask about a file that doesn't exist and watch how it recovers.
  • Ask something the project can't answer ("What's the CEO's name?") and confirm it says so instead of guessing. If it makes something up, tighten the system prompt.
  • Set MAX_STEPS = 2 and confirm the fuse works.
  • Ask it to read ../../etc/passwd (or a similar path on your system) and confirm safe_path refuses.
  • Point it at a folder with a huge file and see what the truncation does to answer quality.

Each of these teaches you where the edges are, and none of them costs more than a few cents.

Improvements, in the order I'd add them

  1. Better logging. Write every request and response to a JSON Lines file, one record per step, with a run ID. You'll want it the first time something odd happens.

  2. A repeat detector. If the same tool call with the same input happens three times, stop and report. It catches loops that a step limit only catches late.

  3. Prompt caching. The system prompt and tool definitions are identical on every call. Marking them as cacheable can reduce both cost and latency noticeably, and prompt caching explains how.

  4. A search tool. A simple text search across the project saves the model from opening file after file. Cap the number of results, and return file and line number so it can jump straight to the spot.

  5. A write tool, carefully. Only add this once you've decided how to gate it. Writing to disk is where an agent stops being harmless. Ask for human approval on every write at first, work only on a copy of the project or a version-controlled branch, and read least privilege for AI agents before you go further.

  6. Tests. Write ten realistic questions with known answers and run them on every change. That set becomes the seed of a real evaluation, and evaluating an agent shows how to grow it.

Common problems

"Invalid model" or a 404 from the API. The model string is wrong or retired. Copy the current identifier from the docs.

An error about a missing tool_result. Every tool_use block needs a matching result with the same ID, in the very next user message. Check that you append the assistant message first and answer every tool request in the turn.

The agent keeps calling list_files on the same folder. Usually the tool output is unclear, or the system prompt doesn't say when to stop. Read the transcript and see what it seemed to be confused about.

Answers are shallow. Give it more room. Raise MAX_STEPS, allow a longer read limit, or tell it in the system prompt to check at least two files before answering.

Surprisingly high token counts. Trim tool output, cache the static prefix, or shorten the history. Printing the usage numbers, as this script does, is how you find out.

Where to go from here

You now have the skeleton of every agent: a model choosing tools inside a bounded loop. Coding assistants, research agents, and support bots are this same loop with better tools, better prompts, and more guardrails.

Two natural next steps. If you want your tools to be reusable across apps, look at the Model Context Protocol, which standardizes how tools are described and called. And if you work in PHP, adding Claude to a Laravel app shows the same call pattern using Laravel's HTTP client.