Build Your Own Claude Code in Python: Project Setup and First LLM Call
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
./agentthat takes a-p "..."prompt, - a single non-streaming chat completion sent to an LLM through OpenRouter,
- a
--doctorhealth 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:
- One API key works everywhere — Claude, GPT, Gemini, DeepSeek, Llama, all behind the same endpoint.
- The OpenAI SDK works unchanged — you only override
base_url. - Free models. OpenRouter offers a generous tier of free models like
z-ai/glm-4.5-air:freethat 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:
base_urloverride. That single line is what turns the OpenAI SDK into an OpenRouter client. Same request format, different host.load_dotenv()inside__init__. Done eagerly so a missing key fails fast with a clearConfigurationError, rather than mysteriously 401-ing later.MODELenv 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 implementThe final code for this post lives on
mainand onmodule/02-system-prompt-and-argsbranch.
Subscribe to my newsletter
Get new posts delivered straight to your inbox.