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.