From LLM to HarnessPart 6/11

The same loop, different seams

Every harness has a log, a tool registry, a context budget and a permission layer. They differ in which of those you may replace — I read four to answer.

Four identical loops, each with a cut at a different position
TL;DR — Every harness has four parts: a log, a tool registry, a context budget, a permission layer. They do not differ in which parts exist. They differ in which parts you may replace.

The right question

The last post concluded that a harness is four parts you can pull out. If that holds, every harness has all four, and the comparison question has to be a different one.

The question I use: when it breaks, what am I allowed to replace without editing the core?

To answer it I read the source of four other harnesses. Every claim below carries path:LINE at a pinned commit.

HarnessWhere the loop livesReplaceable?
Codexcodex-rs/core/src/session/turn.rs:163Partly — see below
Gemini CLIpackages/core/src/core/client.ts:924Partly — the loop body is hard-coded
Claude CodeNot publishedHooks only
OpenHandsNot in this repositoryBy a protocol boundary

The finding that made me correct my own notes

My earlier notes concluded Codex does not let you replace the loop. True, but incomplete — and I only found that out by reading the file I had skipped.

Codex has exactly the “loop is a plugin” structure:

codex-rs/core/src/tasks/mod.rs
pub(crate) trait SessionTask: Send + Sync + 'static {

pub(crate). And the only production call site names a concrete type:

codex-rs/core/src/tasks/mod.rs:532
self.start_task(turn_context, Vec::new(), RegularTask::new())

So an out-of-tree crate cannot install a different SessionTask. That is why I wrote “no”.

Where I was wrong is in a file I had not read: the Stop hook. Inside the turn loop there is this:

codex-rs/core/src/session/turn.rs
let stop_outcome = run_turn_stop_hooks(…).await;
…
if stop_outcome.should_block {
// record a new prompt into the session
stop_hook_active = true;
continue; // the loop runs another iteration
}

A third-party hook can block the turn from ending and inject the prompt for the next one. It cannot replace the loop, but it controls whether the loop keeps going.

This is where I had to split one concept into two:

Replace the loop
  • Means: write a different implementation of the loop itself.
  • Codex: no. The trait is pub(crate); the call site names a concrete type.
  • Gemini CLI: no. Three hard-coded loop bodies in core.
  • Claude Code: unreadable, the engine is closed.
Drive the loop
  • Means: decide when the loop stops, and what the next iteration carries.
  • Codex: yes. The Stop hook returns should_block and injects a prompt.
  • Gemini CLI: partly, through BeforeAgent/AfterAgent hooks.
  • This is the axis that actually separates them.

Three other answers to the same question

Gemini CLI publishes the tool registry but hard-codes the loop. The registration method is public and typed:

packages/core/src/tools/tool-registry.ts
registerTool(tool: AnyDeclarativeTool): void {

A third party can add a tool without editing core. But the loop body sits in three different places in core:

PlaceLine
packages/core/src/core/client.ts924
packages/core/src/agent/legacy-agent-session.ts184
packages/core/src/agents/local-executor.ts685

Each is its own while (true) or recursive function, and none of them can be registered.

Claude Code publishes the seam but not the engine. The repo carries a 13,044-line TypeScript declaration generated from the binary, listing events like turn.start, turn.step and tool.call. You can attach to the hook chain. But what runs the loop is a closed binary, and that same declaration says: EARLY ACCESS: this surface may change between releases without notice.

OpenHands answers with a version boundary. At the commit I read, the repo is a client — 2,059 TypeScript files against 9 Python files, all of them CI. The loop lives in a separate image:

config/defaults.json
"versions": { "agentServer": "1.49.6" },
"compatibility": { "minimumAgentServer": "1.28.0" },
"images": { "agentServer": "ghcr.io/openhands/agent-server" }

Rather than making the loop a plugin, they put it behind a protocol and a version number. That is a third answer, and a “no privileged core” framing does not describe it.

Four layers stacked on each other

On the harness I read, configuration is not one file but four layers applied in order:

Four configuration layers stackedA profile boots by applying four patch layers to an empty list; each later layer wins over the one beforeBundles in profile orderThe profile's cordis.patch.ymlThe home-level patchThe --patch flag at launchThe final plugin tree
Figure 1 — Four configuration layers stacked · tusgino
View diagram data
type: layers
title: Four configuration layers stacked
desc: A profile boots by applying four patch layers to an empty list; each later layer wins over the one before
nodes:
  - id: bundles
    label: Bundles in profile order
    kind: input
  - id: profile
    label: The profile's cordis.patch.yml
  - id: home
    label: The home-level patch
  - id: overlay
    label: The --patch flag at launch
    kind: optional
    focal: true
  - id: result
    label: The final plugin tree
    focal: true
edges:
  - [bundles, profile]
  - [profile, home]
  - [home, overlay]
  - [overlay, result]

That is the first price, and it is real: to find where a behaviour comes from you must dump the whole tree. Four layers, each overriding the one before, and nothing lists the answer for you.

It is also the benefit: the topmost layer always wins, so you can never be stuck because a bundle set a bad default.

Five more prices

This is the part I want every comparison to carry: not only what works well, but what it costs.

ChoiceThe price
The log is the source; history is derivedThere is no second copy to read quickly
An unknown event means refusing to rebuildResume fails loudly instead of silently coming back empty
The sandbox speaks only about the filesystemThe network is not protected at this layer
100% coverage per fileNo small repo can afford this
The session format is one generation aheadv4 is finalized; v3 is what shipped

The first two deserve a note.

A session is an append-only log, and the history sent to the model is derived from it, never stored separately. That means there is no array to read quickly — every question about the past is a rebuild.

And this is one of the best design decisions I have read:

Absent means required: a reader meeting an unrecognized type without this marker MUST refuse to reconstruct the session instead of silently dropping the event, because an unrecognized required event may change how the rest of the log is interpreted. … defaulting to required means a forgotten marker over-refuses (an inconvenience) rather than silently resuming a gutted session.— dsh, docs/subsystems/session.md

They chose to fail loudly. An unknown event missing that marker must refuse to rebuild, rather than skip it and resume a session with its guts pulled out. The default leans toward refusal, because refusing is annoying while resuming empty is a silent disaster.

Criteria first, conclusions after

I read three Anthropic posts on harness design. They tell their story well, but they tell what already worked — a full harness running 6 hours for 200,against20minutesfor200, against 20 minutes for 9. That is n=1, and not repeatable.

So this post publishes its criteria first:

  • Measured how: reading source at pinned commits and comparing structure. Not speed, not quality.
  • What it answers: how they differ. Not which is better.
  • The limits: Claude Code’s engine is unreadable. OpenHands has no loop in the repo. Those two rows are inference, not observation.

Do this now

  1. Write down what you are allowed to replaceNot what you can configure. What you can change at 2am when the system is broken, without rebuilding the core.
  2. Separate 'replace' from 'drive'Two different questions. Codex lets you drive the loop but not replace it. Knowing which cell you are in picks the right way to extend.
  3. Publish your criteria before comparingIf you do not say what you measured and what you skipped, every comparison you write is storytelling.
  4. State the price, not only the benefitEvery architectural choice has an invoice. A lesson is only worth something when the invoice is attached.

The rule

Four harnesses, four answers to the same question. None of them is right for everyone, because they optimise for four different users.

The question is not “which harness is best”. It is “what am I allowed to replace when it breaks, at 2am, without rebuilding the core”.— tusgino
    • Every harness has the four parts; the axis is which part you may replace, not which parts exist.
    • “Loop as a plugin” is not unique in shape — Codex has exactly that structure, kept pub(crate).
    • Separate “replace the loop” from “drive the loop”: Codex allows the second through the Stop hook.
    • Publish criteria before conclusions, and say plainly where you inferred rather than observed.
    • Every architectural choice has a price; a comparison that omits prices is storytelling.

Frequently asked questions

Which harness is best?
That question cannot be answered, and it is not the right question. The right one is: when it breaks, what am I allowed to replace without editing the core.
Is making the loop a plugin the differentiator?
Not if you only mean the shape. Codex has exactly that structure but keeps it private. The real difference is whether that seam is reachable from outside.
Why not compare speed or quality?
Because I cannot measure them. I read source and compared structure. Every comparison here is about how they differ, not which is better.
What does it cost to make everything a plugin?
To find where a behaviour comes from you must dump the whole config tree. Four patch layers stack, and the topmost one wins.
Tú (tusgino)Tú (tusgino)

AI Software Engineer in Da Nang. I write about production system architecture and what each choice costs.