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 -p the 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: and test: (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)
Sessions

1

Visibility

Public

Category

Reinvent the Wheel

Slug

handmade-claude-code-1-loop

Duration

25 min

Judge reviews

~11 per session

Active session

No

Points

10–30

Tags
  • ai-agent
  • harness
  • cli
  • handmade-claude-code
  • campaign
  • 1

    Set up the project and declare the command

    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 command
    AGENT_MODEL_CMD names: 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: and test: (the command that runs your
    test suite). AGENTS.md wins when both files declare one.

    This part keeps the tool set deliberately small — bash only — 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, aider or any other coding agent,
    or building on an agent SDK that owns the loop, is not building one.

    Judged by
  • 2

    One prompt, one answer

    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 stream text_delta fragments 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
  • 3

    The request is a conversation

    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 — the bash tool, with a name, a description and an
      input_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 / 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 a bash block 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 a tool_result whose tool_use_id is the id the model
    chose and whose content is what the command printed.

    Then the model answers in text, and that text is what the agent prints.

    Judged by
  • 5

    Many turns

    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
  • 6

    Bash tells the truth

    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 line exit code: <n> and marks the result is_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
  • 7

    Providers and models

    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_CMD still wins when it is set and no
    --model is given; the checks here leave it empty. A --model that
    names an undeclared provider is an ERROR: and a non-zero exit.

    The command type 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 / 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 / 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 starting ERROR: to stderr and exits non-zero.

    --output-format json replaces the plain answer with one JSON object:

    {"result": "", "session_id": "", "turns": ,
    "usage": {"input_tokens": , "output_tokens": }}

    turns counts the model calls made; usage sums what every reply
    reported. session_id is any non-empty string that names this run —
    part three resumes sessions by it.