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 250 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_KEY
environment 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()
def get_user_message():
return input()
agent = Agent(client, get_user_message)
agent.run()
class Agent:
def __init__(self, client, get_user_message):
self.client = client
self.get_user_message = get_user_message
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 a way to get input from the user.
The Meaty Bit
Now we’ll add the run()
method — the core of our agent:
class Agent:
def __init__(self, client, get_user_message):
self.client = client
self.get_user_message = get_user_message
def run(self):
conversation = []
print("Chat with Claude (use 'ctrl-c' to quit)")
while True:
print("You: ", end="")
user_input = self.get_user_message()
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-4-5-haiku",
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, 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
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
}
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
Now let’s update our Agent class to accept tools:
class Agent:
def __init__(self, client, get_user_message, tools=None):
self.client = client
self.get_user_message = get_user_message
self.tools = tools or []
And update your main()
function to pass in the tools:
def main():
client = Anthropic()
def get_user_message():
return input()
tools = [READ_FILE_TOOL]
agent = Agent(client, get_user_message, tools)
agent.run()
Next, we need to modify _run_inference
to send the tool definitions to Claude:
def _run_inference(self, conversation):
tools = [
{
"name": tool["name"],
"description": tool["description"],
"input_schema": tool["input_schema"]
}
for tool in self.tools
]
response = self.client.messages.create(
model="claude-4-5-haiku",
max_tokens=1024,
messages=conversation,
tools=tools
)
content = []
for block in response.content:
if block.type == "text":
content.append({"type": "text", "text": block.text})
elif block.type == "tool_use":
content.append({
"type": "tool_use",
"id": block.id,
"name": block.name,
"input": block.input
})
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)")
read_user_input = True
while True:
if read_user_input:
print("You: ", end="")
user_input = self.get_user_message()
conversation.append({
"role": "user",
"content": user_input
})
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:
read_user_input = True
continue
read_user_input = False
conversation.append({
"role": "user",
"content": tool_results
})
The read_user_input
flag is the key to making tool execution feel seamless. It creates an automatic feedback loop: Claude uses a tool → we execute it → Claude sees the result → Claude continues working or asks for more tools → repeat until Claude is done and responds to the user.
Without this flag, we’d interrupt Claude after every single tool execution to ask the user for input, breaking the agent’s ability to work autonomously.
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)
if not tool:
return {
"type": "tool_result",
"tool_use_id": tool_id,
"content": "Tool not found",
"is_error": True
}
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 the secret-file.txt file
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, recognized 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:
rel_path = os.path.relpath(os.path.join(root, d), path)
files.append(rel_path + "/")
for f in filenames:
rel_path = os.path.relpath(os.path.join(root, f), path)
files.append(rel_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 == "":
os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
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!
The Revelation
You’ve probably been waiting for the complicated part that makes this all actually work.
There isn’t one!
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 250 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.