The agent harness: everything around the model

A harness is the program around a language model that lets it act. The model only produces text. The harness decides what the model is shown, reads its reply, decides whether the reply is allowed to do anything, does it, looks at what happened, and decides whether to go round again. Claude Code, Codex CLI, Aider and SWE-agent are harnesses; so is a forty-line while loop around an API call.

The useful way to think about it: the model proposes, the harness disposes. The model is the one random, untrusted part of the system. Every guarantee you want, such as “never deletes files outside the repo” or “only says done when the tests pass”, has to live in the code around it, because nothing inside the model enforces it.

Here is one trip round that loop, with a concrete job: a tiny program adds numbers wrong, and the agent has been asked to fix it. Follow the numbers; each box says in plain words what happens, and the small grey tag is the symbol the rest of the article uses for it.

One trip round the loop, on a concrete example: a tiny program adds numbers wrong and the agent was asked to fix it. Step 1, the agent's notes are turned into a prompt. Step 2, the model writes a reply, the only step that guesses: change the file to use a plus b. Step 3, the reply is read as an instruction. Step 4, plain rules check it is allowed: the file is inside the project, so yes. Step 5, the instruction runs for real and the file on disk changes; the agent cannot see the real world, only the result that comes back, wrote 32 bytes. Step 6, the result is written into the notes. Step 7, stop here? If the model said done, the steps ran out or the user stopped it, the run stops; otherwise the next trip starts from the updated notes. One trip round the loop Example: a tiny program adds numbers wrong (2 + 3 gives −1). The agent was asked to fix it. The agent's notes visible state · q the task, every step so far, what came back Build the prompt prompt builder · π notes → one message for the model 1 Model replies model call · M “Change add.py to use a + b” 2 the only step that guesses Read the reply readout · Y a real instruction? yes 3 Check the rules gate · γ inside the project? yes → allowed 4 the real world files · servers · data Do it for real environment · QE the file on disk really changes 5 allowed Note the result verify, fold back · ρ the notes now say what really happened 6 result comes back: “wrote 32 bytes” Stop here? halt check · H only if “done”, out of steps, or user stopped it 7 no → next trip, with the new notes yes → stop Takeaway: the model only writes text. Plain code decides what runs, runs it, and keeps the notes. The agent never sees the real world itself, only the result that comes back.
One trip round the loop, on the example from later in the article. The numbers are the order; the grey tag under each name is the symbol the table below uses.
One trip round the loop, on a concrete example: a tiny program adds numbers wrong and the agent was asked to fix it. Step 1, the agent's notes are turned into a prompt. Step 2, the model writes a reply, the only step that guesses: change the file to use a plus b. Step 3, the reply is read as an instruction. Step 4, plain rules check it is allowed: the file is inside the project, so yes. Step 5, the instruction runs for real and the file on disk changes; the agent cannot see the real world, only the result that comes back, wrote 32 bytes. Step 6, the result is written into the notes. Step 7, stop here? If the model said done, the steps ran out or the user stopped it, the run stops; otherwise the next trip starts from the updated notes. One trip round the loop Example: a tiny program adds numbers wrong (2 + 3 gives −1). The agent was asked to fix it. the real world files · servers · data The agent's notes visible state · q the task, every step so far, what came back Build the prompt prompt builder · π notes → one message for the model 1 Model replies model call · M “Change add.py to use a + b” the only step that guesses 2 Read the reply readout · Y a real instruction? yes 3 Check the rules gate · γ inside the project? yes → allowed 4 Do it for real environment · QE the file on disk really changes 5 Note the result verify, fold back · ρ the notes now say what really happened 6 Stop here? halt check · H only if “done”, out of steps, or the user stopped it 7 allowed result: “wrote 32 bytes” prompt reply instruction no → next trip yes → stop Takeaway: the model only writes text. Plain code decides what runs, runs it, and keeps the notes. The agent never sees the real world itself, only the result that comes back.
One trip round the loop, on the example from later in the article. The numbers are the order; the grey tag under each name is the symbol the table below uses.

Not every reply is usable. The next figure shows the two ways a reply gets bounced: it isn’t an instruction at all, or it asks for something the rules forbid. Either way nothing runs; the model is told why and tries again on the next trip.

Two bad replies from the same example, and what happens to each. First, the model writes Sure, I will fix the bug now, which is chat and not an instruction; the readout cannot use it and a note, answer with one instruction, is added to the agent's notes. Second, the model asks to run r m dash r f tilde, which would wipe the home folder; the rules refuse it, not an allowed tool, and the note rejected, not an allowed tool, is added. In both cases nothing ran and the real world is untouched; the model is told why and tries again on the next trip. When the reply is no good Two more replies from the same example. Neither touches the real world; each costs one trip round the loop. Model replies “Sure! I will fix the bug now.” (chat, not an instruction) Read the reply readout · Y ✗ can't use it parse failure · ⊥Y Added to the notes “Answer with one instruction.” reply bounced next trip Model replies “Run: rm -rf ~” (would wipe the home folder) Check the rules gate · γ ✗ refused refusal · ⊥A Added to the notes “Rejected: not an allowed tool.” reply bounced next trip the real world: untouched — nothing ran, nothing changed Takeaway: a bad reply costs one trip and nothing else. The model is told why, and tries again.
Two replies that go nowhere: chat instead of an instruction, and a dangerous command. Each is bounced back as a note in the agent's notes; the real world is never touched.
Two bad replies from the same example, and what happens to each. First, the model writes Sure, I will fix the bug now, which is chat and not an instruction; the readout cannot use it and a note, answer with one instruction, is added to the agent's notes. Second, the model asks to run r m dash r f tilde, which would wipe the home folder; the rules refuse it, not an allowed tool, and the note rejected, not an allowed tool, is added. In both cases nothing ran and the real world is untouched; the model is told why and tries again on the next trip. When the reply is no good Two more replies from the same example. Neither touches the real world. Model replies “Sure! I will fix the bug now.” chat, not an instruction reply Read the reply readout · Y ✗ can't use it bounced parse failure · ⊥Y Added to the notes “Answer with one instruction.” → next trip: the model is told, and tries again Model replies “Run: rm -rf ~” would wipe the home folder reply Check the rules gate · γ ✗ refused bounced refusal · ⊥A Added to the notes “Rejected: not an allowed tool.” → next trip: the model is told, and tries again the real world: untouched, nothing ran Takeaway: a bad reply costs one trip and nothing else. The model is told why, and tries again.
Two replies that go nowhere: chat instead of an instruction, and a dangerous command. Each is bounced back as a note in the agent's notes; the real world is never touched.

The loop, part by part

One step of the loop runs through these parts in order. The symbol column is the small grey tag under each box in the figures, so the table doubles as their key.

PartSymbolWhat it doesIn a coding agent
States = (q, w)Everything that decides what happens next. Split into what the harness can see (q) and the real world it can’t fully see (w).q: the transcript, the plan, counters, permissions. w: the files on disk, the servers, the database.
Prompt builderπTurns the visible state into the model’s input: picks which history, files and tool descriptions go in.System prompt, the last N tool results, the tool schemas on offer.
Model callMThe only random step. Reads the prompt, writes text.One API call.
ReadoutYParses the text into a proposed action. Can fail (⊥Y): the reply isn’t valid JSON, names no tool, or is cut off.A tool call, or prose where a tool call was expected.
GateγDecides, with plain code and no model, whether the proposed action may run. A refusal (⊥A) goes back to the model as a message.Allow and deny lists, permission prompts, hooks.
EnvironmentQERuns the allowed action against the real world, which changes it (w → w′), and returns a receipt o.The shell command actually runs; its output comes back.
Verify and fold backρChecks the receipt and writes it into the visible state, so the next step sees what really happened.The tool result is appended to the transcript; a test is re-run.
HaltH, τHThe set of states where the loop stops, for any reason, and the step at which it does.The model says it’s done, the step budget runs out, or the user stops it.

Two details in that table do most of the work.

The state must be complete after fold-back. The model has no memory between calls; whatever it needs next step has to be in q by the time the loop comes round. Formally the loop is only Markov (the next step depends on the current state and nothing else) once ρ has run. In practice this is why a harness that forgets to append an error message makes the model repeat the same mistake: from its point of view the mistake never happened.

The gate is deterministic on purpose. If an LLM decided what the LLM is allowed to do, the permission would be as random as the request. A gate is a function of the action and the configuration: the same proposed command gets the same answer every time, and it can be tested.

What the harness sees, and what it doesn’t

The split between q and w is the reason harnesses are hard. The harness knows what it sent and what came back. It does not know the world. A command can succeed and print nothing; it can print “ok” and have failed; a file can change because something else touched it; a test can pass against a cached copy of the code. Every receipt o is a report about w, not w itself.

That has a sharp consequence for when to stop.

Three things can be true when a run stops, and only the first is what you want.

Three ways a run can end. One: the agent said done and the job really is done; this is the goal, and in the example the verifying run reaches it at step 8 after re-running the test itself. Two: the agent said done but the job is not done; the defence is to check the world yourself before accepting, which the article calls verify, rho; in the example the trusting run stops here at step 5 while the test still fails. Three: the run finished but something got broken on the way; the defence is to refuse dangerous actions before they run, the gate, gamma; in the example the request to wipe the home folder was refused at step 3 and never ran. Three ways a run can end Every run stops somewhere. What matters is which kind of stop it was, and that is judged on the real world, not on the notes. ✓ Said done, and it really is accepted halt inside G, outside B This is the goal. In the example: verifying run: stops at step 8 after re-running the test: pass ✗ Said done, but it isn't accepted halt outside G Defence: check the world yourself before accepting (verify · ρ) In the example: trusting run: stops at step 5, the test actually still fails ✗ Finished, but broke something halt inside B Defence: refuse dangerous actions before they run (gate · γ) In the example: “rm -rf ~” was refused at step 3, so it never ran Takeaway: the agent only sees its own notes, so “done” is a claim until something outside the model checks it. Each of the two failures has its own defence: verify before accepting, and refuse before running.
Three ways a run can end. Only the first is a real success; the other two each have their own defence.
Three ways a run can end. One: the agent said done and the job really is done; this is the goal, and in the example the verifying run reaches it at step 8 after re-running the test itself. Two: the agent said done but the job is not done; the defence is to check the world yourself before accepting, which the article calls verify, rho; in the example the trusting run stops here at step 5 while the test still fails. Three: the run finished but something got broken on the way; the defence is to refuse dangerous actions before they run, the gate, gamma; in the example the request to wipe the home folder was refused at step 3 and never ran. Three ways a run can end Every run stops somewhere. What matters is which kind of stop it was, judged on the real world. ✓ Said done, and it really is accepted halt inside G, outside B This is the goal. In the example: verifying run: stops at step 8 after re-running the test itself: pass ✗ Said done, but it isn't accepted halt outside G Defence: check the world yourself before accepting (verify · ρ) In the example: trusting run: stops at step 5, the test actually still fails ✗ Finished, but broke something halt inside B Defence: refuse dangerous actions before they run (gate · γ) In the example: “rm -rf ~” was refused at step 3, so it never ran Takeaway: the agent only sees its own notes, so “done” is a claim until something outside checks it. Two failures, two defences: verify before accepting, and refuse before running.
Three ways a run can end. Only the first is a real success; the other two each have their own defence.
SetMeaningWho decides
HAll halts: every state where the loop stops, whether it accepted, ran out of budget or was interrupted.The harness, from q.
HaccAccepted halts, a subset of H: the run stopped and was reported as done.The harness, from q.
GReal success: the task is actually done in the world.The world, w. Usually only checkable afterwards, or by a test.
BBad or unsafe states: a deleted directory, a leaked secret, a broken deploy.Judged on (q, w), so it can be true while nothing the harness can see says so.

The aim is that accepted halts land in G and never in B. The two failures are different and need different defences:

A complete harness

A harness small enough to read in one go. The model is replaced by a script of canned replies so the run is repeatable; swapping scripted_model for an API call changes nothing else. The task: fix add.py, which subtracts instead of adding. Run twice: once trusting the model’s “done”, once verifying it.

import json, pathlib, subprocess, tempfile

def scripted_model(script):
    """Stands in for the LLM: returns the next canned reply, whatever the prompt says."""
    replies = iter(script)
    return lambda prompt: next(replies)

ALLOWED = {"read", "write", "run_tests", "done"}

def build_prompt(state):                # π: state -> prompt
    return f"Task: {state['task']}\nHistory: {state['history'][-3:]}"

def parse(text):                        # Y, or ⊥Y on a parse failure
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return None

def gate(action, workdir):              # γ: deterministic, no model involved
    if action.get("tool") not in ALLOWED:
        return f"tool {action.get('tool')!r} not allowed"
    p = action.get("path")
    if p and not (workdir / p).resolve().is_relative_to(workdir):
        return f"path {p!r} outside the workspace"
    return None

def tests_pass(workdir):
    r = subprocess.run(["python", "-B", "-c", "from add import add; assert add(2, 3) == 5"],
                       cwd=workdir, capture_output=True)
    return r.returncode == 0

def execute(action, workdir):           # environment: touches the real files, returns a receipt o
    f = workdir / action.get("path", "")
    match action["tool"]:
        case "read":      return f.read_text()
        case "write":     f.write_text(action["text"]); return f"wrote {len(action['text'])} bytes"
        case "run_tests": return "pass" if tests_pass(workdir) else "fail"

def run(model, verify, max_steps=8):
    workdir = pathlib.Path(tempfile.mkdtemp()).resolve()
    (workdir / "add.py").write_text("def add(a, b):\n    return a - b\n")      # the bug
    state = {"task": "make add(2, 3) == 5", "history": [], "steps": 0}
    while state["steps"] < max_steps:                                       # H includes the step budget
        state["steps"] += 1
        action = parse(model(build_prompt(state)))
        if action is None:
            obs = "unparseable reply, answer with one JSON object"
        elif (why := gate(action, workdir)):
            obs = f"rejected: {why}"
        elif action["tool"] == "done":
            if not verify or tests_pass(workdir):                           # ρ: check before accepting
                return f"halted at step {state['steps']}, accepted; tests really pass: {tests_pass(workdir)}"
            obs = "done refused: tests fail"
        else:
            obs = execute(action, workdir)
        state["history"].append(obs)                                        # ρ: fold the receipt back
        print(f"  {state['steps']}: {obs.strip()[:60]!r}")
    return f"stopped: step budget of {max_steps} used up"

FIX = json.dumps({"tool": "write", "path": "add.py", "text": "def add(a, b):\n    return a + b\n"})
script = ['{"tool": "read", "path": "add.py"}',
          'Sure! I will fix the bug now.',
          '{"tool": "shell", "cmd": "rm -rf ~"}',
          '{"tool": "read", "path": "../../etc/passwd"}',
          '{"tool": "done"}',
          FIX,
          '{"tool": "run_tests"}',
          '{"tool": "done"}']

print("trusting harness:");  print(" ", run(scripted_model(script), verify=False))
print("verifying harness:"); print(" ", run(scripted_model(script), verify=True))

Output, on Python 3.12.13:

trusting harness:
  1: 'def add(a, b):\n    return a - b'
  2: 'unparseable reply, answer with one JSON object'
  3: "rejected: tool 'shell' not allowed"
  4: "rejected: path '../../etc/passwd' outside the workspace"
  halted at step 5, accepted; tests really pass: False
verifying harness:
  1: 'def add(a, b):\n    return a - b'
  2: 'unparseable reply, answer with one JSON object'
  3: "rejected: tool 'shell' not allowed"
  4: "rejected: path '../../etc/passwd' outside the workspace"
  5: 'done refused: tests fail'
  6: 'wrote 32 bytes'
  7: 'pass'
  halted at step 8, accepted; tests really pass: True

The same eight replies as a picture, side by side:

The same eight model replies run through two harnesses. Steps 1 to 4 go the same way in both: the file is read, a chatty reply is bounced, a request to wipe the home folder is refused, a path outside the project is refused. At step 5 the model says done. The trusting harness accepts and stops, although the test still fails. The verifying harness re-runs the test, sees it fail, and refuses; the model then writes the fix at step 6, runs the test at step 7, pass, and says done at step 8, which the harness checks and accepts. Same model, same replies: the trusting run ends on a false done at step 5, the verifying run on a true one at step 8. Same model, same replies, two harnesses The eight replies are identical in both runs. The only difference is whether “done” is checked before it is believed. step the model's reply trusting harness · believes “done” verifying harness · re-runs the test first 1 asks to read add.py shows the file same 2 “Sure! I will fix the bug now.” bounced: not an instruction same 3 asks to run rm -rf ~ refused: not an allowed tool same 4 asks to read ../../etc/passwd refused: path outside the project same 5 says “done” ACCEPTED — test still fails ✗ re-runs the test: fail → refused 6 writes the fix into add.py — wrote 32 bytes 7 asks to run the test — pass 8 says “done” — re-runs test: pass → ACCEPTED ✓ stops at step 5: a false “done” stops at step 8: a true “done” Takeaway: trusting the model's “done” ended the run three steps earlier, on a lie it could not see. Verifying cost three more steps and bought a “done” you can believe.
The same eight replies through both harnesses. Steps 1 to 4 go the same way; at step 5 the trusting harness believes “done” and stops, the verifying one checks and goes on.
The same eight model replies run through two harnesses. Steps 1 to 4 go the same way in both: the file is read, a chatty reply is bounced, a request to wipe the home folder is refused, a path outside the project is refused. At step 5 the model says done. The trusting harness accepts and stops, although the test still fails. The verifying harness re-runs the test, sees it fail, and refuses; the model then writes the fix at step 6, runs the test at step 7, pass, and says done at step 8, which the harness checks and accepts. Same model, same replies: the trusting run ends on a false done at step 5, the verifying run on a true one at step 8. Same replies, two harnesses Eight identical replies. The only difference is whether “done” is checked before it is believed. 1 asks to read add.py both: shows the file 2 “Sure! I will fix the bug now.” both: bounced: not an instruction 3 asks to run rm -rf ~ both: refused: not an allowed tool 4 asks to read ../../etc/passwd both: refused: path outside the project 5 says “done” trusting: ACCEPTED — test still fails ✗ verifying: re-runs test: fail → refused 6 writes the fix into add.py verifying: wrote 32 bytes (trusting: run over) 7 asks to run the test verifying: pass (trusting: run over) 8 says “done” trusting: run already over verifying: re-runs test: pass → ACCEPTED ✓ trusting: stops at step 5, a false “done” verifying: stops at step 8, a true “done” Takeaway: trusting “done” ended the run three steps earlier, on a lie it could not see. Verifying cost three more steps and bought a “done” you can believe.
The same eight replies through both harnesses. Steps 1 to 4 go the same way; at step 5 the trusting harness believes “done” and stops, the verifying one checks and goes on.

Every part of the loop shows up in eight steps:

The verifying harness took three more steps and got the right answer. That is the usual trade: verification costs steps and time, and buys accepted halts you can believe.

Design rules that follow

Things that go wrong

Questions? Let's Chat :)

ai llm engineering