Handmade Claude Code 1/7 — The Loop
Part one of the Handmade Claude Code campaign: the agent becomes a loop. Not a script that sends one prompt and prints one reply — a process that holds a conversation, runs the tools the model asks for, feeds the results back, survives a model that hiccups, and stops when told to.
Everything after this part is built on the shape frozen here, so the shape
comes first and the tool set stays deliberately thin: bash only. Part two
gives it real hands.
The command
You declare it, and it is captured into session memory — any language, any entry point:
agent: <command> started as: <command> -C <dir> [--yes] -p "<prompt>"
-C <dir>— the working directory. Everything relative (files, shell commands, later AGENTS.md discovery) is relative to it. Default: the current directory.-p "<prompt>"— headless mode: run the loop to completion, print the final answer to stdout, exit 0. Nothing else goes to stdout — logs and progress belong on stderr. Without-pthe same command opens the terminal UI (part six).--yes— skip permission checks; every tool call is allowed. Part two adds the permission engine; until then the checks always pass--yes.--output-format json— instead of the plain answer, print one JSON object:{"result": "<answer>", "session_id": "<id>", "turns": <model calls>, "usage": {"input_tokens": <sum>, "output_tokens": <sum>}}.--max-turns <n>— the most model calls one run may make (default 50).
The model protocol
The harness never knows which model it is talking to. Every model call is
one run of the command in the environment variable AGENT_MODEL_CMD
(started with sh -c): the request goes to its stdin as one JSON document,
the reply comes back on its stdout, the process exits 0. Once per call — the
model is stateless, the whole conversation travels every time, exactly like
a real API.
The request:
{
"system": "<the system prompt — never empty>",
"messages": [
{"role": "user", "content": [{"type": "text", "text": "<prompt>"}]},
{"role": "assistant", "content": [
{"type": "text", "text": "…"},
{"type": "tool_use", "id": "t1", "name": "bash", "input": {"command": "ls"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "<output>", "is_error": false}
]}
],
"tools": [{"name": "bash", "description": "…", "input_schema": {…}}]
}
The reply: one JSON object per line. Any number of streamed fragments
{"type": "text_delta", "text": "…"}, then exactly one final line:
{"type": "message", "content": [<blocks>], "stop_reason": "end_turn", "usage": {"input_tokens": 123, "output_tokens": 45}}
stop_reason: "tool_use" means the content carries tool_use blocks: run
every one of them, append one user message carrying a tool_result per
call in the same order, and call the model again. end_turn ends the loop;
the text blocks of the final message, joined with newlines, are the answer.
The deltas are for showing progress — they are never printed as the answer
a second time.
A model command that exits non-zero, or whose last line is not a valid
message, has failed. Call it again — three attempts in total, no more than a
second or two apart. After the third failure print a line starting ERROR:
to stderr and exit non-zero. --max-turns is enforced the same way: one call
past the cap is an ERROR: and a non-zero exit.
Providers and models
AGENT_MODEL_CMD is the shortcut the checks use; the mechanism behind it
is a provider registry in .agent/settings.json of the working directory:
{"providers": {"scripted": {"type": "command", "command": "sh model.sh"},
"claude": {"type": "anthropic", "api_key_env": "ANTHROPIC_API_KEY"},
"local": {"type": "openai", "base_url": "http://localhost:11434/v1"}},
"model": "scripted/default"}
model names the default as <provider>/<model>; --model <provider>/<model> overrides it for one run. Every request carries the
model name: "model": "<model>" next to system, messages and tools.
AGENT_MODEL_CMD, when set and non-empty and no --model is given, is a
command provider that wins over the settings. A --model naming a
provider the settings do not declare is an ERROR: and a non-zero exit.
The command type is the one the checks exercise. At least one HTTP type
belongs in the same registry so the agent can be used for real — the
Anthropic Messages API, or an OpenAI-compatible chat completions endpoint
(which also covers Ollama, OpenRouter and most local servers) — behind the
same interface, so the loop never knows which one it is talking to. The
review panel looks for it; part six adds /model to switch at runtime.
The bash tool
{"name": "bash", "input": {"command": "<shell>"}}
Runs the command with sh -c in the working directory. The result content
is its stdout and stderr, in order. A non-zero exit appends a line
exit code: <n> to the content and sets is_error to true. The loop does
not stop on a failed tool — the model is told, and decides.
Wrapping claude, codex, gemini, aider or any other coding agent, or
building on an agent SDK that owns the loop, is not building one. The loop
is yours; HTTP clients, JSON parsers and terminal libraries are tools.
The judges
The quality panel — architecture, performance, code quality, test quality, technical governance and DX review — sits on the rungs where its subject is decided, one verdict per judge per rung, on top of the rung's own points. There is no closing review: what you build is judged as you build it, and a rung you never reach is a verdict you never get.
The ladder
- Set up: declare
agent:andtest:(10) - One prompt, one answer (10)
- The request is a conversation (10)
- A tool round trip (30)
- Many turns (30)
- Bash tells the truth (20)
- Providers and models (30)
- A flaky model gets retried (20)
- Turns are capped, the bill is printed (20)
1
Public
Reinvent the Wheel
handmade-claude-code-1-loop
25 min
~11 per session
No
10–30
- ai-agent
- harness
- cli
- handmade-claude-code
- campaign
1
Set up the project and declare the command
+10 pts per passing check · +10 for completing the task
10
pts / check
+10 pts per passing check · +10 for completing the task
Build a coding agent: the loop between a model and its tools.
agent: started as: -C
[--yes] -p "" In headless mode (
-p) it runs the loop to completion, prints the final
answer to stdout, and exits 0. The model behind it is whatever commandAGENT_MODEL_CMDnames: request JSON in on stdin, reply JSON out on
stdout, once per model call. The checks plug in a scripted model.Any language, any entry point. Write an AGENTS.md (or README.md) that
documents the stack you chose and carries two lines the platform captures
into session memory:agent:andtest:(the command that runs your
test suite). AGENTS.md wins when both files declare one.This part keeps the tool set deliberately small —
bashonly — because
what is being built here is the loop: a conversation that grows, tool
calls that come back as results, a model that hiccups and gets retried.
Part two gives the agent real hands.Wrapping
claude,codex,gemini,aideror any other coding agent,
or building on an agent SDK that owns the loop, is not building one.Judged by
The Debrief 2
One prompt, one answer
+10 pts per passing check · +10 for completing the task
10
pts / check
+10 pts per passing check · +10 for completing the task
The agent speaks for the model, not over it.
-p "<prompt>"runs the
loop and prints the final message's text to stdout — exactly that text,
followed by a newline, and nothing else on stdout. Banners, spinners,
logs and tool traces belong on stderr.Several text blocks in the final message print as several lines, in
order. A model may streamtext_deltafragments before its final
message; they exist so a UI can show progress and change nothing here
— the answer is printed once, from the message. Whatever the agent
prints is what the checks compare, so a greeting on startup is a
failing greeting.Judged by
DX Review 3
The request is a conversation
+10 pts per passing check · +10 for completing the task
10
pts / check
+10 pts per passing check · +10 for completing the task
What the model receives is the whole contract. The first request of a
run carries:system— a non-empty string. What goes in it is yours (part three
fills it with context); that it is there is not.messages— exactly one message,role: user, whose text block is the
prompt verbatim. No assistant turn has happened yet, so none is sent.tools— thebashtool, with aname, adescriptionand aninput_schema(JSON Schema for{"command": "<shell>"}).
The scripted model keeps every request it receives; this rung reads the
first one.4
A tool round trip
+30 pts per passing check · +10 for completing the task
30
pts / check
+30 pts per passing check · +10 for completing the task
The model asks, the agent acts, the model hears back. A reply with
stop_reason: "tool_use"and abashblock means: run the command in
the working directory, then call the model again with the conversation
grown by two messages — the assistant's reply as it came, and one user
message carrying atool_resultwhosetool_use_idis the id the model
chose and whosecontentis what the command printed.Then the model answers in text, and that text is what the agent prints.
Judged by
Architecture 5
Many turns
+30 pts per passing check · +10 for completing the task
30
pts / check
+30 pts per passing check · +10 for completing the task
A conversation is a history. Five tool calls in a row, one per model
reply, and by the sixth request the model must see all of it, in order:
each assistant turn as it came, each result in the user message that
followed it. Nothing summarised, nothing dropped, nothing reordered.Every call carries the whole conversation — the model is stateless, the
agent is not.Judged by
Performance 6
Bash tells the truth
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
A tool result is evidence, not a summary. For
bash, the content is
stdout and stderr in the order they were written; a non-zero exit
appends a lineexit code: <n>and marks the resultis_error: true.
The loop does not stop — the model is told and decides what to do next.The command runs in the working directory given by
-C, so a relative
path in a command means what the model thinks it means.Judged by
Code Quality 7
Providers and models
+30 pts per passing check · +10 for completing the task
30
pts / check
+30 pts per passing check · +10 for completing the task
The loop never knows which model it talks to.
.agent/settings.json
declares providers and a default model:{"providers": {"a": {"type": "command", "command": "sh a.sh"},
"b": {"type": "command", "command": "sh b.sh"}},
"model": "a/m1"}--model <provider>/<model>overrides the default for one run, and
every request carries"model": "<model>"so the provider knows what
to ask for.AGENT_MODEL_CMDstill wins when it is set and no--modelis given; the checks here leave it empty. A--modelthat
names an undeclared provider is anERROR:and a non-zero exit.The
commandtype is what the checks drive. Wire at least one HTTP
provider — the Anthropic Messages API or an OpenAI-compatible chat
completions endpoint — behind the same interface; the review panel
reads for it.8
A flaky model gets retried
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Models fail: a process that exits non-zero, a reply whose last line is
not a message. Neither is the end of the run. The agent calls again — up
to three attempts in total for one turn, a second or two apart at most —
and carries on as if nothing happened.Three failures in a row are the end: a line starting
ERROR:on stderr
and a non-zero exit, promptly. A harness that retries forever, or waits
a minute between attempts, fails this rung by never finishing it.9
Turns are capped, the bill is printed
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
A loop needs a way out and a receipt.
--max-turns <n>caps the model
calls of one run: the call past the cap is not made — the agent prints a
line startingERROR:to stderr and exits non-zero.--output-format jsonreplaces the plain answer with one JSON object:{"result": "", "session_id": "", "turns": ,
"usage": {"input_tokens": , "output_tokens": }}turnscounts the model calls made;usagesums what every reply
reported.session_idis any non-empty string that names this run —
part three resumes sessions by it.