Blueprint · Coding-Agent Harness
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.
“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 EricBoth 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.
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 EricEverything 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.
Two nested loops. The outer one collects user input; the inner one lets the model call tools until it is done.
# 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})
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.
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:
Four fields. Only one of them is code — the other three are prompt engineering.
| Field | Purpose | Most common mistake |
|---|---|---|
| name | Identity for dispatch. Verb plus object, snake_case. | Cryptic abbreviations like rf instead of read_file. |
| description | Tells the model when to use the tool and when not to. | Describes what the function does instead of when you need it. |
| input_schema | JSON Schema. Enforces valid arguments. | Free-text parameters without a per-field description. |
| function | Executes and always returns a string. | Throws exceptions instead of returning the error text. |
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"], }, }
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}
is_error instead of a crash — and truncate, so a single file can't eat the context window.Both articles independently arrive at the same minimal set. With it, the agent already reads, writes and changes real projects.
| Tool | Parameters | Contract |
|---|---|---|
| list_files | path? | Returns a JSON array. Directories end with /. Ignores .git and node_modules. |
| read_file | path | File contents as a string. If the file is missing, the error text comes back — no crash. |
| edit_file | path, old_str, new_str | Replaces 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}"
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.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.
This is where the articles diverge — and the choice decides how much parser code you write.
| A · Native tool_use | B · Text protocol | |
|---|---|---|
| Source | Thorsten Ball | Mihail Eric |
| Mechanics | Tools 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. |
| Parsing | None. Arguments arrive validated. | Your own parser, plus handling broken JSON. |
| Strength | Robust, multiple calls per turn, official error channels. | Runs on any model, even without a tool API. |
| Use it | As 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.
“The rest, the stuff that makes Amp so addictive and impressive? Elbow grease.”
Thorsten BallBoth articles name this gap but don't fill it. This is the list your agent has to work through — sorted by impact.
| Gap | Symptom without it | Implementation |
|---|---|---|
| Path sandbox | ../../.ssh/id_rsa is readable. | safe_path(): resolve, check against the project root, reject symlinks. In every file tool. |
| Approvals | An 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 feedback | Agent 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 budget | One 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. |
| Compaction | Session 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 brake | Agent reads the same file twenty times. | Hard counter per user request; detect identical calls and answer them with a notice. |
| Streaming | 30 seconds of blank screen. | Stream the response, show tool calls as they arrive. |
| Project context | Ignores the repo's conventions. | Read AGENTS.md or CLAUDE.md on startup and append it to the system prompt. |
| Sub-agents | Searches flood the main context. | A task tool starts a second loop with its own array; only the result comes back. |
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.
A process that gets a model response onto the console.
MODEL constant, an empty conversation array, a REPL loop.Inner loop with tool dispatch — with exactly one dummy tool.
name → {schema, function}), run_tool() with try/except, correct appending of tool_use and tool_result, exit when no calls come back.get_time gets called and its result flows into the answer. Two calls in one turn work.The agent works with real files.
list_files, read_file, edit_file following the contracts from section 06 — including the ambiguity check.safe_path(). Not later. Without it, every test is a risk.list_files.The toy becomes a tool you can actually leave running.
bash tool with a timeout, bound to the working directory, with mandatory approval. Only now, not earlier.Tasks that last longer than one context window.
AGENTS.md · grep/glob · a task sub-agent with its own array.user message.safe_path() is.The same page, condensed to what a coding agent needs as its assignment. Copy it and hand it over as a prompt.
# 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.
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 ↗