A Beginner's Guide — No Magic, Just Code
What Are We Building?
We're going to build a code-editing agent — a terminal program where you chat with an AI that can read your files, list your folders, and edit your code. All in under 300 lines of Python.
When you watch tools like Cursor or Copilot edit files, run commands, and recover from errors, it looks like wizardry. It's not. It's three things:
A language model
A loop
Three simple tools
That's the entire secret. Let's prove it.
What You Need
Requirement | How to Get It |
|---|---|
Python 3.13 | |
An OpenAI API key | |
A terminal | You already have one |
10 minutes | You've got this |
Step 0: Project Setup
Open your terminal and run:
mkdir code-editing-agent
cd code-editing-agent
Set your API key:
# macOS / Linux
export OPENAI_API_KEY="sk-your-key-here"
# Windows (PowerShell)
$env:OPENAI_API_KEY = "sk-your-key-here"
Install the SDK:
pip install openai
Create our file:
touch agent.py
That's all the setup.
Step 1: A Simple Chatbot (The Heartbeat)
Before we build an agent, let's build the simplest possible chat program. Open agent.py and type this:
from openai import OpenAI
def run_agent():
client = OpenAI() # reads OPENAI_API_KEY automatically
conversation = [] # this list IS our memory
print("Chat with the assistant (use Ctrl-C to quit)\n")
while True:
try:
user_input = input("\033[94mYou\033[0m: ")
except (EOFError, KeyboardInterrupt):
print("\nGoodbye!")
break
# Add the user's message to the conversation
conversation.append({"role": "user", "content": user_input})
# Send the ENTIRE conversation to the model
response = client.chat.completions.create(
model="gpt-4o",
max_tokens=1024,
messages=conversation,
)
# Get the reply
reply = response.choices[0].message.content
print(f"\033[93mAssistant\033[0m: {reply}")
# Add the reply to the conversation
conversation.append({"role": "assistant", "content": reply})
if __name__ == "__main__":
run_agent()
Run it:
python agent.py
You: Hey! I'm Alex!
Assistant: Hi Alex! Nice to meet you. How can I help?
You: What's my name?
Assistant: Your name is Alex!
Why does it remember your name? Because we keep appending messages to the conversation list and send the whole thing every time. The API is stateless — it has no memory of its own. Our list is the model's memory.
This is every AI chat app you've ever used, except it's 25 lines of code in a terminal.
Step 2: Understanding Tools (The Key Idea)
Here's the single most important concept in this guide:
A "tool" is a structured way to tell the model: "if you need to do X, ask me, and I'll do it for you."
The model can only generate text. It can't read your files. It can't run code. It can't touch your filesystem. But it can ask you to do those things.
Think of it like telling a friend: "In our conversation, wink if you want me to open the door." Tools are a structured version of that wink.
We can prove this without changing any code:
You: When I ask about the weather, reply with get_weather(location).
I'll then tell you the result. Understood?
Assistant: Understood!
You: What's the weather in Paris?
Assistant: get_weather(Paris)
You: Sunny, 22 degrees
Assistant: Paris is lovely right now — sunny and 22°C!
The model "used a tool." We just did it manually. Now let's automate it.
Step 3: Anatomy of a Tool
Every tool needs exactly four things:
Part | What It Does | Example |
|---|---|---|
Name | A short identifier |
|
Description | Tells the model when and how to use it |
|
Parameters | Tells the model what inputs the tool expects (as JSON Schema) |
|
Function | The actual Python code that runs |
|
Here's our first tool — a function that reads a file:
def read_file(path: str) -> str:
"""Read a file and return its contents."""
with open(path, "r") as f:
return f.read()
And here's how we describe it to the API:
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a file at a given relative path.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path to the file.",
}
},
"required": ["path"],
},
},
}
The parameters block is a JSON Schema — just a standard way to say "I expect one string called path, and it's required." The model reads this and knows exactly what to send us.
Step 4: The Three Tools
Tool 1: read_file — Let the model see file contents
def read_file(path: str) -> str:
with open(path, "r") as f:
return f.read()
Tool 2: list_files — Let the model see what's in a directory
def list_files(path: str = ".") -> str:
if not path:
path = "."
results = []
for root, dirs, files in os.walk(path):
for d in sorted(dirs):
rel = os.path.relpath(os.path.join(root, d), path)
results.append(rel + "/")
for f in sorted(files):
rel = os.path.relpath(os.path.join(root, f), path)
results.append(rel)
return json.dumps(results, indent=2)
Tool 3: edit_file — Let the model create and modify files
def edit_file(path: str, old_str: str, new_str: str) -> str:
if not path or old_str == new_str:
raise ValueError("Invalid input parameters")
# File doesn't exist + old_str is empty → create a new file
if not os.path.exists(path) and old_str == "":
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
with open(path, "w") as f:
f.write(new_str)
return f"Created {path}"
# Otherwise: read, replace, write back
with open(path, "r") as f:
content = f.read()
if old_str and old_str not in content:
raise ValueError("old_str not found in file")
with open(path, "w") as f:
f.write(content.replace(old_str, new_str))
return "OK"
Why string replacement for editing? Because it's simple and the model is very good at producing exact string matches for the parts it wants to change. No diff algorithms needed.
Step 5: The Tool Registry and Executor
We need two things: a list of tool descriptions to send to the API, and a way to run the right function when the model asks.
The registry (sent to the API)
TOOLS = [
{"type": "function", "function": {"name": "read_file", ...}},
{"type": "function", "function": {"name": "list_files", ...}},
{"type": "function", "function": {"name": "edit_file", ...}},
]
The lookup map (used locally)
TOOL_FUNCTIONS = {
"read_file": lambda inp: read_file(inp["path"]),
"list_files": lambda inp: list_files(inp.get("path", ".")),
"edit_file": lambda inp: edit_file(inp["path"], inp["old_str"], inp["new_str"]),
}
The executor
def execute_tool(name: str, raw_arguments: str) -> str:
func = TOOL_FUNCTIONS.get(name)
if not func:
return f"Error: unknown tool '{name}'"
tool_input = json.loads(raw_arguments) # parse the JSON string
print(f"\033[92mtool\033[0m: {name}({raw_arguments})")
try:
return func(tool_input)
except Exception as e:
return f"Error: {e}"
Note: The API sends tool arguments as a JSON string, not a dict. That's why we call
json.loads()to parse it.
Step 6: The Agent Loop (The Whole Secret)
This is the complete, final loop. Read the inline comments — every line matters:
def run_agent():
client = OpenAI()
conversation = []
model = "gpt-4o"
print(f"Chat with {model} (use Ctrl-C to quit)\n")
read_user_input = True # ← this flag is the key trick
while True:
# ── A: Get user input (only when the model isn't mid-tool-use) ──
if read_user_input:
try:
user_input = input("\033[94mYou\033[0m: ")
except (EOFError, KeyboardInterrupt):
print("\nGoodbye!")
break
conversation.append({"role": "user", "content": user_input})
# ── B: Send conversation + tools to the model ──
response = client.chat.completions.create(
model=model,
max_tokens=4096,
tools=TOOLS, # ← tell the model what tools exist
messages=conversation,
)
assistant_message = response.choices[0].message
conversation.append(assistant_message)
# ── C: Process the response ──
tool_results = []
# Print any text the model wrote
if assistant_message.content:
print(f"\033[93mAssistant\033[0m: {assistant_message.content}")
# Run any tools the model requested
if assistant_message.tool_calls:
for tool_call in assistant_message.tool_calls:
result = execute_tool(
tool_call.function.name,
tool_call.function.arguments,
)
tool_results.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
# ── D: Decide what happens next ──
if tool_results:
conversation.extend(tool_results) # send results back
read_user_input = False # keep looping
else:
read_user_input = True # ask the user
How the loop works, step by step:
┌───────────────────────────────────────────────────┐
│ User types a message │
│ ↓ │
│ Add it to `conversation` │
│ ↓ │
│ Send `conversation` + `TOOLS` to the model │
│ ↓ │
│ Model replies with text and/or tool_calls │
│ ↓ │
│ ┌─ Has .content? → Print it │
│ └─ Has .tool_calls? → Run each, collect results │
│ ↓ │
│ Any tool results? │
│ YES → Add role="tool" messages, loop back │
│ to the model WITHOUT asking user │
│ NO → Ask user for next message │
└───────────────────────────────────────────────────┘
The read_user_input flag is the trick: when the model uses a tool, we don't ask the user for input. We send the tool result straight back and let the model keep working. It might use 1 tool, or 5 in a row. The loop handles it all.
Step 7: How Tool Results Get Back to the Model
When the model requests a tool, here's exactly what flows through the conversation:
conversation = [
{"role": "user", "content": "What's in main.py?"},
{"role": "assistant", ...tool_calls: [
{id: "call_abc", function: {name: "read_file", arguments: '{"path":"main.py"}'}}
]},
{"role": "tool", "tool_call_id": "call_abc", "content": "import os\n..."},
{"role": "assistant", "content": "Here's what's in main.py: ..."},
]
Each tool result is its own message with role: "tool". The tool_call_id links it back to the specific request. The model reads the result and continues.
Step 8: Try It!
Save the complete file (provided as agent.py alongside this guide). Then:
python agent.py
Test 1: Read a file
Create a test file first:
echo 'What animal always says neigh?' > riddle.txt
You: Solve the riddle in riddle.txt
tool: read_file({"path": "riddle.txt"})
Assistant: The answer is a horse! They always say "neigh."
You didn't tell it to read the file. You said "solve the riddle." The model figured out on its own that it should use read_file to get the contents.
Test 2: List files
You: What files are in this directory?
tool: list_files({})
Assistant: I can see agent.py, riddle.txt, and ...
Test 3: Create a file from scratch
You: Create fizzbuzz.js that I can run with Node.js
tool: edit_file({"path": "fizzbuzz.js", "old_str": "", "new_str": "function fizz..."})
Assistant: Done! Run it with: node fizzbuzz.js
Verify:
node fizzbuzz.js
Test 4: Edit an existing file
You: Edit fizzbuzz.js so it only goes up to 15
tool: read_file({"path": "fizzbuzz.js"})
tool: edit_file({"path":"fizzbuzz.js", "old_str":"100", "new_str":"15"})
Assistant: Updated! Now prints FizzBuzz from 1 to 15.
Notice: it read the file first to understand it, then edited it. Just like you would.
Test 5: The grand finale
You: Create congrats.js that rot13-decodes
'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!' and prints it
tool: edit_file({"path": "congrats.js", "old_str": "", "new_str": "..."})
$ node congrats.js
Congratulations on building a code-editing agent!
Bonus: Use Any OpenAI-Compatible Provider
Because so many providers speak the same API format, your agent works with them too. Just set environment variables:
# Groq (fast inference)
export OPENAI_API_KEY="gsk_your-groq-key"
export OPENAI_BASE_URL="https://api.groq.com/openai/v1"
export AGENT_MODEL="llama-3.3-70b-versatile"
# Together AI
export OPENAI_BASE_URL="https://api.together.xyz/v1"
export AGENT_MODEL="meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"
# Ollama (local, free)
export OPENAI_BASE_URL="http://localhost:11434/v1"
export OPENAI_API_KEY="unused"
export AGENT_MODEL="llama3.1"
# OpenRouter
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export AGENT_MODEL="openai/gpt-4o"
Then run the same python agent.py. The agent handles this with three lines:
base_url = os.environ.get("OPENAI_BASE_URL")
model = os.environ.get("AGENT_MODEL", "gpt-4o")
client = OpenAI(base_url=base_url) if base_url else OpenAI()
The Complete Architecture
┌──────────────┐ ┌───────────────────────┐ ┌────────────────┐
│ Your │ │ agent.py │ │ LLM API │
│ Terminal │◄────►│ │◄────►│ (OpenAI or │
│ │ │ conversation = [] │ │ compatible) │
└──────────────┘ │ TOOLS = [...] │ └────────────────┘
│ TOOL_FUNCTIONS │
│ the loop │
└────────┬───────────────┘
│
┌────────▼───────────────┐
│ Your Filesystem │
│ read / list / edit │
└────────────────────────┘
What We Learned
Concept | The Reality |
|---|---|
An agent | A language model + a loop + tools |
A tool | A name, a description, a schema, and a function |
Tool use | The model says "call X" → we run X → we send the result back |
The loop | Keep going until the model stops requesting tools |
The conversation | A list that grows. We send the whole thing every time. |
Lines of code | Under 300 |
There's no magic. There's no secret algorithm. It's a language model that knows when to ask for help, a loop that listens, and three Python functions that touch your filesystem. Everything else — the fancy UIs, the IDE integrations, the multi-agent orchestration — is engineering built on top of exactly this foundation.
Now go build something with it.



Discussion
Responses
No comments yet. Be the first to add one.