How to build an agent
Building a fully functioning coding agent is not nearly as hard as you might think.
When you watch an agent autonomously editing files, running commands, recovering from errors, and adapting its strategy on the fly, it feels like magic. Like there must be some secret sauce you’re missing.
But there isn’t. The core of an agent is surprisingly simple: an LLM, a loop, and enough context. Everything else is just good engineering.
You can build one in under 200 lines of straightforward Python.
I’m going to show you exactly how. We’ll go from zero to “wait, I just built that?”
I encourage you to follow along and type the code yourself. You need to feel how little there is.
What You Need
- Python 3.7 or higher
- An Anthropic API key (set as the
ANTHROPIC_API_KEYenvironment variable)
That’s it. Let’s get started.
Setting Up
Create a new directory and file:
mkdir coding-agent
cd coding-agent
touch agent.py
The Skeleton
Open agent.py and add this basic structure:
import os
import json
from anthropic import Anthropic
def main():
client = Anthropic()
agent = Agent(client)
agent.run()
class Agent:
def __init__(self, client):
self.client = client
if __name__ == "__main__":
main()
This won’t run yet, but we’ve laid the foundation. Our Agent class has access to an Anthropic client (which automatically reads your API key from the environment), and we’ve imported all the libraries we need.
The Meaty Bit
Now we’ll add the run() and run_inference() methods — the core of our agent:
class Agent:
def __init__(self, client):
self.client = client
def run(self):
conversation = []
print("Chat with Claude (use 'ctrl-c' to quit)")
while True:
print("You: ", end="")
user_input = input()
if not user_input.strip():
continue
conversation.append({"role": "user", "content": user_input})
message = self._run_inference(conversation)
conversation.append(message)
for content in message["content"]:
if content["type"] == "text":
print(f"Claude: {content['text']}")
def _run_inference(self, conversation):
response = self.client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
messages=conversation
)
return {
"role": response.role,
"content": [{"type": block.type, "text": block.text} for block in response.content]
}
That’s the core loop. It’s dead simple: get user input, add it to the conversation history, send everything to Claude, append Claude’s response, display it, and repeat.
This is every AI chatbot you’ve ever used — just in your terminal.
Let’s install the Anthropic SDK and take it for a spin:
pip install anthropic
export ANTHROPIC_API_KEY="your-key-here"
python agent.py
Go ahead - try talking to Claude. It works! The conversation grows naturally with each exchange, and we’re managing all the context ourselves on the client side.
A First Tool
So what makes this an agent rather than just a chatbot? Tools.
An agent is an LLM with the ability to interact with the world beyond its context window. It can read files, run commands, make API calls — anything you give it access to.
And tools are surprisingly simple. You describe what actions are available, the model requests them when needed, you execute them, and send back the results. That’s the whole dance.
Modern LLMs are trained to know when they need external help. They understand their own limitations and will proactively request tools to gather information or take action.
Simulation Time
Before we implement actual tools, let’s demonstrate how simple the concept really is. We can just tell Claude to respond in a specific format when it wants to use a “tool”, and it will.
Run your file as-is and try this:
$ python agent.py
You: You are a calculator assistant. When I ask you to perform a calculation, reply with `calculate(<expression>)` and I will give you the result. Understood?
Claude: I understand! When you ask me to perform a calculation, I will reply with `calculate(<expression>)` format, and then you'll provide me with the result. I'm ready to help with your calculations.
You: What's 847 multiplied by 23?
Claude: calculate(847 * 23)
Perfect! Claude is making a request by using our specified format. Now we fulfill that request by providing the result:
You: 19481
Claude: The result of 847 multiplied by 23 is 19,481.
That’s the entire concept. These models are trained to understand that they can request external actions in specific formats. When we send tool definitions via the API (like we’ll do in a moment), we’re just making this protocol more structured and reliable.
Let’s give our agent its first tool: the ability to read files.
The read_file Tool
Every tool definition needs four things:
- A name that identifies it
- A description that tells the model what it does and when to use it
- An input schema that defines what parameters it expects using JSON Schema (a standard way to describe the structure of JSON data — here it says we need an object with a required “path” property that’s a string)
- A function that actually executes the tool
Add this code before your main() function:
def read_file(inputs):
"""Read the contents of a file."""
path = inputs.get("path")
with open(path, "r") as f:
return f.read()
READ_FILE_TOOL = {
"name": "read_file",
"description": "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The relative path of a file in the working directory."
}
},
"required": ["path"]
},
"function": read_file
}
Now let’s update our Agent class to accept tools:
class Agent:
def __init__(self, client, tools):
self.client = client
self.tools = tools
And update your main() function to pass in the tools:
def main():
client = Anthropic()
tools = [READ_FILE_TOOL]
agent = Agent(client, tools)
agent.run()
Next, we need to modify _run_inference to send the tool definitions to Claude:
def _run_inference(self, conversation):
tools = [
{k: tool[k] for k in ["name", "description", "input_schema"]}
for tool in self.tools
]
response = self.client.messages.create(
model="claude-haiku-4-5",
max_tokens=1024,
messages=conversation,
tools=tools,
)
content = [
{"type": "text", "text": block.text}
if block.type == "text"
else {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
for block in response.content
]
return {"role": response.role, "content": content}
When we pass tools to the API, Claude learns what’s available and will respond in a structured format when it wants to use one.
But we’re not actually listening for those tool requests yet. Let’s fix that by replacing the run() method with this version that can handle tool calls:
def run(self):
conversation = []
print("Chat with Claude (use 'ctrl-c' to quit)")
while True:
print("You: ", end="")
user_input = input()
if not user_input.strip():
continue
conversation.append({"role": "user", "content": user_input})
while True:
message = self._run_inference(conversation)
conversation.append(message)
tool_results = []
for content in message["content"]:
if content["type"] == "text":
print(f"Claude: {content['text']}")
elif content["type"] == "tool_use":
result = self._execute_tool(
content["id"], content["name"], content["input"]
)
tool_results.append(result)
if not tool_results:
break
conversation.append({"role": "user", "content": tool_results})
Now add the _execute_tool method to actually run the tools:
def _execute_tool(self, tool_id, name, inputs):
tool = next((t for t in self.tools if t["name"] == name), None)
print(f"tool: {name}({inputs})")
try:
result = tool["function"](inputs)
return {"type": "tool_result", "tool_use_id": tool_id, "content": result}
except Exception as e:
return {
"type": "tool_result",
"tool_use_id": tool_id,
"content": str(e),
"is_error": True,
}
The logic is straightforward: when Claude requests a tool (indicated by content.type == "tool_use"), we find the matching tool, execute it, and send back the result. If anything goes wrong, we return an error.
Let’s test it out. Create a simple test file:
echo 'What has a mouth but never eats, a bed but never sleeps, and can run but never walks' > secret-file.txt
Now run the agent:
$ python agent.py
You: help me solve the riddle in secret-file.txt
Claude: I'll help you solve the riddle. Let me read the file first.
tool: read_file({'path': 'secret-file.txt'})
Claude: The answer is: **A river**
A river has a mouth (where it meets the sea or another body of water), a bed (the bottom channel it flows through), and runs (flows) but never walks!
Let’s pause for a moment and appreciate what just happened. We gave Claude a tool and it autonomously decided when to use it. We never wrote any logic like “if the user mentions a file, read it.” Claude understood from the tool description that it could read files, recognised that solving the riddle required reading the file, and did it on its own.
The list_files Tool
Let’s add another tool, this time giving our agent the ability to explore the filesystem by listing files and directories:
def list_files(inputs):
"""List files and directories at a given path."""
path = inputs.get("path", ".")
files = []
for root, dirs, filenames in os.walk(path):
for d in dirs:
files.append(os.path.relpath(os.path.join(root, d), path) + "/")
for f in filenames:
files.append(os.path.relpath(os.path.join(root, f), path))
return json.dumps(files)
LIST_FILES_TOOL = {
"name": "list_files",
"description": "List files and directories at a given path. If no path is provided, lists files in the current directory.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Optional relative path to list files from. Defaults to current directory if not provided."
}
}
},
"function": list_files
}
Update main() to include both tools:
tools = [READ_FILE_TOOL, LIST_FILES_TOOL]
Now watch Claude chain these tools together intelligently:
You: Tell me about all the Python files in here
Claude: Let me check what files are available first.
tool: list_files({})
Claude: I found a Python file. Let me examine it:
tool: read_file({'path': 'agent.py'})
Claude: Here's a brief overview: agent.py implements a Claude AI agent that can interact with the local filesystem...
Notice the reasoning here? Claude first lists the directory to see what’s there, identifies the Python file, and then reads it. It’s using tools the same way you would - building up knowledge step by step.
Let it edit_file
Time for the final piece: giving Claude the ability to actually modify files.
You might be expecting some complex diffing algorithm or syntax tree manipulation here. But the reality is much simpler:
def edit_file(inputs):
"""Edit a file by replacing old_str with new_str."""
path = inputs.get("path")
old_str = inputs.get("old_str")
new_str = inputs.get("new_str")
if not path or old_str == new_str:
raise ValueError("Invalid input parameters")
try:
with open(path, "r") as f:
content = f.read()
except FileNotFoundError:
if old_str == "":
if os.path.dirname(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(new_str)
return f"Successfully created file {path}"
raise
new_content = content.replace(old_str, new_str)
if content == new_content and old_str != "":
raise ValueError("old_str not found in file")
with open(path, "w") as f:
f.write(new_content)
return "OK"
EDIT_FILE_TOOL = {
"name": "edit_file",
"description": """Make edits to a text file.
Replaces 'old_str' with 'new_str' in the given file. 'old_str' and 'new_str' MUST be different from each other.
If the file specified with path doesn't exist, it will be created.""",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file"
},
"old_str": {
"type": "string",
"description": "Text to search for - must match exactly"
},
"new_str": {
"type": "string",
"description": "Text to replace old_str with"
}
},
"required": ["path", "old_str", "new_str"]
},
"function": edit_file
}
Yep - string replacement — that’s it. Modern LLMs are exceptionally good at this pattern. Give them a chunk of existing text and what it should become, and they’ll apply the change accurately.
Add this tool to main():
tools = [READ_FILE_TOOL, LIST_FILES_TOOL, EDIT_FILE_TOOL]
Now for the moment of truth. Let’s ask our agent to write some code from scratch:
You: create countdown.js that counts down from 10 to 0
Claude: I'll create a countdown.js file. Let me check if it exists first:
tool: list_files({})
Claude: I don't see a countdown.js file, so I'll create one:
tool: edit_file({'path': 'countdown.js', 'old_str': '', 'new_str': '...'})
Claude: I've created countdown.js. You can run it with: node countdown.js
Does it actually work?
$ node countdown.js
Counting down from 10:
10
9
8
7
6
5
4
3
2
1
0
Blast off!
Perfect. Now let’s test the real editing capability:
You: edit countdown.js so that it counts down from 20 instead
Claude: Let me check the current content:
tool: read_file({'path': 'countdown.js'})
Claude: I'll modify it to count down from 20:
tool: edit_file({'path': 'countdown.js', 'old_str': '...10...', 'new_str': '...20...'})
tool: edit_file({'path': 'countdown.js', 'old_str': '...comment...', 'new_str': '...'})
Claude: Done! The program now counts down from 20.
Claude read the file to understand its structure, made the necessary changes to the starting number, and even updated the comment to match the new behavior. All on its own.
$ node countdown.js
Counting down from 20:
20
19
18
...
1
0
Blast off!
That’s all there is!
What you’ve just built is the fundamental inner loop of every coding agent. Yes, there’s more work to integrate it into an editor, polish the prompts, add a proper UI, implement better tooling. But none of that requires breakthroughs or eureka moments. It’s all practical engineering.
The real magic is in the models themselves. With less than 200 lines of straightforward Python and three simple tools, you’ve built an agent that can understand natural language instructions, navigate a codebase, and modify code intelligently.
Complete Code
Find the complete code at https://github.com/samdobson/mini-py-agent
Acknowledgements
Huge thanks to Thorsten Ball for his excellent GoLang tutorial by which this post was heavily inspired.