Creating your own agent
Introduction
What if I tell you that Claude Code can be implemented in around 150 lines of code?!
Of course there's more to it, many different features that piled up along its development. But the core of a coding agent like Claude Code, Codex, Open Code or any other, is pretty easy to implement. And that's what we're going to build together here in this short course.
The architecture
Before anything, what the hell is an agent?! More specifically a coding agent.
An agent is basically a LLM with access to tools, running in a loop:
graph TD A[Agent] --> B[LLM] A --> C[Tools]
The agent sends messages to the LLM and it answers back. With one particular detail that makes everything really explode in possibilities: the LLM can ask for the agent to execute tools!
But why is this so important?! Remember that LLMs are "static" models. They were trained a few months ago with the state of the Internet back there. So everything an LLM knows is frozen in time: it has no access to the current world, no access to your computer, no access to call APIs to bring in more signals from the outside.
That's why we give tools to LLMs! It's a pretty ingenious way to allow them to grab signals from the current world and act in this world. With tools they can read any files in your computer, make requests to any API in the Internet or run any program available in your machine.
OK, tools are pretty cool, but how they do it? Well, the loop is really straightforward:
- 1: The agent send a message to the LLM
- 2: The LLM knows it has access to tools, so it asks the agent to execute those tools
- 3: The agent executes the tools, grab the results and send it back to the LLM
- 4: The LLM use the tools output to improve it's "reasoning" and send an answer back
- 5: Repeat from step 1 until the user is finished or other criteria is met
It makes much easier to think of it with a practical example.
Let's say you want to know what's the weather in London today (you could only have the agent returning "Cloudy." and be right 80% of time, but let's actually use an LLM for it :D).
Remember that the LLM doesn't know anything about today's weather in any place of the world. So it'll need access to some tool that will give it access to that external signal! We can see how it happens in this sequence:
sequenceDiagram
Agent->>+LLM: What's the temperature?
LLM->>+Agent: <toolcall>get_weather(london)</toolcall>
Agent->>+ToolRegistry: get_weather(london)
ToolRegistry->>Agent: {"temp": 9, "unit": "Celcius", "weather": "Cloudy"}
Agent->>+LLM: Tool result: 9 deg
LLM->>+Agent: The temperature in London is 9 degrees Celcius.
Here we're using a pretty simple tool as an example, but it could be literally any procedure!
And now you must be questioning: OK, but how the LLM knows about the get_weather() tools?!.
Well, I omitted one important step! Before sending any message to the LLM, we should "setup" it for success. We do that by sending an initial message telling everything the LLM should know before starting. We call it a system prompt. It's just a fancy name for an initial message.
In the system prompt we'll have something like:
You are an weather assistant.
You should provide the current temperature in a single sentence.
You have access to these tools:
get_weather(location): Returns the current temperature and weather in this JSON schema:
{"temp": float, "unit": string, "weather": string}
Example:
get_weather(london): {"temp": 10, "unit": "Celcius", "weather": "Sun with clouds"}
Important: when answering, use the temperature unit requested by the user, if no unit is
requested, default to Celcius.
Note that we're also defining a "persona" for the LLM (and for the agent): from now on it will be a weather assistant, nothing else. And it will have access to a tool that returns the weather in a JSON format.
But we haven't implemented the tool yet! Yep, that's right, for now we only need to define what the tool is about and the shape of its inputs and outputs. The implementation will be defined in the agent later and it's totally independent of the LLM!
Alright, I believe it's enough of theory, let's put this in practice to create our own agent!
Setup
We're going to use Python to code the agent, but you can use any programming language you want. If you never used Python before, please check our How to start coding course. And if you never used an Unix-based terminal before, please check our How to use terminals course.
First of all, create a folder to host your project, a Python environment and install the OpenAI SDK:
mkdir ai-agent
cd ai-agent
python -m venv .venv
source .venv/bin/activate
pip install openai
I recommend you creating an account in OpenRouter. You can actually use OpenAI's LLMs directly, but OpenRouter allows you to switch between a whole ecosystem of LLMs, including opensource ones! Just head to OpenRouter, add like $10 in credits, create an API key and copy and paste it here:
export OPENROUTER_API_KEY=paste the API here
Once you have that setup, we're ready to create our baby!
Step 1: The LLM
Let's first test our connection to OpenRouter through the OpenAI SDK. That's the library we'll be using to send messages to the LLM.
Create a file called agent.py with the following:
import os
from openai import OpenAI
MODEL = "anthropic/claude-opus-4-6"
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)
Here we're basically creating an instance of OpenAI and letting it know we want to use Anthropic's
Claude Opus 4.6 LLM. We're also passing the OpenRouter API key you just created.
messages = [
{"role": "user", "content": "What's your name?"},
]
response = client.chat.completions.create(
model=MODEL,
messages=messages,
)
message = response.choices[0].message
print(message)
And then we send a message to the LLM, asking about its name. You should see something like the following as response:
ChatCompletionMessage(
content="My name is Claude. I'm an AI assistant made by Anthropic. How can I help you today?",
refusal=None,
role='assistant',
annotations=None,
audio=None,
function_call=None,
tool_calls=None,
reasoning=None
)
Great! So we have a way to talk to the LLM. We're half way there, we only need to add the tools!
Step 2: The tools
You'll probably thinking: Claude Code and other agents probably need tons of tools to make good use of the external world, like the computer the agent is running or the Internet.
Well, yes and no :-) It really depends on how those LLM models were trained and post-trained.
What we know these days is that they were post-trained a lot on reading, writing and editing
code, as expected. But the one tool that opens the world to them is the bash tool! And
the coding LLMs are well versed on its usage!
With the bash tool you can basically let the agent execute any program! You want access
to the Internet? Just run the curl command. Want to check the size of the disk? Just run df.
Do you want to check which processes are running? Run ps.
Agents and Unix-based systems, where everything is a file, is a powerful combination. LLMs understand text really well, so it's not a surprise that they can work great with CLI programs in a Unix box that basically receives and returns text.
And if you still don't trust me that this is enough, there are agent harness like Pi that only use those 4 tools that we're creating here, nothing else! I use Pi daily (well, I used it to create this webapp you're using :-)) and I don't feel any need to use Claude Code.
Alright, so let's implement those tools! First, we need to define what they are all about: their name, description and parameters:
tools = [
{
"type": "function",
"function": {
"name": "read",
"description": "Read a UTF-8 text file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write",
"description": "Overwrite a UTF-8 text file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "edit",
"description": "Replace exact text in a file",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"},
},
"required": ["path", "old_text", "new_text"],
},
},
},
{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command in the current directory",
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"],
},
},
},
]
Have defined the tools signatures and descriptions, let's finally implement them as simple Python functions:
from pathlib import Path
import subprocess
def tool_read(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
def tool_write(path: str, content: str) -> str:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return f"wrote {path}"
def tool_edit(path: str, old_text: str, new_text: str) -> str:
p = Path(path)
original = p.read_text(encoding="utf-8")
if old_text not in original:
return "edit failed: old_text not found"
updated = original.replace(old_text, new_text, 1)
p.write_text(updated, encoding="utf-8")
return f"edited {path}"
def tool_bash(command: str) -> str:
result = subprocess.run(
command,
shell=True,
text=True,
capture_output=True,
timeout=30,
)
output = (result.stdout or "") + (result.stderr or "")
return output[:8000] or "(no output)"
def execute_tool(name: str, args: dict) -> str:
try:
if name == "read":
return tool_read(args["path"])
if name == "write":
return tool_write(args["path"], args["content"])
if name == "edit":
return tool_edit(args["path"], args["old_text"], args["new_text"])
if name == "bash":
return tool_bash(args["command"])
return f"unknown tool: {name}"
except Exception as e:
return f"tool error: {e}"
Note that we also added a function called execute_tool() to map the name of
a tool to its implementation and then run and return the output.
Step 3: The agent loop
Now we have to put those tools to use, executing them when the LLM asks us to:
import json
SYSTEM_PROMPT = (
"You are a precise coding assistant. "
"Use tools when needed. Keep changes minimal and explain clearly."
)
def run_agent(user_request: str):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_request},
]
while True:
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools,
)
message = response.choices[0].message
messages.append(message)
if not message.tool_calls:
print(f"\nassistant> {message.content}\n")
return
for tool_call in message.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments or "{}")
result = execute_tool(name, args)
print(f"tool> {name}({args})\n\t{result}")
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
}
)
Let's take a close look on what's happening here because there's actually two loops running.
First we send a system prompt, instructing the LLM to behave like a coding assistant and to use the tools we defined.
The rest of the code you already know, but note one important change: when
we receive the response from the LLM, we check for message.tool_calls.
There will be tool calls if the LLM "felt" the need to execute them to
provide a better answer to the user!
So, we iterate in a second loop:
for each tool call we run the function matching the tool name, we grab
the result and append it to the messages we already sent. Note
that we set the role as tool, indicating for the LLM that this is the
result of that tool it asked before.
Then we repeat the while-true loop step again, now with the tool
results appended to the messages. This time the LLM will use the tool
results to improve its "reasoning" and finally answer the user with
more context about the question.
If more tools are needed, it will request more tool calls. Otherwise it will just show the response to the user and finish the loop.
Step 4: The chat loop
Sorry, I lied again, there's actually another final loop :-) The outermost loop is the one that makes this an actual chat like you see in Claude Code and other agents.
It's really simple: we wait for an input from the user and send it to the agent. And we repeat it until the user exits:
if __name__ == "__main__":
# ANSI colors: cyan prompt, reset after text
CYAN = "\033[96m"
RESET = "\033[0m"
print("My Coding Agent. Type 'exit' to quit.\n")
while True:
try:
user_text = input(f"{CYAN}agent>{RESET} ").strip()
except (EOFError, KeyboardInterrupt):
print("\nbye")
break
if not user_text:
continue
if user_text.lower() in {"exit", "quit"}:
print("bye")
break
run_agent(user_text)
Running
Is that it?! Yep, that's it! Congratulations! You built your own Claude Code!
To run it, just remember to set the OpenRouter API key and call the Python interpreter:
export OPENROUTER_API_KEY="your-key"
python agent.py
You should see a prompt like this one. Ask whatever you want and see if it can use the tools as expected:
What's next?!
You can see the whole implementation here. Less than 170 lines of code! Download it, adapt for your own needs, translate to your favourite programming language.
The important thing here is that now you know how Claude Code and other agents are implemented.
There is still one big piece missing though: the model! But that's for another time, in another course! Please keep following and sign up to get access to it when it's published!