schedule · 7 min read

Build Your Own Claude Code in Python: Project Setup and First LLM Call

link
Part of series
Build Your Own Claude Code

I use coding agents every day. Claude Code, Opencode, Windsurf — they’ve quietly become the most important tools on my machine. And yet, when I stopped to ask myself how they actually work underneath, I realized I was waving my hands. “It calls the LLM, the LLM calls tools, there’s a loop somewhere.” Fine words for a tech influencer tweet and linkedin influencer post, useless if you actually want to understand the thing.

So I did what I always do when I want to know how something works: I am building one.

The project is called nunuk — short for nunukkam (நுணுக்கம்), Tamil for fine detail or the careful, intricate way of looking at something. It felt like the right name for a build-from-scratch project. It’s open source on GitHub. The agent it builds toward is small (~800 lines of Python) but real: a working coding agent with Read, Write, Edit, Bash, Glob, Grep, and an interactive REPL. Claude Code-shaped, minus the polish.

This series walks through nunuk one module at a time. By the end you’ll have built something you can run on your own code — and, more importantly, you’ll understand every line of it.

Let’s start.


By the end of this post we will have:

  • a uv-managed Python 3.12 project,
  • a CLI binary called ./agent that takes a -p "..." prompt,
  • a single non-streaming chat completion sent to an LLM through OpenRouter,
  • a --doctor health check.

That covers modules 00-setup, 01-first-llm-call, and 02-system-prompt-and-args. Each is tiny on its own, so we’ll do all three together.

Project layout

nunuk/
├── agent              # shell wrapper: `uv run python -m app.main "$@"`
├── pyproject.toml
├── .env.example
└── app/
    ├── __init__.py
    ├── main.py        # CLI entry point
    ├── llm.py         # OpenAI SDK wrapper
    ├── config.py      # constants
    └── exceptions.py  # error types

The agent wrapper is just a one-liner so we can run ./agent -p "hi" without typing uv run python -m app.main every time.

pyproject.toml

[project]
name = "nunuk-agent"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "openai>=2.15.0",
    "python-dotenv>=1.0.1",
]

[tool.uv]
package = false

Two dependencies for this post: the openai SDK (we’ll point it at OpenRouter) and python-dotenv to load .env.

Bootstrap with uv:

uv sync

.env.example

OPENROUTER_API_KEY=sk-or-v1-...
MODEL=z-ai/glm-4.5-air:free

OpenRouter is an OpenAI-compatible gateway in front of many providers. We use it for three reasons:

  1. One API key works everywhere — Claude, GPT, Gemini, DeepSeek, Llama, all behind the same endpoint.
  2. The OpenAI SDK works unchanged — you only override base_url.
  3. Free models. OpenRouter offers a generous tier of free models like z-ai/glm-4.5-air:free that are more than capable for following along with this series. You can build the whole agent without paying a cent.

Copy .env.example to .env and fill in your key from openrouter.ai/keys.

app/config.py

DEFAULT_MODEL = "anthropic/claude-haiku-4.5"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
SYSTEM_PROMPT = "You are a helpful, concise coding assistant."

A flat module of constants. As the series grows this file picks up retry settings, iteration caps, and tool timeouts — but for now, just these three.

app/exceptions.py

class AgentError(Exception):
    """Base exception for all agent errors."""


class ConfigurationError(AgentError):
    """Missing or invalid configuration (env vars, etc.)."""

One base error type so main.py can catch any agent-level failure with a single except AgentError. Concrete errors inherit from it.

app/llm.py — the LLM client

This is the smallest interesting file in the project:

import os
from dotenv import load_dotenv
from openai import OpenAI

from app.config import DEFAULT_MODEL, OPENROUTER_BASE_URL
from app.exceptions import ConfigurationError


class LLMClient:
    def __init__(self):
        load_dotenv()
        api_key = os.environ.get("OPENROUTER_API_KEY")
        if not api_key:
            raise ConfigurationError("OPENROUTER_API_KEY is not set")

        self._client = OpenAI(
            api_key=api_key,
            base_url=OPENROUTER_BASE_URL,
        )
        self._model = os.environ.get("MODEL", DEFAULT_MODEL)

    def create(self, messages: list[dict]):
        return self._client.chat.completions.create(
            model=self._model,
            messages=messages,
        )

Three things worth noticing:

  1. base_url override. That single line is what turns the OpenAI SDK into an OpenRouter client. Same request format, different host.
  2. load_dotenv() inside __init__. Done eagerly so a missing key fails fast with a clear ConfigurationError, rather than mysteriously 401-ing later.
  3. MODEL env override. Defaults to Claude Haiku 4.5, but you can change models without touching code: MODEL=openai/gpt-4o-mini ./agent -p "hi".

app/main.py — the CLI

import argparse
import sys

from app.config import SYSTEM_PROMPT
from app.exceptions import AgentError
from app.llm import LLMClient


def main() -> int:
    parser = argparse.ArgumentParser(prog="agent")
    parser.add_argument("-p", "--prompt", required=True,
                        help="Prompt to send to the model.")
    args = parser.parse_args()

    try:
        llm = LLMClient()
        response = llm.create([
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user",   "content": args.prompt},
        ])
        print(response.choices[0].message.content or "")
        return 0
    except AgentError as e:
        print(str(e), file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

The conversation is two messages: a system prompt that defines the assistant’s role, and the user prompt from -p. The reply we want is response.choices[0].message.content.

The wrapper script agent (chmod +x agent):

#!/usr/bin/env bash
exec uv run python -m app.main "$@"

Trying it

$ ./agent -p "say hi in five words"
Hi! Nice to meet you.

That’s it — a working LLM CLI in under 50 lines.

Adding —doctor

One small but useful addition: a health check for the dev environment. When students hit a wall it’s almost always one of: wrong Python, missing uv, or missing API key. So we add a --doctor flag that checks each.

import os
import shutil
import subprocess
import sys
from dotenv import load_dotenv
from app.config import DEFAULT_MODEL


def doctor() -> int:
    load_dotenv()
    failed = 0

    py = sys.version_info
    if (py.major, py.minor) >= (3, 12):
        print(f"python: {py.major}.{py.minor}.{py.micro} OK")
    else:
        print(f"python: {py.major}.{py.minor} FAIL (need >=3.12)")
        failed += 1

    if shutil.which("uv"):
        version = subprocess.run(
            ["uv", "--version"], capture_output=True, text=True
        ).stdout.strip()
        print(f"uv: {version} OK")
    else:
        print("uv: missing FAIL")
        failed += 1

    if os.environ.get("OPENROUTER_API_KEY"):
        print("OPENROUTER_API_KEY: set OK")
    else:
        print("OPENROUTER_API_KEY: missing FAIL")
        failed += 1

    model = os.environ.get("MODEL", DEFAULT_MODEL)
    print(f"MODEL: {model}")

    return 0 if failed == 0 else 1

Wire it into main():

parser.add_argument("--doctor", action="store_true",
                    help="Verify the dev environment.")
# ...
if args.doctor:
    return doctor()

Now ./agent --doctor gives a clean preflight:

python: 3.12.7 OK
uv: uv 0.4.30 OK
OPENROUTER_API_KEY: set OK
MODEL: anthropic/claude-haiku-4.5
OK

What’s next

In Part 2 we flip stream=True and print the response token by token. After that: retries, tool advertising, the agent loop, and eventually all the file-system tools that make this thing feel like a real coding assistant.

Try it yourself. Check out the starter branch and follow the module:

git clone https://github.com/sureshdsk/nunuk.git && cd nunuk
git checkout module/00-setup
# read modules/00-setup/instructions.md, then implement

The final code for this post lives on main and on module/02-system-prompt-and-args branch.

link
Part of series
Build Your Own Claude Code

Subscribe to my newsletter

Get new posts delivered straight to your inbox.