How to build a coding agent
I decided to follow Geoffrey Huntley’s advice and build a simple coding agent to make sure I understand the fundamentals.
Geoffrey puts it this way:
It’s not hard to build an agent. It’s 300 lines of code running in a loop with LLM tokens.
Let’s build it! I decided to go with gpt-5.6-luna. It’s a very capable model at $0.20 per 1M input tokens.
I asked an agent to build the auth for me, so I could reuse my subscription:
Create a new python project with uv and wire openai client creation into it. I want to use my chatgpt subscription account to log in.
The agent sorted out the auth and built a simple main with the OpenAI client for me:
def auth(
*, force_login: bool = False, auth_dir: Path = AUTH_DIR, timeout: float = 300
) -> str:
# let's omit the auth code, it's boring
def main():
llm = OpenAI(api_key=auth())
if __name__ == "__main__":
main()
Now we can build the simplest form of a chat. Get a user prompt, append it to the conversation, and send the conversation to the model. Get the response and append it to the conversation. Loop it:
llm = OpenAI(api_key=auth())
conversation = []
while True:
user_input = input("> ")
conversation.append({"role": "user", "content": user_input})
output_items = run_inference(llm, conversation, tools)
conversation.extend(output_items)
Let’s unpack the run_inference function. We print the tokens as they arrive and return the model’s output:
def run_inference(
llm: OpenAI,
conversation: list,
*,
model: str = "gpt-5.6-luna",
) -> list[ResponseOutputItem]:
"""Stream one model response and return its completed output items in order.
Prints text as it arrives. The caller appends the returned items to history
and handles any function calls; this function does not modify conversation.
"""
completed = False
output_items = {}
with llm.responses.create(
model=model,
input=conversation,
store=False,
stream=True,
) as stream:
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "response.output_item.done":
output_items[event.output_index] = event.item
elif event.type == "response.completed":
completed = True
elif event.type == "response.failed":
raise RuntimeError(event.response.error)
elif event.type == "response.incomplete":
raise RuntimeError("The model response was incomplete.")
elif event.type == "error":
raise RuntimeError(event.message)
print()
if not completed:
raise RuntimeError("The stream ended without a completed response")
return [output_items[i] for i in sorted(output_items)]
We can now chat with the model:
$ uv run python main.py
> you are a friendly assistant
Hi! How can I help you today?
Good. We have a chatbot now. For it to become an agent, we have to give it tools. I’ll follow Geoffrey’s approach to demonstrate this:
$ uv run python main.py
> when you need to read a file, respond with read_file(<path>)
Understood. When I need to read a file, I’ll respond with `read_file(<path>)`.
> is README.md empty or not?
read_file(README.md)
That’s it. We’ve just taught the model to use our tool. Whenever there is a cue for a model to read a file, it will call read_file(<path>). We can then parse this output inside the loop and call the real function. The tool is pretty straightforward:
def read_file(path: str) -> str:
print(f"📖 Read({path})")
with open(path, "r") as f:
return f.read()
tool_registry = {
("files", "read_file"): read_file
}
Now the tool spec:
def get_tools() -> list[dict]:
return [
{
"type": "namespace",
"name": "files",
"description": "Tools for working with files.",
"tools": [
{
"type": "function",
"name": "read_file",
"description": "Read the contents of a given relative file path, never for directories.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to a file.",
}
},
"required": ["path"],
"additionalProperties": False,
},
"strict": True,
},
],
},
]
Wire it into run_inference. We add another while loop to process the model’s tool calls until none remain. We add each tool call’s output to the conversation so the model can infer the next steps:
def run_inference(
llm: OpenAI,
conversation: list,
tools: list[dict],
*,
model: str = "gpt-5.6-luna",
) -> list[ResponseOutputItem]:
# ...
with llm.responses.create(
model=model,
input=conversation,
tools=tools,
store=False,
stream=True,
) as stream:
# ...
def main():
llm = OpenAI(api_key=auth())
tools = get_tools()
conversation = []
while True:
user_input = input("> ")
conversation.append({"role": "user", "content": user_input})
while True:
# print(grey(conversation))
llm.api_key = auth()
output_items = run_inference(llm, conversation, tools)
conversation.extend(output_items)
calls = [i for i in output_items if i.type == "function_call"]
if not calls:
break
for call in calls:
try:
args = json.loads(call.arguments)
func = tool_registry[(call.namespace, call.name)]
result = func(**args)
output = json.dumps({"result": result})
except Exception as e:
output = json.dumps({"error": str(e)})
print()
conversation.append(
{
"type": "function_call_output",
"call_id": call.call_id,
"output": output,
}
)
Let’s test it out:
$ uv run python main.py
> Is .venv in .gitignore? Also, list the deps we use from pyproject.toml
📖 Read(.gitignore)
📖 Read(pyproject.toml)
Yes. `.venv` is listed in `.gitignore` under **Virtual environments**.
Dependencies from `pyproject.toml`:
- `httpx>=0.28.1`
- `openai>=3.29.0`
- `pyjwt[crypto]>=2.10.1`
The model responded with two tool calls. This is how tool chaining happens.
I then asked Codex to build the rest of the tools for me:
Read main.py. It is a minimal coding agent implementation. I want you to add a few more tools:
- list_files(<path>): List files and directories at a given path. If no path is provided, lists files in the current directory.
- edit_tool(<path>): Edit a text file by replacing one unique exact match of old_str with new_str. The strings must differ. Use an empty old_str to append to an existing file or create a missing file and its parent directories.
- bash_tool(<command>): Execute a bash command and return its output. Use this to run shell commands.
The tools (I will omit the schemas):
def edit_tool(path: str, old_str: str, new_str: str) -> str:
"""Replace a unique exact match, or append/create when old_str is empty."""
print(f"✏️ Edit({path})")
if not path or old_str == new_str:
raise ValueError("invalid input parameters")
file_path = Path(path)
try:
with file_path.open("r", encoding="utf-8", newline="") as file:
content = file.read()
except FileNotFoundError:
if old_str:
raise
file_path.parent.mkdir(parents=True, exist_ok=True)
with file_path.open("w", encoding="utf-8", newline="") as file:
file.write(new_str)
return f"Successfully created file {path}"
if old_str:
count = content.count(old_str)
if count == 0:
raise ValueError("old_str not found in file")
if count > 1:
raise ValueError(f"old_str found {count} times in file, must be unique")
content = content.replace(old_str, new_str, 1)
else:
content += new_str
with file_path.open("w", encoding="utf-8", newline="") as file:
file.write(content)
return "OK"
def bash_tool(command: str) -> str:
"""Execute a bash command and return its combined stdout and stderr."""
print(f"💻 Bash({command})")
result = subprocess.run(
["bash", "-c", command],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
return result.stdout
tool_registry = {
("files", "read_file"): read_file,
("files", "list_files"): list_files,
("files", "edit_tool"): edit_tool,
("shell", "bash_tool"): bash_tool,
}
We can now use our own micro agent to build another tool for us:
$ uv run python main.py
> Read main.py. It is a minimal coding agent implementation. Your task is to add the code_search_tool. The tool should be used every time an agent needs to search for code patterns. The tool should call ripgrep (rg).
Use this input schema as a reference:
type CodeSearchInput struct {
Pattern string `json:"pattern" jsonschema_description:"The search pattern or regex to look for"`
Path string `json:"path,omitempty" jsonschema_description:"Optional path to search in (file or directory)"`
FileType string `json:"file_type,omitempty" jsonschema_description:"Optional file extension to limit search to (e.g., 'go', 'js', 'py')"`
CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema_description:"Whether the search should be case sensitive (default: false)"`
}
It came up with this:
def code_search_tool(
pattern: str,
path: str | None = None,
file_type: str | None = None,
case_sensitive: bool = False,
) -> str:
"""Search with ripgrep, optionally limiting path, extension, and case."""
search_path = path or "."
command = ["rg", "--line-number", "--no-heading"]
command.append("--case-sensitive" if case_sensitive else "--ignore-case")
if file_type:
extension = file_type.lstrip(".")
if not extension:
raise ValueError("file_type must contain a file extension")
command.extend(["--glob", f"*.{extension}"])
# Invoke rg directly rather than through a shell. Besides avoiding shell
# interpretation of patterns, ``--`` allows searches for patterns beginning
# with a dash. rg's exit status of 1 (no matches) is a normal result.
command.extend(["--", pattern, search_path])
print(
f"🔎 Search({pattern}, {search_path}"
f"{', ' + file_type if file_type else ''}"
f"{', case-sensitive' if case_sensitive else ''})"
)
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
return result.stdout
We can now try to use it:
$ uv run python main.py
> search the source code for the auth implementation
📂 List(.)
🔎 Search(auth, .)
🔎 Search(login|logout|password|token|session|authenticate|authorization, .)
📖 Read(main.py)
The auth implementation is in **`main.py`**, primarily lines **42–254**.
...
> have you successfully used the code_search_tool? any issues with that?
Yes. I successfully used `code_search_tool` to search the project for:
- `auth`
- `login`, `logout`, `password`, `token`, `session`, `authenticate`, and `authorization`
It returned the expected matches in `main.py` and `test_auth.py`. I didn’t encounter any issues. The only minor note is that the broad search also matched the tool implementation’s own documentation and code in `main.py`, since that file contains the `code_search_tool` function itself.
So we used our little agent to help build itself—kinda funny! I think that when you build one from scratch, you realize how much a model brings to the table and how little the harness does.
Full source code is available at https://github.com/cwarck/how-to-build-a-coding-agent/tree/main.