You know what an agent is and how its loop works. Now you will build one. In this lesson you write a small but real agent in Python, about 90 lines long, using direct calls to the Claude API and no framework. It answers questions about a folder of text notes by listing and reading files on its own. After it you will have a program you can run, a clear picture of each part, and the confidence to add your own tools.
What we are building
A notes helper. You ask it something like "Which of my notes mention a deadline?" and it:
- lists the files in a
notesfolder, - reads the ones that look relevant,
- answers from what it found.
It has two tools, list_files and read_file, and both are read-only. That is deliberate. A first agent should not be able to change anything.
Before you start
You need three things:
- Python 3 installed. Any recent version works.
- The Anthropic Python SDK. Install it with
pip install anthropic. - An API key. Create one in the Claude Console and store it in an environment variable named
ANTHROPIC_API_KEY. The SDK reads that variable by itself, so the key never appears in your code.
On macOS or Linux, in the terminal where you will run the program:
1export ANTHROPIC_API_KEY="your-key-here"Never paste a key into a file you might share or commit to Git. Environment variables keep it out of your code.
Then make a project folder with a few notes in it:
1my-agent/
2 agent.py
3 notes/
4 todo.txt (for example: "Pay rent by the 5th")
5 ideas.txt (for example: "Ideas for blog posts")Putting a few lines of real text in each file makes the result more interesting.
The shape of the program
The program has three jobs, and it helps to keep them apart in your head.

- Describe. A system prompt with the agent's rules, and a list of tool descriptions. This is all the model ever sees of your tools.
- Do. Real Python functions that carry out the tools, plus one small function that picks the right one by name.
- Loop. The function that talks to the model, runs what it asks for, and repeats.
We will write them in that order.
Step 1: Setup and the system prompt
1import json
2from pathlib import Path
3
4import anthropic
5
6MODEL = "claude-haiku-5-5"
7MAX_STEPS = 10
8NOTES = Path("notes").resolve()
9
10client = anthropic.Anthropic()
11
12SYSTEM = (
13 "You help the user with their notes. Use the tools to look inside the notes "
14 "folder. Answer only from what the files say. If you cannot find something, say so."
15)A few choices to notice:
MODELis a small, fast, low-cost Claude model, which is a good fit for learning because every run costs very little. Choosing and configuring a model explains how to pick, and you can change this one line later.MAX_STEPSis the safety limit from the last lesson. The loop can never run more than this many times.NOTESis the folder the tools may touch. We turn it into a full path now, because we will use it to check file names later.anthropic.Anthropic()creates the client. It finds your key in the environment.SYSTEMtells the model its job and one important rule: answer only from the files. This lowers the chance of it making things up.
Step 2: Describe the tools
1TOOLS = [
2 {
3 "name": "list_files",
4 "description": "List the file names in the notes folder.",
5 "input_schema": {"type": "object", "properties": {}},
6 },
7 {
8 "name": "read_file",
9 "description": "Read one text file from the notes folder. Use a name returned by list_files.",
10 "input_schema": {
11 "type": "object",
12 "properties": {
13 "name": {"type": "string", "description": "File name, for example todo.txt"}
14 },
15 "required": ["name"],
16 },
17 },
18]Each tool has a name, a description and an input_schema. The schema is JSON Schema, the same format APIs use to describe data. The model reads these and decides which tool fits. The description is the most important part, because it is the only thing that tells the model what the tool does and when to use it. Notice that the read_file description says "use a name returned by list_files". That small hint steers the model to look before it reads.
list_files takes no inputs, so its schema has an empty properties object.
Step 3: Write the tools
Now the real code behind those descriptions:
1def list_files():
2 names = sorted(p.name for p in NOTES.iterdir() if p.is_file())
3 return "\n".join(names) or "The folder is empty."
4
5
6def read_file(name):
7 path = (NOTES / name).resolve()
8 if path.parent != NOTES or not path.is_file():
9 return f"Error: there is no file called {name}"
10 return path.read_text()[:5000]Look closely at read_file, because it has three habits worth copying.
It checks the path. The model chooses name, and a model can be wrong or be tricked into asking for something like ../../secret.txt. The code builds the full path, then checks that the file sits directly inside the notes folder. Anything else is refused. This is a first taste of a rule you will meet again in Prompt injection and tool sandboxing: never trust the inputs the model gives your tools.
It returns errors as text. A missing file does not crash the program. It returns a message the model can read, and the model can then try another name. This is how agents recover from small mistakes.
It limits the size. [:5000] keeps a huge file from flooding the context window, which would make every later step slower and more costly.
Now a small function that connects a tool name to its code:
1def run_tool(name, args):
2 try:
3 if name == "list_files":
4 return list_files()
5 if name == "read_file":
6 return read_file(args["name"])
7 return f"Error: unknown tool {name}"
8 except Exception as e:
9 return f"Error: {e}"The try block is a safety net. If a tool fails in a way you did not expect, the model gets an error message instead of your program stopping.
Step 4: The loop
This is the heart of the agent. Compare it with the loop in The agent loop, because it follows the same pattern.
1def run_agent(task):
2 messages = [{"role": "user", "content": task}]
3
4 for step in range(1, MAX_STEPS + 1):
5 response = client.messages.create(
6 model=MODEL,
7 max_tokens=1024,
8 system=SYSTEM,
9 tools=TOOLS,
10 messages=messages,
11 )
12 messages.append({"role": "assistant", "content": response.content})
13
14 if response.stop_reason == "end_turn":
15 return "".join(b.text for b in response.content if b.type == "text")
16 if response.stop_reason != "tool_use":
17 return f"Stopped early: {response.stop_reason}"
18
19 results = []
20 for block in response.content:
21 if block.type == "tool_use":
22 print(f"step {step}: {block.name}({json.dumps(block.input)})")
23 output = run_tool(block.name, block.input)
24 print(f" -> {output[:60]!r}")
25 results.append(
26 {"type": "tool_result", "tool_use_id": block.id, "content": output}
27 )
28 messages.append({"role": "user", "content": results})
29
30 return "Stopped: step limit reached"Walk through it line by line:
messagesstarts with the user's task. It is the conversation, and it is the agent's memory for this run.- The
forloop runs at mostMAX_STEPStimes. This is the step limit in action. client.messages.createsends the system prompt, the tool descriptions and the whole conversation. Each call is stateless, as you learned in How LLMs work, so everything must be sent every time.- The reply is appended to
messagesexactly as received. end_turnmeans the model is done. We collect its text and return it. This is the normal exit.- Any other non-tool stop returns a message that says why.
max_tokensandrefusalare real possibilities, and a finished answer is not what they mean. - For every
tool_useblock we print what is happening, run the tool, and build atool_resultthat carries the sametool_use_id. One reply can hold several tool requests, so we loop over all the blocks. - The results go back as a user message, and the loop goes around again.
- If the loop ends without returning, the step limit was reached. We say so instead of pretending it finished.
The two print lines are not decoration. They show you each step as it happens, and when an agent behaves strangely, that trace is how you find out why.
Step 5: Run it
Add the last lines to the file:
1if __name__ == "__main__":
2 print(run_agent("Which of my notes mention a deadline?"))Run it from the my-agent folder:
1python agent.pyThe output will look something like this. Your wording will differ, because the model decides its own path:
1step 1: list_files({})
2 -> 'ideas.txt\ntodo.txt'
3step 2: read_file({"name": "todo.txt"})
4 -> 'Pay rent by the 5th\n'
5step 3: read_file({"name": "ideas.txt"})
6 -> 'Ideas for blog posts\n'
7Only todo.txt mentions a deadline: pay rent by the 5th.
Look at what happened. You never wrote "list the files, then read each one". The model worked that out from the tool descriptions and the question. It might also read both files in one step, since one reply can request several tools. Either way the loop handles it.
Make it your own
You now have a working base. These small changes teach you a lot:
- Change the question. Try "Summarize everything in my notes" or "Do I have anything planned for Friday?" and watch the steps change.
- Ask something it cannot know. Try "What is the capital of France?" It should say it cannot find that in the notes, because the system prompt tells it to answer only from the files. If it answers anyway, your instructions need tightening.
- Remove the path check in
read_fileand ask for../agent.py. You will see why the check matters. Then put it back. - Lower
MAX_STEPSto 2 and run the first question. The agent stops with the step-limit message, which shows that the guard works. - Add a third tool, such as
count_words(name). Write the function, add one entry toTOOLS, and add one line torun_tool. That is all it takes.
What this agent does not do yet
Be honest about the gaps, because they are the next lessons:
- Memory between runs. Each run starts fresh. Remembering earlier sessions is a later topic.
- Retries and rate limits. If the API is busy, the program raises an error. You will fix that in Errors, retries and rate limits.
- Cost tracking. The step limit caps the loop, but it does not count tokens or money. You will add that when you study cost.
- Other providers. The same pattern works with other model providers, with small differences in the request format. Function calling across providers covers them.
- A tested, measured result. You tried it by hand. Real agents need a test set, which comes in the quality module.
Mistakes beginners make
- Pasting the API key into the code. Use an environment variable.
- Vague tool descriptions. If the model picks the wrong tool, improve the description first.
- Trusting tool inputs. Validate every path, number and name the model gives you.
- Crashing on a tool error. Return the error as text so the model can recover.
- Forgetting to append the model's reply before sending the tool result. The conversation then makes no sense to the model.
- Mismatched
tool_use_id. Every result must carry the id of the request it answers. - No step limit. Always cap the loop, even in a tiny script.
- Giving a first agent write access. Start with read-only tools until you trust the behavior.
Quick recap
- A working agent is a system prompt, a list of tool descriptions, the real tool functions, and a loop.
- The model only reads the descriptions. Your code runs the tools and sends results back with the matching
tool_use_id. - Return tool errors as text, check every input the model supplies, and keep results small.
- Print each step so you can see what the agent is doing.
- Stop on
end_turn, report other stop reasons clearly, and cap the loop with a step limit. - Adding a tool means a function, a description and one line in the dispatcher.
Try it yourself
Run the agent on your own folder of notes with three different questions. For each, read the printed steps and write down whether the order made sense. Then add a search_notes(word) tool that returns the names of files containing a word, and compare how many steps the same question takes before and after. If you do not have an API key yet, read the code and predict the messages list after step 2 instead, including who sent each entry.
Next lesson: Function calling across providers.