Day 1 — Hello Agent
Today we write a ~150 line Python CLI called agent-code. By the end you'll see the model ask for a tool → execute → get a result → answer — the dividing line between an Agent and a chatbot.
Today we write a ~150 line Python CLI called agent-code.
By the end you'll see the model ask for a tool → execute → get a result → answer. That's the dividing line between an Agent and a chatbot.
This is a teaching project. We use the Claude Code source snapshot as an architecture reference and rewrite a minimal, teachable version in Python. We intentionally call our binary agent-code so it doesn't get confused with the official CLI.
Quick look — the core loop
Before writing any code, start with this Agent Logic Map. It does not replay every execution frame; it keeps only the two lines that matter for today's final shape: the minimum Agent Loop and the temporary Day 1 messages shape.
model proposes tool_call → harness executes tool → observation returns to messagesNotice the harness boundary in the middle: the model only proposes an action; ToolRegistry is what actually executes echo. In later days we keep adding real model protocol, file tools, and permission rules to that boundary.
We'll start from the dumbest possible echo CLI and grow it into a tool-calling Agent over three small iterations. Every version actually runs with uv run, and after each one you see what new ability you got.
Setup — empty project
Run these in your terminal to scaffold the project:
mkdir agent-code && cd agent-code
uv init --package
uv add typer rich
uv add --dev pytest
rm -rf src
mkdir -p agent_code tests
touch agent_code/__init__.pyRecent uv init --package versions create src/agent_code/ by default. This tutorial and the reference snapshots use the root-level agent_code/ directory instead, so remove src/ first. From here on, every agent_code/xxx.py path means agent-code/agent_code/xxx.py.
Edit the key fields of pyproject.toml (leave everything else uv init generated):
[project]
name = "agent-code"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"rich>=13.7.0",
"typer>=0.12.0",
]
[project.scripts]
agent-code = "agent_code.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["agent_code"][project.scripts] is what lets uv run agent-code know that the entry is main() in agent_code/cli.py. [tool.hatch.build.targets.wheel] tells the build backend to install the root-level agent_code/ package, not the src/agent_code/ directory we just removed.
[!NOTE] Reference snapshot lives in
packages/day-01-hello-agent/. That's the answer key — not a directory you create every day. For the next 7 days you keep editing the sameagent-codeproject.
v1 — an echoing CLI (~15 lines)
Simplest possible version: the CLI takes a string and echoes it.
Create cli.py under the root-level agent_code/ directory. The full path is agent-code/agent_code/cli.py:
from __future__ import annotations
import typer
from rich.console import Console
console = Console()
app = typer.Typer(add_completion=False)
@app.callback(invoke_without_command=True)
def main_command(prompt: str = typer.Argument("hi")) -> None:
console.print(f"echo: {prompt}")
def main() -> None:
app()Run:
$ uv run agent-code "hi"
echo: hiThis is basically a fancy cat. No model, no tools, no "doing anything". But it already lines up the skeleton: uv run agent-code works, the pyproject.toml entry is wired, typer parses the argument.
v2 — extract the "model" (~20 more lines)
To turn this into an Agent, step one is to admit: the thing answering the user shouldn't be the CLI — it should be a model.
Even if today the model is fake, we draw the boundary now. Tomorrow when we swap in real Claude, the CLI doesn't change.
Create agent_code/model.py:
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ModelResponse:
# one model response. v2 only has final text; v3 will add tool calls.
text: str
class MockProvider:
def complete(self, prompt: str) -> ModelResponse:
# a fake model. fixed reply, just enough to draw the CLI <-> Provider boundary.
return ModelResponse(text=f"I'm MockProvider, you said: {prompt}")At this point the boundary between CLI Harness and Provider is drawn.
Two small edits in cli.py. Add this import at the top:
from .model import MockProviderThen replace the v1 line inside main_command:
console.print(f"echo: {prompt}")with:
provider = MockProvider()
response = provider.complete(prompt)
console.print(f"final: {response.text}")Run:
$ uv run agent-code "hi"
final: I'm MockProvider, you said: hiNow the CLI doesn't answer anymore — it delegates to MockProvider. But this "model" still only speaks. Tell it "echo hi using the echo tool" and it just parrots the words back. To actually do things, its output can't be only text.
v3 — let the model ask for tools (the Agent Loop ships)
For the model to do anything, it has to be able to say: "I want to call tool X with arguments Y." Then the program runs it, hands the result back, and the model continues.
That model says what → program runs it → result handed back to model cycle is the Agent Loop.
We add it in three steps: extend the protocol, write the tool registry, wire the loop.
3.1 Extend model.py so the model can write a "tool call slip"
MockProvider now has two new needs:
ModelResponseneeds to carry one or more Tool Calls, not justtext.complete()no longer takes a singleprompt— it takes a messages list, because after a tool runs the model needs the full conversation to turn the result into a final answer.
Rewrite agent_code/model.py entirely.
3.2 The tool registry
Now the model can say "I want to use echo". The program needs somewhere that maps tool names to actual Python functions.
Create agent_code/tools.py to declare the tool abstraction and the registry container.
When we add read_file, bash etc. later, it's just one more register line in default_tools().
3.3 Wire model + tools into the Agent Loop
All the parts are here. Create agent_code/agent.py to manage the conversation turn-by-turn.
Today the loop runs just once (one tool_call → observation → one final answer). The {"role": "tool"} shape is a Day 1 simplification for the mock loop. Tomorrow when we hit real Claude, we'll switch to Anthropic's official tool_use / tool_result content blocks.
Finally wire cli.py to run_agent. Two edits. Top-of-file imports become:
from .agent import run_agent
from .model import MockProvider
from .tools import default_toolsThe three lines from v2 inside main_command become:
result = run_agent(prompt, MockProvider(), default_tools())
for line in result.trace:
console.print(line)Run:
$ uv run agent-code "echo hi using the echo tool"
tool_call: echo {'text': 'hi'}
observation: hi
final: echo tool returned: hiThe full v2 → v3 diff:
This is the minimum Agent Loop:
model -> tool_call -> tool -> observation -> model -> finalv1's CLI could only echo. v2's "model" could only speak. v3 is the first time the model actually does something.
Summary — what exactly is a tool, and what is a function call
Now that it runs, let's pin down these two terms — you'll use them every day for the next 7 days.
A tool is just a Python function you hand to the model. The model itself only emits text — it can't read files, run commands, or query a database. So on the harness side we prepare functions, give each a name and description (our Tool is the trio name + description + run), and drop them into ToolRegistry. Registering one tells the model: "you can dispatch me to do these things." Today there's only echo; from Day 3 on, read_file and bash show up here.
A function call is the model placing an order, not executing it. This is the key: the model never runs the function itself. All it does is emit a structured slip — "call echo with arguments {"text": "hi"}", i.e. our ToolCall(name=..., arguments=...). The harness does the actual work: ToolRegistry.run() looks up the Python function by name, runs it, and hands the observation back to the model. Model orders, harness executes, result flows back — that's exactly why the chain reads tool_call → observation → final.
One naming trap worth calling out: function call, tool call, and tool use are basically the same thing, just different vendor names. OpenAI's early term was function calling; Anthropic calls it tool use; our code uses ToolCall / tool_calls throughout. Tomorrow when you wire real Claude, you'll see tool_use / tool_result in the API — and you'll know it's just today's idea wearing a different shell.
Finishing touches — REPL, slash commands, --cwd
v3 only supports one-shot mode: agent-code "..." runs once and exits. The official Claude Code also lets you just type claude to drop into a REPL with /help, /exit slash commands. We add the same layer. We also add a --cwd working directory flag, which Day 3's file tools will need.
Replace agent_code/cli.py with the final version:
Remember this boundary: slash commands are handled by the CLI itself, never sent to the model as prompts. Day 7 makes them extensible.
Terminal replay
Here's what running v3 looks like end-to-end:
What you have today
- CLI runtime:
uv run agent-codesupports both one-shot and REPL modes, knows about--cwd, intercepts/helpand/exit. - ModelResponse contract: locked in v3. Tomorrow we swap in real Claude — same contract, the CLI doesn't change.
- Tool calling: the model can output "I want to use tool X" rather than just text.
- Observation: tool results flow back into messages, driving the next turn.
- Agent Loop:
model -> tool -> observation -> model— the smallest core of a code Agent.
FAQ
agent-code command not found
Make sure pyproject.toml has:
[project.scripts]
agent-code = "agent_code.cli:main"Then use uv run agent-code, not agent-code directly.
My dict output uses single quotes
Python dict may print with single quotes. As long as you see tool_call, observation, final in order, it's correct.
ModuleNotFoundError: No module named 'agent_code.cli'
The most common cause is having two package directories at once: src/agent_code/ and agent_code/. uv init --package creates the first one by default, but this tutorial writes cli.py into the second one, so packaging cannot find agent_code.cli.
First remove the generated src/ directory:
rm -rf srcThen make sure pyproject.toml has both blocks:
[project.scripts]
agent-code = "agent_code.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["agent_code"]Finally confirm the file lives at agent-code/agent_code/cli.py, and keep running it with uv run agent-code "hi".
Challenges
- Challenge 1: add an
uppercasetool that uppercases its input. - Challenge 2: improve
ToolRegistry.run()error for unknown tools (include the list of available tools). - Challenge 3: make
/helpprint all currently registered tools.
Thinking questions
A few open-ended questions. Try to answer each in one sentence before reading on. If an interviewer ever drills into the Agent layer, this is what gets tested.
-
In one sentence, what is the Agent Loop? Where does it fundamentally differ from a regular chatbot?
-
What role does
ToolRegistryplay in the harness? Without it, if you just dispatched directly in the Agent Loop withif call.name == "echo": ..., what would go wrong? -
In v2 we split the "model" into
MockProvider, even though the CLI doesn't strictly need that boundary today — is this overkill? (Hint: peek at Day 2 when we plug in the real model and count how many lines ofcli.pyactually change.) -
The v3 output
tool_call → observation → finalis the minimum Agent Loop. If you had to explain to a coworker in three sentences whatmodel/harness/tooleach do along this chain, what would you say?
Tomorrow
Today we ran the minimum Agent Loop on a MockProvider. Tomorrow we swap MockProvider for a real Claude provider, and grow the single-step for into while step < max_steps. Because ModelResponse is already locked, tomorrow doesn't rewrite the Agent Loop — only the provider.