Blueprint · Coding-Agent Harness

A harness is a loop.
Everything else is craft.

Two articles, one finding: the core of a coding agent is an LLM, a loop, and enough tokens — under 400 lines of Go for Thorsten Ball, about 200 lines of Python for Mihail Eric. This page condenses both into an executable blueprint: readable for humans, directly workable for a coding agent.

01The thesis

“It's an LLM, a loop, and enough tokens.”

Thorsten Ball, Amp

“The core of these tools isn't magic. It's about 200 lines of straightforward Python.”

Mihail Eric

Both take apart the same black box and find the same thing: no planning engine, no reasoning framework, no graph. An array of messages, a handful of functions, a while loop.

The practical consequence for building one: the loop is done in an hour. The work sits at its edges — error handling, permissions, context budget, tool quality. Flip that around and start with architecture, and you are building past the problem.

02The model touches nothing

The most important mental model on this page. With it, every harness design makes sense; without it, you build security holes.

“The LLM never actually touches your filesystem. It just asks for things to happen, and your code makes them happen.”

Mihail Eric
LLM decides no access HARNESS executes checks permissions limits output only trust boundary SYSTEM files · shell network tool_use tool_result syscall bytes The model only produces text. Every effect in the world happens in your code.

Everything important follows from this: permission checks, sandboxing, path validation and output limits belong in the harness, without exception — never in the prompt. A prompt is a request, not a safeguard.

03The loop

Two nested loops. The outer one collects user input; the inner one lets the model call tools until it is done.

outer loop · per user input user input inner loop · until no more tool_use API call + tool defs tool? execute local, checked append result to conversation yes back into the model — this is where multi-step work happens no → print answer, wait The agent's entire state is the conversation array. The API is stateless.
# The complete agent. Everything else is hardening.
conversation = []

while True:
    conversation.append({"role": "user", "content": input("you: ")})

    while True:                                  # inner loop
        msg = client.messages.create(
            model=MODEL, max_tokens=8192,
            system=SYSTEM_PROMPT,
            tools=[t["schema"] for t in TOOLS.values()],
            messages=conversation,
        )
        conversation.append({"role": "assistant", "content": msg.content})

        calls = [b for b in msg.content if b.type == "tool_use"]
        if not calls:
            print(text_of(msg)); break                # done → user's turn

        results = [run_tool(c) for c in calls]      # answer every call!
        conversation.append({"role": "user", "content": results})
agent.py — the core, about 20 lines. Python/Anthropic; the pattern is provider-agnostic.
Hard rule

Every tool_use block in a response needs exactly one tool_result block with a matching tool_use_id — in the next message, all of them. If one is missing, the API rejects the next call. This is by far the most common mistake when rebuilding this.

04The state is an array

The server remembers nothing. Everything the agent “knows” lives entirely in conversation and is resent in full on every step. That explains three things at once:

05Anatomy of a tool

Four fields. Only one of them is code — the other three are prompt engineering.

FieldPurposeMost common mistake
nameIdentity for dispatch. Verb plus object, snake_case.Cryptic abbreviations like rf instead of read_file.
descriptionTells the model when to use the tool and when not to.Describes what the function does instead of when you need it.
input_schemaJSON Schema. Enforces valid arguments.Free-text parameters without a per-field description.
functionExecutes and always returns a string.Throws exceptions instead of returning the error text.
The lever

The description is not a comment — it is the part of your prompt with the biggest effect. Both authors stress the same point: the model is trained on tool use and does it eagerly. It doesn't need persuading, it needs boundaries. Write down what the tool does not do (“Not for directories”) and when another tool fits better.

# A tool = schema + function. The description carries the behavior.
READ_FILE = {
  "name": "read_file",
  "description": (
      "Reads the contents of a file relative to the working directory. "
      "Use this before editing a file. "
      "NOT for directories - use list_files for that."),
  "input_schema": {
      "type": "object",
      "properties": {"path": {"type": "string",
                      "description": "Relative path, e.g. src/main.py"}},
      "required": ["path"],
  },
}

The dispatcher that doesn't swallow errors

Errors are not crashes, they are information for the model. It reads “file not found”, fixes the path and tries again. A harness that lets exceptions propagate takes away the agent's ability to correct itself.

def run_tool(call):
    fn = TOOLS.get(call.name)
    try:
        out = fn["function"](**call.input) if fn else f"unknown tool: {call.name}"
        err = fn is None
    except Exception as e:
        out, err = f"{type(e).__name__}: {e}", True   # error = feedback
    return {"type": "tool_result", "tool_use_id": call.id,
            "content": truncate(out, 30_000), "is_error": err}
Two details make the difference: is_error instead of a crash — and truncate, so a single file can't eat the context window.

06The three tools that are enough

Both articles independently arrive at the same minimal set. With it, the agent already reads, writes and changes real projects.

ToolParametersContract
list_filespath?Returns a JSON array. Directories end with /. Ignores .git and node_modules.
read_filepathFile contents as a string. If the file is missing, the error text comes back — no crash.
edit_filepath, old_str, new_strReplaces old_str with new_str. Empty old_str plus a missing file means create.

The trick with edit_file: a single tool covers creating and changing, and the model never has to count line numbers — it quotes the text it wants to replace. That is exactly why string replacement works better than any diff format.

def edit_file(path, old_str, new_str):
    if old_str == new_str:
        raise ValueError("old_str and new_str must differ")

    p = safe_path(path)                       # mandatory, see section 08
    if old_str == "":
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(new_str); return f"created {path}"

    src = p.read_text()
    n = src.count(old_str)
    if n == 0:
        raise ValueError("old_str not found - read the file again")
    if n > 1:
        raise ValueError(f"old_str found {n}x - include more context")

    p.write_text(src.replace(old_str, new_str))
    return f"edited {path}"
The ambiguity check (n > 1) is missing from both minimal versions and is the most important addition: it stops an edit from silently hitting five places at once.
Only then expand

Production harnesses add bash, grep/glob and web_search. Keep the order anyway: get the three working first, then bash — from that moment on you need permission checks.

07Two ways to wire up tools

This is where the articles diverge — and the choice decides how much parser code you write.

A · Native tool_useB · Text protocol
SourceThorsten BallMihail Eric
MechanicsTools are passed as tools=[...]; the model returns structured tool_use blocks.Tools are listed in the system prompt; the model writes tool: name({"a":1}), you parse the line.
ParsingNone. Arguments arrive validated.Your own parser, plus handling broken JSON.
StrengthRobust, multiple calls per turn, official error channels.Runs on any model, even without a tool API.
Use itAs the default.Only as a fallback for models without tool support.

Option B is instructive because it shows that tool use isn't API magic, just a convention. For the build you still pick A: whatever you save on parser code goes into tool quality.

08What separates a demo from a harness

“The rest, the stuff that makes Amp so addictive and impressive? Elbow grease.”

Thorsten Ball

Both articles name this gap but don't fill it. This is the list your agent has to work through — sorted by impact.

GapSymptom without itImplementation
Path sandbox../../.ssh/id_rsa is readable.safe_path(): resolve, check against the project root, reject symlinks. In every file tool.
ApprovalsAn rm -rf runs without asking.Classify tools as read/write/exec. Everything from write upward asks first, with a diff preview and “remember for this session”.
Errors as feedbackAgent quits on a typo in a path.is_error: true plus a readable message. The error text is a prompt — write it like one.
Output budgetOne npm install log blows the window.Hard-truncate every tool result, e.g. to 30k characters: keep head and tail, replace the middle with a notice.
CompactionSession dies on a long task.From about 75% of the window, have the model summarize older turns; keep the summary plus the last N turns.
Loop brakeAgent reads the same file twenty times.Hard counter per user request; detect identical calls and answer them with a notice.
Streaming30 seconds of blank screen.Stream the response, show tool calls as they arrive.
Project contextIgnores the repo's conventions.Read AGENTS.md or CLAUDE.md on startup and append it to the system prompt.
Sub-agentsSearches flood the main context.A task tool starts a second loop with its own array; only the result comes back.

09The build plan

Five phases. Each ends with an acceptance test that must pass before the next one starts. A coding agent can work through this top to bottom.

P0Scaffold

A process that gets a model response onto the console.

Build
Project, SDK client, API key from the environment, a MODEL constant, an empty conversation array, a REPL loop.
Acceptance
“Say hello” → a response appears; the second question demonstrably refers to the first.

P1The loop

Inner loop with tool dispatch — with exactly one dummy tool.

Build
Tool registry (name → {schema, function}), run_tool() with try/except, correct appending of tool_use and tool_result, exit when no calls come back.
Rule
Answer every call in a response before the next request goes out.
Acceptance
The dummy tool get_time gets called and its result flows into the answer. Two calls in one turn work.

P2The three tools

The agent works with real files.

Build
list_files, read_file, edit_file following the contracts from section 06 — including the ambiguity check.
Right away
safe_path(). Not later. Without it, every test is a risk.
Acceptance
“Write fizzbuzz.js and run it up to 15” → the file is created, the agent reads it back and changes the limit. “Which version is in package.json?” → it finds the file on its own via list_files.

P3Hardening

The toy becomes a tool you can actually leave running.

Build
Approval prompt before every writing tool · output truncation · iteration limit · streaming · save and load the session as JSON.
Then
A bash tool with a timeout, bound to the working directory, with mandatory approval. Only now, not earlier.
Acceptance
A rejected approval stops the action without crashing the agent — it rephrases. A tool with 1 MB of output doesn't kill the session.

P4Endurance

Tasks that last longer than one context window.

Build
Token counter · automatic compaction from about 75% · project context from AGENTS.md · grep/glob · a task sub-agent with its own array.
Acceptance
A task with 40 or more tool calls runs through compaction without forgetting the assignment.

10Pitfalls

11Agent spec

The same page, condensed to what a coding agent needs as its assignment. Copy it and hand it over as a prompt.

build-harness.md
# TASK: Build a coding-agent harness

## Invariant
The LLM touches nothing. It returns text; every effect happens in your
code. Security, limits and permissions belong in the harness, never in the
prompt.

## Architecture
- State = one array `conversation`. The API is stateless.
- Outer loop: append user input.
- Inner loop: API call (with tool definitions) -> if tool_use blocks
  come back: execute all of them locally, append all results as ONE user
  message, call again. No tool_use -> print text, leave the loop.
- Use the API's native tool calling; don't parse a text protocol.

## Tool contract
Every tool: name (snake_case, verb_object), description (says WHEN and WHEN
NOT), input_schema (JSON Schema, every field described), function
(always returns a string).
Never throw errors: return them as a tool_result with is_error=true and a
readable message. Truncate every result to roughly 30k characters.
Every tool_use block gets exactly one tool_result with a matching
tool_use_id.

## Tools (in this order)
1. list_files(path?)  -> JSON array, directories end with "/",
   .git and node_modules skipped.
2. read_file(path)    -> contents as a string.
3. edit_file(path, old_str, new_str) -> replaces old_str with new_str.
   old_str == "" and file missing -> create (incl. parent directories).
   old_str not found -> error "read the file again".
   old_str found multiple times -> error "include more context". MANDATORY.
Only after that: bash (timeout + approval), grep/glob, task (sub-agent).

## Security (not optional)
safe_path(path): resolve, check against the project root, reject symlinks
and escapes. Call it in EVERY file tool.
Writing and executing tools require user approval with a preview.

## Phases with acceptance tests
P0 Scaffold   -> two turns, the second refers to the first.
P1 Loop       -> dummy tool gets called; two calls per turn work.
P2 Three tools -> "write fizzbuzz.js up to 15" creates, reads and changes
                 the file; "which version is in package.json" is found
                 on its own via list_files.
P3 Hardening  -> approvals, truncation, iteration limit, streaming, save/
                 load session. A rejected approval doesn't crash.
P4 Endurance  -> token counter, auto-compaction from 75% of the window,
                 project context from AGENTS.md, sub-agent with its own
                 array. 40+ tool calls survive a compaction.

## Do not build
Planners, state machines, graph frameworks, a tool zoo. The loop is the
architecture. The rest is craft at its edges.

Keep building

Questions about rebuilding this, your own harness variants and what actually holds up day to day — that's what I discuss in my community.

AI mit Arnie — KI Revolutionskool.com/ai-mit-arnie-ki-revolution Join the community ↗