How to Build an Agent


(This is a mere python translation (from GoLang) of Thorsten Ball's blogpost, credits goes to him) Original: https://ampcode.com/how-to-build-an-agent
It’s not that hard to build a fully functioning, code-editing agent.
It seems like it would be. When you look at an agent editing files, running commands, wriggling itself out of errors, retrying different strategies - it seems like there has to be a secret behind it.
There isn’t. It’s an LLM, a loop, and enough tokens. It’s what we’ve been saying on the podcast from the start. The rest, the stuff that makes Amp so addictive and impressive? Elbow grease.
But building a small and yet highly impressive agent doesn’t even require that. You can do it in less than 400 lines of code, most of which is boilerplate.
I’m going to show you how, right now. We’re going to write some code together and go from zero lines of code to “oh wow, this is… a game changer.”
I urge you to follow along. No, really. You might think you can just read this and that you don’t have to type out the code, but it’s less than 400 lines of code. I need you to feel how little code it is and I want you to see this with your own eyes in your own terminal in your own folders.
Here’s what we need:
Python 3.9+
The
anthropic
Python library (pip install anthropic
)The
pydantic
library (pip install pydantic
) - we'll use this for schema definition later.An Anthropic API key (set as the
ANTHROPIC_API_KEY
environment variable).
Pencils out! Let’s dive right in and get ourselves a new Python project set up in four easy commands:
mkdir code-editing-agent
cd code-editing-agent
# Maybe set up a virtual environment (optional but recommended)
# python -m venv venv
# source venv/bin/activate # On Linux/macOS
# .\venv\Scripts\activate # On Windows
pip install anthropic pydantic
touch main.py
Now, let’s open main.py
and, as a first step, put a skeleton of things we need in it:
# main.py
import os
import sys
import json
from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT
from anthropic.types import MessageParam, TextBlock
from typing import List, Dict, Callable, Optional, Any
from pydantic import BaseModel, Field
import pathlib
# Helper function to get user input
def get_user_message() -> Optional[str]:
try:
return input()
except EOFError:
return None
class Agent:
def __init__(self, client: Anthropic, get_user_message_func: Callable[[], Optional[str]]):
self.client = client
self.get_user_message = get_user_message_func
self.tools = [] # We'll add tools later
def run(self):
# We'll implement this next
pass
# We'll define NewAgent functionality implicitly via the class constructor
# The Pythonic way is direct instantiation: Agent(...)
def main():
# Client automatically looks for ANTHROPIC_API_KEY env var
try:
client = Anthropic()
except Exception as e:
print(f"Error creating Anthropic client: {e}")
print("Please ensure the ANTHROPIC_API_KEY environment variable is set.")
sys.exit(1)
agent = Agent(client, get_user_message)
try:
agent.run()
except Exception as e:
print(f"\nError: {e}")
if __name__ == "__main__":
main()
Yes, this doesn't do much yet. But what we have here is an Agent
class that has access to an anthropic.Anthropic
client and can get a user message by reading from stdin on the terminal using our get_user_message
function.
Now let’s add the missing run()
method:
# main.py
# ... (imports and get_user_message function remain the same) ...
from anthropic.types import Message
class Agent:
def __init__(self, client: Anthropic, get_user_message_func: Callable[[], Optional[str]]):
self.client = client
self.get_user_message = get_user_message_func
self.tools = [] # Tool definitions will go here
def _run_inference(self, conversation: List[MessageParam]) -> Message:
# Note: The Python SDK uses 'Message' not '*anthropic.Message'
# The parameters are slightly different from Go SDK
message = self.client.messages.create(
model="claude-3-7-sonnet-latest", # Or other compatible model
max_tokens=1024,
messages=conversation,
# tools=... will be added later
)
return message
def run(self):
conversation: List[MessageParam] = []
print("Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)")
while True:
# ANSI escape codes for color
print("\u001b[94mYou\u001b[0m: ", end="", flush=True)
user_input = self.get_user_message()
if user_input is None:
print("\nExiting...")
break
# The Python SDK expects messages in a specific dict format
user_message: MessageParam = {"role": "user", "content": user_input}
conversation.append(user_message)
try:
message = self._run_inference(conversation)
# Append the assistant's response message object/dict
assistant_message: MessageParam = {"role": message.role, "content": message.content}
conversation.append(assistant_message)
# Process and print response content
# message.content is a list of blocks (e.g., TextBlock)
for content_block in message.content:
if content_block.type == "text":
# ANSI escape codes for color
print(f"\u001b[93mClaude\u001b[0m: {content_block.text}")
# We'll handle 'tool_use' later
except Exception as e:
print(f"\nError during inference: {e}")
# Optionally remove the last user message to allow retry
if conversation and conversation[-1]["role"] == "user":
conversation.pop()
# ... (main function remains the same) ...
if __name__ == "__main__":
main()
It’s not a lot, is it? Around 90 lines and the most important thing in them is this loop in run()
that lets us talk to Claude. But that’s already the heartbeat of this program.
And for a heartbeat, it’s pretty straightforward: we first print a prompt, ask the user to type something, add it to the conversation, send it to Claude, add Claude’s response to the conversation, print the response, and around we go.
This is every AI chat application you’ve ever used, except it’s in the terminal.
Let’s run it:
export ANTHROPIC_API_KEY="this is the last time i will tell you to set this"
# Or on Windows: set ANTHROPIC_API_KEY="this is the last time i will tell you to set this"
# Run it
python main.py
Then you can just talk to Claude, like this:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: Hey! I'm Thorsten! How are you?
Claude: Hi Thorsten! I'm doing well, thanks for asking. It's nice to meet you. How are you doing today? Is there something I can help you with?
You: Can you come up with any horse-related nicknames that make fun of my first name?
Claude: Hmm, let's see. Playing with "Thorsten" and horses... how about these? * Thorough-sten * Trotsten * Thorsteed * Neigh-sten * Gallop-sten * Hoofsten * Mane Man Thorsten * Saddle-sten * Canter-sten * Thorsten the Colt. Hopefully, those are lighthearted and give you a chuckle!
You:
Notice how we kept the same conversation going over multiple turns. It remembers my name from the first message. The conversation grows longer with every turn and we send the whole conversation every time. The server — Anthropic’s server — is stateless. It only sees what’s in the conversation
list. It’s up to us to maintain that.
Okay, let’s move on, because the nicknames suck and this is not an agent yet. What’s an agent? Here’s my definition: an LLM with access to tools, giving it the ability to modify something outside the context window.
A First Tool
An LLM with access to tools? What’s a tool? The basic idea is this: you send a prompt to the model that says it should reply in a certain way if it wants to use “a tool”. Then you, as the receiver of that message, “use the tool” by executing it and replying with the result. That’s it. Everything else we’ll see is just abstraction on top of it.
Imagine you’re talking to a friend and you tell them: “in the following conversation, wink if you want me to raise my arm”. Weird thing to say, but an easy concept to grasp.
We can already try it without changing any of our code.
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: You are a weather expert. When I ask you about the weather in a given location, I want you to reply with `get_weather(<location_name>)`. I will then tell you what the weather in that location is. Understood?
Claude: Understood. When you ask about the weather in a specific location, I will respond with `get_weather(<location_name>)`. You will then provide the weather information for that location, and I can proceed from there. I'm ready when you are.
You: Hey, what's the weather in Munich?
Claude: get_weather(Munich)
We told Claude to wink with get_weather
when it wants to know about the weather. The next step is to raise our arm and reply with “result of the tool”:
You: hot and humid, 28 degrees celcius
Claude: Thank you for the information. So, the weather in Munich is currently hot and humid at 28 degrees Celsius (which is about 82 degrees Fahrenheit). That sounds like quite warm weather, especially with the humidity potentially making it feel even warmer. If you're in Munich, it might be a good day to stay hydrated and maybe find some shade or air conditioning during the hottest part of the day. Do you need weather information for any other location?
That worked very well, on first try, didn’t it?
These models are trained and fine-tuned to use “tools” and they’re very eager to do so. By now, 2025, they kinda “know” that they don’t know everything and can use tools to get more information. (Of course that’s not precisely what’s going on, but it’s good enough an explanation for now.)
To summarize, all there is to tools and tool use are two things:
You tell the model what tools are available
When the model wants to execute the tool, it tells you, you execute the tool and send the response up
To make (1) easier, the big model providers have built-in APIs to send tool definitions along.
Okay, now let’s build our first tool: read_file
The read_file
tool In order to define the read_file
tool, we’re going to use the structures that the Anthropic Python SDK suggests, but keep in mind: under the hood, this will all end up as data structures (often JSON-like) that are sent to the model. It’s all “wink if you want me to use read_file
“.
Each tool we’re going to add will require the following:
A name
A description, that tells the model what the tool does, when to use it, when to not use it, what it returns and so on.
An input schema, that describes (usually as a JSON schema), what inputs this tool expects and in which form
A function that actually executes the tool with the input the model sends to us and returns the result
So let’s add that to our code. We'll create a simple class to hold this definition.
# main.py
# ... (other imports) ...
from pydantic import BaseModel, Field # Make sure pydantic is imported
from typing import List, Dict, Callable, Optional, Any, Type # Added Type
# Define a class to hold tool information
class ToolDefinition:
def __init__(self, name: str, description: str, input_schema: Type[BaseModel], function: Callable):
self.name = name
self.description = description
self.input_schema = input_schema
self.function = function
# Helper to generate the format Anthropic expects for the 'tools' parameter
def get_anthropic_schema(self) -> Dict[str, Any]:
# Pydantic v2+ uses model_json_schema()
schema = self.input_schema.model_json_schema()
# We need to remove the 'title' key if pydantic adds it,
# and adjust the structure slightly for Anthropic
return {
"name": self.name,
"description": self.description,
"input_schema": {
"type": "object",
"properties": schema.get("properties", {}),
"required": schema.get("required", [])
}
}
# Now we give our Agent tool definitions:
# ... inside the Agent class ...
class Agent:
# Update __init__ to accept tools
def __init__(self,
client: Anthropic,
get_user_message_func: Callable[[], Optional[str]],
tools: List[ToolDefinition] = None): # Make tools optional
self.client = client
self.get_user_message = get_user_message_func
# Store tools in a dictionary for easy lookup by name
self.tools = {tool.name: tool for tool in tools} if tools else {}
# Update _run_inference to send tools
def _run_inference(self, conversation: List[MessageParam]) -> Message:
anthropic_tools = [tool.get_anthropic_schema() for tool in self.tools.values()]
message = self.client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
messages=conversation,
tools=anthropic_tools if anthropic_tools else None # Pass tools if defined
)
return message
# ... (run method will be updated later) ...
# Update the main function to pass tools
def main():
# ... (client setup) ...
# Define tools (initially empty, will add ReadFileDefinition soon)
tools = []
agent = Agent(client, get_user_message, tools) # Pass tools list
# ... (agent.run() call) ...
# ... (if __name__ == "__main__": block) ...
And send them along to the model in _run_inference
(which we just did above).
There’s a bit of data structuring going on, but really, I swear, it’s very simple:
We send along our tool definitions, on the server Anthropic then likely wraps these definitions in a system prompt (which isn’t much), which it adds to our conversation, and the model then replies in a specific way if it wants to use that tool.
Alright, so tool definitions are being sent along, but we haven’t defined a tool yet. Let’s do that and define read_file
:
# main.py
# ... (imports) ...
import pathlib # Use pathlib for file operations
# Define the input schema using Pydantic
class ReadFileInput(BaseModel):
path: str = Field(..., description="The relative path of a file in the working directory.")
# Define the actual function that implements the tool
def read_file(input_data: Dict[str, Any]) -> str:
# Validate input using the Pydantic model
try:
validated_input = ReadFileInput(**input_data)
file_path = pathlib.Path(validated_input.path)
# Basic security check: prevent escaping the current directory
# This is rudimentary; real applications need more robust checks.
if not file_path.resolve().is_relative_to(pathlib.Path.cwd().resolve()):
raise ValueError("File path is outside the allowed directory.")
if not file_path.is_file():
raise FileNotFoundError(f"File not found or is a directory: {validated_input.path}")
content = file_path.read_text()
return content
except FileNotFoundError as e:
# Return error message that the LLM can understand
return f"Error: File not found at path: {input_data.get('path', 'N/A')}"
except Exception as e:
# General error handling
return f"Error reading file: {e}"
# Create the ToolDefinition instance for read_file
ReadFileDefinition = ToolDefinition(
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=ReadFileInput,
function=read_file,
)
# ... (Agent class remains the same for now) ...
# Update main function to include the ReadFileDefinition
def main():
# ... (client setup) ...
tools = [ReadFileDefinition] # Add our new tool definition
agent = Agent(client, get_user_message, tools)
# ... (agent.run() call) ...
# ... (if __name__ == "__main__": block) ...
That’s not much, is it? It’s a single function, read_file
, and two descriptions the model will see: our tool description
("Read the contents of a given relative file path. ...") and a description of the single input parameter this tool has (defined via Pydantic's Field(description=...)
).
The Pydantic ReadFileInput
class and ToolDefinition
help structure this. Pydantic automatically generates the JSON schema (.model_json_schema()
) that the Anthropic API needs.
Time to try it!
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: what's in main.py?
Claude: Okay, I can help with that. I'll use the `read_file` tool to check the contents of `main.py`.
You:
Wait, what? Ho, ho, ho, it wants to use the tool! Obviously the output will be slightly different for you, but it certainly sounds like Claude knows that it can read files, right?
The problem is that we don’t listen! When Claude winks (by requesting a tool use), we ignore it. We need to fix that.
Here, let me show you how to do that in a single, quick, surprisingly-agile-for-my-age move by replacing our Agent’s run
method with this:
# main.py
# ... (imports, ToolDefinition, read_file tool etc. remain the same) ...
# Inside the Agent class:
class Agent:
def __init__(self, client: Anthropic, get_user_message_func: Callable[[], Optional[str]], tools: List[ToolDefinition] = None):
self.client = client
self.get_user_message = get_user_message_func
self.tools = {tool.name: tool for tool in tools} if tools else {}
def _run_inference(self, conversation: List[MessageParam]) -> Message:
anthropic_tools = [tool.get_anthropic_schema() for tool in self.tools.values()]
# print(f"DEBUG: Sending tools: {json.dumps(anthropic_tools, indent=2)}") # Optional debug print
message = self.client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
messages=conversation,
tools=anthropic_tools if anthropic_tools else None,
# Tool choice can be added here if needed, e.g., tool_choice={"type": "auto"}
)
# print(f"DEBUG: Received message: {message}") # Optional debug print
return message
def _execute_tool(self, tool_name: str, tool_input: Dict[str, Any], tool_use_id: str) -> Dict[str, Any]:
"""Executes a tool and returns the result in the format Anthropic expects."""
tool_result_content = ""
is_error = False
if tool_name in self.tools:
tool_def = self.tools[tool_name]
# ANSI escape codes for color
print(f"\u001b[92mtool\u001b[0m: {tool_name}({json.dumps(tool_input)})")
try:
# Execute the tool's function
tool_result_content = tool_def.function(tool_input)
except Exception as e:
print(f"Error executing tool {tool_name}: {e}")
tool_result_content = f"Error executing tool {tool_name}: {e}"
is_error = True
else:
print(f"Error: Tool '{tool_name}' not found.")
tool_result_content = f"Error: Tool '{tool_name}' not found."
is_error = True
# Return the result in the format required for the next API call
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": str(tool_result_content), # Content should be a string
"is_error": is_error, # Indicate if the tool execution failed
}
def run(self):
conversation: List[MessageParam] = []
print("Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)")
read_user_input = True
while True:
if read_user_input:
# ANSI escape codes for color
print("\u001b[94mYou\u001b[0m: ", end="", flush=True)
user_input = self.get_user_message()
if user_input is None:
print("\nExiting...")
break
user_message: MessageParam = {"role": "user", "content": user_input}
conversation.append(user_message)
try:
# print(f"DEBUG: Sending conversation: {json.dumps(conversation, indent=2)}") # Optional debug
message = self._run_inference(conversation)
# Append the assistant's response *before* processing tool calls
# The SDK's message object might not be directly serializable,
# so reconstruct the dictionary if needed or store the object
# For simplicity here, let's reconstruct the dict structure Anthropic uses
assistant_response_content = []
if message.content:
for block in message.content:
assistant_response_content.append(block.model_dump()) # Use model_dump() for Pydantic objects in SDK
assistant_message: MessageParam = {"role": message.role, "content": assistant_response_content}
conversation.append(assistant_message)
# print(f"DEBUG: Appended assistant message: {json.dumps(assistant_message, indent=2)}") # Optional debug
tool_results = []
assistant_said_something = False
# Process message content (text and tool calls)
if message.content:
for content_block in message.content:
if content_block.type == "text":
# ANSI escape codes for color
print(f"\u001b[93mClaude\u001b[0m: {content_block.text}")
assistant_said_something = True
elif content_block.type == "tool_use":
# It's requesting a tool
tool_name = content_block.name
tool_input = content_block.input or {}
tool_use_id = content_block.id
tool_result = self._execute_tool(tool_name, tool_input, tool_use_id)
tool_results.append(tool_result)
# If there were tool calls, send results back
if tool_results:
read_user_input = False # Let the agent respond to the tool result
# Construct the user message containing tool results
tool_result_message: MessageParam = {
"role": "user",
"content": tool_results # Send list of tool result blocks
}
conversation.append(tool_result_message)
# print(f"DEBUG: Appended tool results message: {json.dumps(tool_result_message, indent=2)}") # Optional debug
else:
# No tool calls, wait for next user input
read_user_input = True
if not assistant_said_something and message.stop_reason == 'tool_use':
# Handle cases where the assistant *only* outputs tool calls and no text
# You might want to print a generic message or just continue
print(f"\u001b[93mClaude\u001b[0m: (Thinking...)")
pass # Let the loop continue to process tool results
except Exception as e:
print(f"\nAn error occurred: {e}")
# Decide how to handle errors, e.g., retry, ask user, exit
# For simplicity, let's just allow the user to type again
read_user_input = True
# Remove the last user message maybe?
# if conversation and conversation[-1]["role"] == "user":
# conversation.pop()
# ... (main function and ReadFileDefinition etc. remain the same) ...
if __name__ == "__main__":
main()
Squint and you’ll see that it’s mostly boilerplate and a little bit that matters: when we get a message back from Claude, we check whether Claude asked us to execute a tool by looking for content_block.type == "tool_use"
. If so, we hand over to _execute_tool
, look up the tool by name in our self.tools
dictionary, parse the input (the SDK likely does this for us into a dict), execute it, and return the result formatted correctly for the next API call (as a tool_result
content block). If it’s an error, we set the is_error
flag. That’s it.
We execute the tool, create a new user message containing the tool results, send that back up to Claude in the next turn, and ask again for Claude’s response. Truly: that’s it. Let me show you.
Mise-en-place, run this in your terminal:
echo 'what animal is the most disagreeable because it always says neigh?' > secret-file.txt
That creates a secret-file.txt
in our directory, containing a mysterious riddle.
In that very same directory, let’s run our new tool-using agent, and ask it to look at the file:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: Claude, buddy, help me solve the riddle in the secret-file.txt file
Claude: Okay, buddy! I can help with that. Let me read the `secret-file.txt` first to see the riddle.
tool: read_file({"path":"secret-file.txt"})
Claude: Got it! The riddle in the file is: "what animal is the most disagreeable because it always says neigh?"
The answer is a **horse**!
It's a play on words: "neigh" sounds like "nay," which means no or disagree. So, the horse is jokingly called disagreeable because it always says "neigh."
You:
Let’s take a deep breath and say it together. Ready? Here we go: holy shit. You just give it a tool and it… uses it when it thinks it’ll help solve the task. Remember: we didn’t say anything about “if a user asks you about a file, read the file”. We also didn’t say “if something looks like a filename, figure out how to read it”. No, none of that. We say “help me solve the thing in this file” and Claude realizes that it can read the file to answer that and off it goes.
Of course, we can be specific and really nudge it towards a tool, but it basically does it all on its own:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: What's going on in main.py? Be brief!
Claude: Okay, I'll check the contents of `main.py` to give you a brief summary.
tool: read_file({"path":"main.py"})
Claude: Based on the code in `main.py`, here's a brief summary:
It sets up a command-line chat interface that allows a user to interact with an Anthropic Claude model. The core logic involves a loop that takes user input, sends the conversation history to the Claude API, and handles the response. It includes functionality to define and use "tools" (currently just `read_file`) allowing the AI to interact with the local file system by reading files when requested. The agent manages the conversation state and executes tools when the model indicates a tool use is necessary.
You:
Spot on. Okay, now that we know how to make Claude use tools, let’s add a few more.
The list_files
tool If you’re anything like me, the first thing you do when you log into a new computer is to get your bearings by running ls
— list files.
Let’s give Claude the same ability, a tool to list files. And here’s the complete implementation of a list_files
tool:
# main.py
# ... (imports, other tools) ...
import os # Need os for walk
# Define the input schema using Pydantic
class ListFilesInput(BaseModel):
path: Optional[str] = Field(None, description="Optional relative path to list files from. Defaults to current directory if not provided.")
# Define the actual function that implements the tool
def list_files(input_data: Dict[str, Any]) -> str:
try:
validated_input = ListFilesInput(**input_data)
target_path_str = validated_input.path if validated_input.path else "."
target_path = pathlib.Path(target_path_str).resolve()
base_path = pathlib.Path.cwd().resolve()
# Security check
if not target_path.is_relative_to(base_path):
raise ValueError("Listing path is outside the allowed directory.")
if not target_path.is_dir():
raise ValueError(f"Path is not a directory: {target_path_str}")
files_list = []
for item in target_path.iterdir():
# Get path relative to the *requested* directory for cleaner output
relative_item_path = item.relative_to(target_path)
if item.is_dir():
files_list.append(f"{relative_item_path}/")
else:
files_list.append(str(relative_item_path))
# Return the list as a JSON string, as Claude handles structured data well
return json.dumps(files_list)
except Exception as e:
return f"Error listing files: {e}"
# Create the ToolDefinition instance for list_files
ListFilesDefinition = ToolDefinition(
name="list_files",
description="List files and directories at a given relative path. If no path is provided, lists files in the current directory.",
input_schema=ListFilesInput,
function=list_files,
)
# ... (Agent class remains the same) ...
# Update main function to include the ListFilesDefinition
def main():
# ... (client setup) ...
# Add ListFilesDefinition to the tools list
tools = [ReadFileDefinition, ListFilesDefinition]
agent = Agent(client, get_user_message, tools)
# ... (agent.run() call) ...
# ... (if __name__ == "__main__": block) ...
Nothing fancy here: list_files
returns the list of files and directories in the current folder (or specified subfolder). There’s a thousand optimizations we could (and probably should) make if this were a serious effort, but since I just want to show you what’s in the wizard’s hat, this is fine.
One thing to note: we return a JSON list of strings and we denote directories with a trailing slash. That’s not required, it’s just something I just decided to do. There’s no fixed format. Anything goes as long as Claude can make sense of it and whether it can you need to figure out by experimentation. You could also prepend each directory with "directory: "
or return a Markdown document with two headers: "directories" and "files". There’s a ton of options and which one you chose depends on what Claude can make the most sense of, how many tokens it requires, how fast it is to generate and read, and so on.
Here, we just want to create a small list_files
tool and the easiest option wins. Returning JSON is often a good bet for LLMs.
Let’s ask Claude what it can see in this directory.
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: what do you see in this directory?
Claude: Okay, I can check the contents of the current directory for you using the `list_files` tool.
tool: list_files({})
Claude: I found the following items in the current directory:
["secret-file.txt", "main.py", ".venv/", "__pycache__/"]
It looks like there's the secret file we looked at earlier, the main Python script for this agent, a virtual environment directory (`.venv/`), and a Python cache directory (`__pycache__/`).
Would you like me to look inside any of these files (except the directories)?
You:
(Note: Your output for list_files
will vary depending on your directory structure and OS)
Works! It can read the directory.
But here’s the thing: Claude knows how to combine these tools. We just need to prompt it in a way that provokes it:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: Tell me about all the Python files in here. Be brief!
Claude: Sure, let me first list the files in the current directory to find the Python files.
tool: list_files({})
Claude: I see one Python file in the main directory: `main.py`. Let me read its contents to summarize it for you.
tool: read_file({"path":"main.py"})
Claude: Okay, I've read `main.py`. Briefly, it's a Python script that creates a command-line chat agent using the Anthropic API. It allows interacting with Claude, manages conversation history, and includes support for "tools" like `read_file` and `list_files` to let the AI interact with the local filesystem.
You:
First it used the list_files
and then it called read_file
with the Python file I asked it about.
Just… just like we would, right? I mean, here, what would you do if I ask you which Anthropic model we are using? Here’s what Claude does for me:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: Which Anthropic model are we using in this project?
Claude: Let me check the source code to find where the model is specified. I'll start by reading the main script.
tool: read_file({"path":"main.py"})
Claude: I found it in the `_run_inference` method within the `Agent` class. The code specifies `model="claude-3-5-sonnet-20240620"`.
You:
Claude looks at the file and finds the answer.
We’re at around 270 lines of Python code now (with all comments). Let that sink in. Once you have, let’s add another tool.
Let it edit_file
The last tool we’re going to add is edit_file
— a tool that lets Claude edit files.
“Holy shit”, you’re thinking now, “this is where the rubber hits the road, this is where he pulls the rabbit out of the hat.” Well, let’s see, shall we?
First, let’s add a definition for our new edit_file
tool:
# main.py
# ... (imports, other tools) ...
# Define the input schema using Pydantic
class EditFileInput(BaseModel):
path: str = Field(..., description="The relative path to the file to edit.")
old_str: str = Field(..., description="The exact text content to search for in the file. Use an empty string to insert at the beginning or create a new file.")
new_str: str = Field(..., description="The text content to replace old_str with. Cannot be the same as old_str unless old_str is empty.")
# Add a validator
from pydantic import model_validator
@model_validator(mode='after')
def check_old_new_diff(self) -> 'EditFileInput':
if self.old_str != "" and self.old_str == self.new_str:
raise ValueError("old_str and new_str must be different if old_str is not empty")
return self
# Helper function to create a file with directories if needed
def create_new_file(file_path: pathlib.Path, content: str) -> str:
try:
# Create parent directories if they don't exist
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Successfully created file {file_path}"
except Exception as e:
raise OSError(f"Failed to create file {file_path}: {e}") from e
# Define the actual function that implements the tool
def edit_file(input_data: Dict[str, Any]) -> str:
try:
validated_input = EditFileInput(**input_data)
file_path = pathlib.Path(validated_input.path)
# Security check
if not file_path.resolve().is_relative_to(pathlib.Path.cwd().resolve()):
raise ValueError("File path is outside the allowed directory.")
if file_path.is_dir():
raise ValueError(f"Path points to a directory, not a file: {validated_input.path}")
if validated_input.old_str == "":
# Create new file or prepend
if file_path.exists():
# Prepend content
existing_content = file_path.read_text()
new_content = validated_input.new_str + existing_content
file_path.write_text(new_content)
return f"OK - Prepended content to {validated_input.path}"
else:
# Create new file
return create_new_file(file_path, validated_input.new_str)
else:
# Replace existing content
if not file_path.exists():
return f"Error: File not found at path: {validated_input.path} (cannot replace content)"
old_content = file_path.read_text()
# Use replace with count=1 to replace only the first occurrence?
# The original Go code used -1 (replace all). Let's stick to that.
# For more robust editing, diff/patch libraries or line-based editing would be better.
if validated_input.old_str not in old_content:
return f"Error: old_str '{validated_input.old_str[:50]}...' not found in the file."
new_content = old_content.replace(validated_input.old_str, validated_input.new_str)
if old_content == new_content:
# This case should ideally be caught by Pydantic validator if old_str != "",
# but double check doesn't hurt.
return "Warning: No changes made. old_str might not have been found or new_str is identical."
file_path.write_text(new_content)
return "OK - File edited successfully."
except Exception as e:
return f"Error editing file: {e}"
# Create the ToolDefinition instance for edit_file
EditFileDefinition = ToolDefinition(
name="edit_file",
description="""Make edits to a text file by replacing content.
Replaces the first occurrence of 'old_str' with 'new_str' in the file specified by 'path'.
- 'old_str' and 'new_str' MUST be different unless 'old_str' is empty.
- If 'old_str' is an empty string (""), 'new_str' will be prepended to the file if it exists, or a new file will be created with 'new_str' as content if the file doesn't exist.
- Use with caution. This performs a simple string replacement.
""",
input_schema=EditFileInput,
function=edit_file,
)
# ... (Agent class remains the same) ...
# Update main function to include the EditFileDefinition
def main():
# ... (client setup) ...
# Add EditFileDefinition to the tools list
tools = [ReadFileDefinition, ListFilesDefinition, EditFileDefinition]
agent = Agent(client, get_user_message, tools)
# ... (agent.run() call) ...
# ... (if __name__ == "__main__": block) ...
That’s right, I again know what you’re thinking: “string replacement to edit files?” That’s right. Claude Sonnet handles structured edits well, but a simple, robust replace
tool is surprisingly effective for many tasks, especially if the LLM first reads the file to get the exact old_str
. We instruct it to use an empty old_str
for file creation or prepending.
The implementation checks inputs, uses pathlib
for file operations, handles creation via our helper, and performs the replacement, writing the result back.
And… we’re ready, but are you? Are you ready to let it rip?
Thought so, let’s do this. Let’s tell Claude to create a new FizzBuzz function in JavaScript.
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: hey claude, create fizzbuzz.js that I can run with Nodejs and that has fizzbuzz in it and executes it
Claude: Okay, I can create that `fizzbuzz.js` file for you. I'll use the `edit_file` tool with an empty `old_str` to create the new file and populate it with the JavaScript code.
tool: edit_file({"path":"fizzbuzz.js","old_str":"","new_str":"/**\n * Classic FizzBuzz program in JavaScript.\n * Prints numbers from 1 to 100.\n * For multiples of 3, prints \"Fizz\".\n * For multiples of 5, prints \"Buzz\".\n * For multiples of both 3 and 5, prints \"FizzBuzz\".\n */\n\nfunction fizzBuzz(limit) {\n for (let i = 1; i <= limit; i++) {\n let output = '';\n if (i % 3 === 0) {\n output += 'Fizz';\n }\n if (i % 5 === 0) {\n output += 'Buzz';\n }\n console.log(output || i);\n }\n}\n\nconsole.log('Running FizzBuzz up to 100:');\nfizzBuzz(100);\n"})
Claude: I've created the `fizzbuzz.js` file. It contains a JavaScript function `fizzBuzz` that implements the classic rules, and it calls this function to print the sequence up to 100.
You should be able to run it using Node.js like this:
```bash
node fizzbuzz.js
Let me know if you'd like any modifications!
You:
*(Code generated by Claude may vary slightly)*
Right?! It’s impressive, isn’t it? And that’s the most basic implementation of edit_file
— of an agent in general — you can probably come up with.
But, did it work? Yes, it did:
$ node fizzbuzz.js
Running FizzBuzz up to 100:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
... (output continues to 100) ...
Amazing. But hey, let’s have it actually edit a file and not just create one.
Here’s what Claude does when I ask it to “Please edit the fizzbuzz.js
so that it only prints until 15”:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: Please edit fizzbuzz.js so that it only prints until 15
Claude: Okay, I can do that. I need to change the number 100 to 15 in the function call at the end of the script. First, I'll read the file to get the exact content to replace.
tool: read_file({"path":"fizzbuzz.js"})
Claude: Thanks! Now I have the content. I see the line `fizzBuzz(100);` near the end. I'll replace that part. I should also update the comment `Running FizzBuzz up to 100:` just above it. Let's do the function call first.
tool: edit_file({"path":"fizzbuzz.js","old_str":"fizzBuzz(100);","new_str":"fizzBuzz(15);"})
Claude: Great, that part is updated. Now I'll update the preceding comment line.
tool: edit_file({"path":"fizzbuzz.js","old_str":"console.log('Running FizzBuzz up to 100:');","new_str":"console.log('Running FizzBuzz up to 15:');"})
Claude: Alright, I've edited `fizzbuzz.js`. I changed the limit from 100 to 15 in both the code that calls the function and the comment right before it. The script should now only print the FizzBuzz sequence up to 15 when you run `node fizzbuzz.js`.
You:
It reads the file, identifies the exact strings to change (code and comment!), and uses the edit_file
tool twice to make the modifications.
And it still works:
$ node fizzbuzz.js
Running FizzBuzz up to 15:
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
Okay, let’s do one more and ask it to do the following:
Create a congrats.js script that rot13-decodes the following string ‘Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!’ and prints it
Maybe a tall order. Let’s see:
$ python main.py
Chat with Claude (use 'ctrl-d' or 'ctrl-c' to quit)
You: Create a congrats.js script that rot13-decodes the following string 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!' and prints it
Claude: Sure thing! I can create a `congrats.js` file with a simple ROT13 decoding function and apply it to your string. I'll use the `edit_file` tool to create the new file.
tool: edit_file({"path":"congrats.js","old_str":"","new_str":"/**\n * Decodes a ROT13 encoded string.\n */\n\nfunction rot13(str) {\n return str.replace(/[a-zA-Z]/g, function(char) {\n const charCode = char.charCodeAt(0);\n // Handle uppercase letters (A-Z: 65-90)\n if (charCode >= 65 && charCode <= 90) {\n return String.fromCharCode(((charCode - 65 + 13) % 26) + 65);\n }\n // Handle lowercase letters (a-z: 97-122)\n if (charCode >= 97 && charCode <= 122) {\n return String.fromCharCode(((charCode - 97 + 13) % 26) + 97);\n }\n // Return non-alphabetic characters unchanged\n return char;\n });\n}\n\nconst encodedString = 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!';\nconst decodedString = rot13(encodedString);\n\nconsole.log(decodedString);\n"})
Claude: I've created the `congrats.js` file. It defines a `rot13` function and uses it to decode the string you provided, then prints the result to the console.
You can run it with Node.js:
```bash
node congrats.js
It should print the decoded message!
You:
Does it work? Let’s try it:
$ node congrats.js
Congratulations on building a code-editing agent!
It does!
Isn’t this amazing? If you’re anything like all the engineers I’ve talked to in the past few months, chances are that, while reading this, you have been waiting for the rabbit to be pulled out of the hat, for me to say “well, in reality it’s much, much harder than this.” But it’s not.
This is essentially all there is to the inner loop of a code-editing agent. Sure, integrating it into your editor, tweaking the system prompt, giving it the right feedback at the right time, a nice UI around it, better tooling around the tools (maybe using diffs instead of string replacement), support for multiple agents, running shell commands, and so on — we’ve built some of that in Amp, but it didn’t require moments of genius. All that was required was practical engineering and elbow grease.
These models are incredibly powerful now. Roughly 400 lines of Python code and three tools and now you’re able to talk to an alien intelligence that edits your code. If you think “well, but we didn’t really…” — go and try it! Go and see how far you can get with this. I bet it’s a lot farther than you think.
That’s why we think everything’s changing.
You can find the final code here
Subscribe to my newsletter
Read articles from Mehmet Öner Yalçın directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
