# A harness for an AI-native app: evals, labs and a fast judge

> What changed when every decision a model makes in my app became measurable — what evals actually test, how labs turn hunches into hypotheses, and why a fast, cheap decision model turned out to be the breakthrough.

By Aleksandr Kondaurov · Oct 25, 2026 · 22 min read
Tags: llm, agents, evals
URL: https://kondaurov.dev/blog/harness-evals-labs-judge/

One of my tools had a parameter for the file format, and its description said, in plain words: _PNG only when the user asks for a PNG._ Over thirty days the model set it to PNG in 64 calls. In 64 of them nobody had asked. Phone photos went out as files many times their size, and I had no idea, because nothing in the app ever looked — not at this, and not at any other rule in any other description.

That is the problem this post is about. I build apps with a model inside, and I build them together with a coding agent. For a long time both of us made decisions on a feeling: the prompt reads better, the answer looks right, it seems to work. What changed is that I gave the app a **harness** — a set of tools around the model where every decision it makes can be measured — and the conversations with the agent stopped being about feelings.

The app itself doesn't matter here; it's one case of a general problem. Enough to know that, among other things, it edits people's photos, and that some of its steps are paid calls to an image model. The numbers below are from it. The code is from an invented one.

<!-- excerpt -->

## A harness for the app, not for the agent

"Harness" usually means the scaffolding around a coding agent. I mean something else: the scaffolding around **the model inside your product**. Mine has five parts.

- **Traces from production.** Every turn stores what the model was told, which tools it called with which arguments, what came back and what the person did next. A small CLI reads them.
- **Evals.** Short tests against a real model, each case with a known answer.
- **Labs.** Experiments: a variant against the baseline on real scenarios, run against a local copy of the backend.
- **A fast judge.** A small decision model that answers questions about text and pictures in under a second, for a fraction of a cent.
- **A journal.** One file per experiment: the hypothesis, the numbers, the decision — kept even when the idea is dropped.

None of this is a framework. It's a few hundred lines of glue and a habit. The coding agent built most of it. And once it existed, the agent's work changed too: instead of "I improved the prompt", it could say "the eval went from 14 to 17 out of 17" or "this run costs 35 cents, here is the table".

The traces came first, so I'll start there.

## Start with the traces

The PNG story came out of one question I started asking of thirty days of production: for every rule in a tool description, _how often is it broken?_ The traces had the answer for each one.

| What the model was asked to decide                  | What it did in thirty days                       |
| --------------------------------------------------- | ------------------------------------------------ |
| File format: "PNG only if asked"                    | Set a format in 64 calls; nobody had asked       |
| A crop rectangle, estimated by eye from a preview   | 24 of 90 crops replaced its own previous crop    |
| "Keep the faces when adjusting a photo of a person" | Did so in 15 of 67 calls                         |
| Ask the user only one kind of question              | 94 of 114 questions were something else          |
| How hard the task in front of it is                 | Agreed with an independent reading in 170 of 195 |

The last row is there on purpose. Data doesn't only convict the model; sometimes it acquits it. I was sure the effort setting was wrong — 84 % of tasks marked "demanding" looked absurd — and the judge, asked the same question independently, agreed with the model almost every time. Without the trace I would have "fixed" something that worked.

Two more findings I didn't expect. Rules the code _could_ enforce — never repeat a call a third time, never retry after a safety refusal — turned out to be followed almost perfectly; they didn't need code. And when I trimmed every tool description of what the code could handle instead, the texts shrank by about a tenth. Not enough to matter. Whatever the problem was, it wasn't the wording.

So the audit left me with a list of rules the model keeps breaking, and no way to tell whether any fix helped. The next two parts are for that.

## Evals: what they actually test

An eval is not a measure of quality. It's a **contract**: a handful of inputs where the right behaviour is known, run against the real model. It holds a floor, and it tells you when a change broke something. It doesn't tell you a change made things better — that's what labs are for.

Mine come in three kinds.

**Choice evals.** Given this conversation, did the model call the right tool with the right arguments? "Remove the background" must be the cheap deterministic step, not a paid redraw. A photo of a person made "brighter and juicier" must be a tone adjustment, not a render that draws a new face. These are the cheapest to write and catch the most regressions after a prompt edit.

**Checks of meaning.** Some properties no assertion can express: did the model's image prompt describe the change ("replace the red banner") instead of the result ("a green banner hangs on the wall")? Did it give a person's hair a colour nobody asked for? Is there a visible defect in the edited picture — a grey blotch, a smear, a hard seam? For these, a judge model answers a yes/no question, and the eval asserts the answer.

**Calibration of the judge itself.** This is the one people skip, and it's the one that matters most. Before a judge's "yes" means anything, it answers questions where I know the answer. For the defect check, I took clean photos and broke copies of them in code — a flat grey rectangle, a black box, a smeared square — so no render was paid for. The clean ones read 0.17, the broken ones 0.96–0.98. Only then did "defect" become a check I trust.

Two practical things. Models are not deterministic, so a case that passes once proves little; mine run "k of n", two runs, both must pass. And an eval's question can outlive the eval: the check "does this prompt describe a change?" started as a test and is now production code. Write your checks as if they might end up in the product, because the good ones do.

An eval tells you that a change didn't break the floor. It can't tell you which of two ideas is better. For that I needed a different form.

## Labs: hypotheses, not hunches

A lab answers "which is better, and by how much". The form is strict, and the strictness is the point:

1. **The hypothesis is written before the run.** One sentence: what I expect and why.
2. **A variant against the baseline,** on the same scenarios, at the same settings. One thing differs.
3. **The cost is stated up front.** "Six renders, about 35 cents." It keeps experiments small and honest.
4. **The result goes in the journal,** with the decision. Dropped ideas too — especially those. Six months later "why don't we use X?" has an answer with numbers.

Here is how a real one went, because the interesting part is how the hypothesis changed along the way.

The task: remove an object from a photo — a bottle on a table, a lamp on a pillar, one flower in a banner. The image editor (GPT Image) takes the photo and a mask of the object; afterwards my code pastes the original pixels back everywhere outside the mask, so nothing else in the photo can change. Removals came back wrong often enough that people asked twice.

My first hypothesis was that the mask was too tight. The lab said otherwise: with a mask the exact shape of the bottle, the editor painted _another_ bottle — or a vase, or a glass jar — into the hole, four times in four. A hole the shape of a bottle is a very strong hint to draw a bottle.

The second hypothesis was to widen the region to cover the object's shadow and reflection. That fixed the flower, whose reflection in the water had been left behind. For the lamp it made things worse: the judge's box for "the glow" was a strip down to the floor, and the editor filled the strip with a new stone column.

The third was to drop the mask entirely and tell the editor in words what to remove. The bottle vanished cleanly, five times in five, shadow included. But in the banner it removed _every_ flower, not the one I meant, and without a mask there was nothing to paste back along — the whole photo came back at the editor's resolution, and a face in it would drift.

At that point I asked the question that turned it around — a question, not a fix, which is the theme of [my last post](/blog/dont-follow-the-agent/): _what if the paste-back itself is the problem?_ The next experiment cost nothing. The lab took the renders it already had and composited them back onto the original in different ways, purely in pixels, and the judge scored each composite.

<svg viewBox="0 0 720 270" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;font-family:var(--font-sans);font-size:13px" role="img" aria-label="Three ways to paste a clean render back over the original photo of a bottle on a table. Along the bottle's outline with an 8 px blend: a ghost of the bottle remains, removed 0.03, defect 0.95. Widened by a ninth of the object with the same blend: a fainter ghost, removed 0.10, defect 0.90. Widened by a ninth and blended over a sixth of the object: clean, removed 0.98, defect 0.17">
  <defs>
    <filter id="feather" x="-20%" y="-20%" width="140%" height="140%">
      <feGaussianBlur stdDeviation="4"/>
    </filter>
  </defs>
  <g>
    <text x="120" y="22" text-anchor="middle" fill="var(--text)" font-weight="600">Along the outline</text>
    <text x="120" y="38" text-anchor="middle" fill="var(--text-muted)" font-size="12">8 px blend — what I shipped</text>
    <rect x="32" y="50" width="176" height="150" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <path d="M32 170 H208" stroke="var(--border-strong)"/>
    <path d="M112 80 h16 v20 h16 v70 h-48 v-70 h16 z" fill="var(--text-muted)" opacity="0.35"/>
    <path d="M112 80 h16 v20 h16 v70 h-48 v-70 h16 z" fill="none" stroke="var(--accent)" stroke-width="2"/>
    <text x="120" y="228" text-anchor="middle" fill="var(--text-secondary)" font-size="12">removed 0.03 · defect 0.95</text>
    <text x="120" y="248" text-anchor="middle" fill="var(--text-muted)" font-size="12">a ghost of the bottle</text>
  </g>
  <g>
    <text x="360" y="22" text-anchor="middle" fill="var(--text)" font-weight="600">Widened by 1/9 of the object</text>
    <text x="360" y="38" text-anchor="middle" fill="var(--text-muted)" font-size="12">8 px blend</text>
    <rect x="272" y="50" width="176" height="150" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <path d="M272 170 H448" stroke="var(--border-strong)"/>
    <path d="M352 80 h16 v20 h16 v70 h-48 v-70 h16 z" fill="var(--text-muted)" opacity="0.18"/>
    <path d="M346 74 h28 v26 h16 v76 h-60 v-76 h16 z" fill="none" stroke="var(--accent)" stroke-width="2"/>
    <text x="360" y="228" text-anchor="middle" fill="var(--text-secondary)" font-size="12">removed 0.10 · defect 0.90</text>
    <text x="360" y="248" text-anchor="middle" fill="var(--text-muted)" font-size="12">a fainter ghost</text>
  </g>
  <g>
    <text x="600" y="22" text-anchor="middle" fill="var(--text)" font-weight="600">Widened by 1/9</text>
    <text x="600" y="38" text-anchor="middle" fill="var(--text-muted)" font-size="12">blend over 1/6 of the object</text>
    <rect x="512" y="50" width="176" height="150" rx="6" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
    <path d="M512 170 H688" stroke="var(--border-strong)"/>
    <path d="M586 74 h28 v26 h16 v76 h-60 v-76 h16 z" fill="none" stroke="var(--accent)" stroke-width="10" opacity="0.55" filter="url(#feather)"/>
    <text x="600" y="228" text-anchor="middle" fill="var(--accent)" font-size="12" font-weight="600">removed 0.98 · defect 0.17</text>
    <text x="600" y="248" text-anchor="middle" fill="var(--text-muted)" font-size="12">clean</text>
  </g>
</svg>

A clean render, pasted back along the object's outline with a sharp blend, produced the ghost of the very object the editor had removed. The edge was the bug — not the model, and not the mask.

What shipped: a removal goes to the editor without a mask, so there is no hole to fill with a new object, and is pasted back over a region that grows with the object's size, so everything far from it — other flowers, faces, the photo's full resolution — stays the original's. The scenario that had failed three times in three passed three in three.

Some traps I fell into along the way, so you don't have to:

- **n = 1.** One render tells you almost nothing. Two runs per variant is my minimum; the one time I trusted a single run, the next one disagreed.
- **Tuning the judge on the data you measure.** When I added two examples to a judge's question that were paraphrases of cases in the evaluation set, the score went up. Of course it did. Write it down when you do it.
- **A bad question is not a bad system.** I twice failed to phrase a "was it done?" question that could tell a correct crop from the uncropped original — both genuinely contained the object whole. The crop was right; my question was wrong. When a check fails, look at the picture before you change the code.
- **Experiment locally, read production.** My labs run against a local dev server; production is only ever read. It keeps experiments cheap and keeps the people using the app out of them.

Every number under those three pictures, every "removed" and "defect", was a judge's answer. Which is the part I'd build first if I were starting over.

## The fast judge: the breakthrough

Everything above leans on one component: **a decision model that is fast, cheap and calibrated.**

Mine is TypeSafe Jev, running on Cloudflare Workers AI. It doesn't write text. It answers questions of four shapes:

- **yes/no** — a calibrated probability;
- **a choice** among named options — with a probability for each;
- **a score** on an ordered rubric;
- **a box** — where a described thing is in an image (new in version 1.14).

It answers in well under a second and costs a fraction of a cent. Because it can't write prose, it can't drift into reasoning and talk itself into an answer — it gives you a number, and the number means the same thing every time.

What makes it a breakthrough is not any single use. It's that **the same question works in three places**:

<svg viewBox="0 0 720 230" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;font-family:var(--font-sans);font-size:13px" role="img" aria-label="One calibrated question feeds three places: an eval asks whether the model's behaviour is still right, a lab asks which variant is better, and production decides now or sends the call back">
  <defs>
    <marker id="arrow-q" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0 L10 5 L0 10 z" fill="var(--text-muted)"/>
    </marker>
  </defs>
  <rect x="8" y="70" width="232" height="90" rx="10" fill="var(--accent-subtle)" stroke="var(--accent)"/>
  <text x="124" y="102" text-anchor="middle" fill="var(--text)" font-weight="600">one question</text>
  <text x="124" y="122" text-anchor="middle" fill="var(--text-secondary)" font-size="12">a yes/no, a choice, a score, a box</text>
  <text x="124" y="142" text-anchor="middle" fill="var(--text-muted)" font-size="12">held to known answers once</text>
  <path d="M240 115 C 296 115, 296 36, 352 36" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow-q)"/>
  <path d="M240 115 H352" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow-q)"/>
  <path d="M240 115 C 296 115, 296 194, 352 194" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow-q)"/>
  <rect x="356" y="8" width="356" height="56" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="376" y="31" fill="var(--accent)" font-weight="600">eval</text>
  <text x="376" y="51" fill="var(--text-secondary)" font-size="12">is the model's behaviour still right?</text>
  <rect x="356" y="87" width="356" height="56" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="376" y="110" fill="var(--accent)" font-weight="600">lab</text>
  <text x="376" y="130" fill="var(--text-secondary)" font-size="12">which variant is better, and by how much?</text>
  <rect x="356" y="166" width="356" height="56" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="376" y="189" fill="var(--accent)" font-weight="600">production</text>
  <text x="376" y="209" fill="var(--text-secondary)" font-size="12">decide now, or send the call back to the model</text>
</svg>

You write a question once, hold it to known answers once, and then it judges your tests, scores your experiments, and makes decisions in the live product — and you already know how far to trust it.

Here's a check from an invented app — a resale assistant that turns a seller's photo into a listing — written the way I write them:

```ts
// Is someone in the photo? Faces are blurred before a listing goes live.
const PERSON_IN_PHOTO = {
  type: "yes_no",
  question:
    "Is there a person in the photo — however small, partly seen, a face, " +
    "a hand, someone reflected in a mirror?",
  yes: {
    description: "someone is in the picture",
    examples: [
      "a jacket worn by someone",
      "a hand holding a lamp",
      "a face in the mirror behind a dresser",
    ],
  },
  no: {
    description: "only the item and its surroundings",
    examples: ["a jacket on a hanger", "a lamp on a table", "a dog on a sofa"],
  },
  threshold: 0.5,
}

// Held to known answers before anything trusts it.
const CASES = [
  { photo: "fixtures/jacket-worn.jpg", person: true },
  { photo: "fixtures/lamp-in-hand.jpg", person: true },
  { photo: "fixtures/dresser-mirror.jpg", person: true },
  { photo: "fixtures/jacket-hanger.jpg", person: false },
  { photo: "fixtures/sofa-with-dog.jpg", person: false },
]

for (const c of CASES) {
  test(`${c.person ? "someone" : "no one"}: ${c.photo}`, async () => {
    const p = await judge.ask(PERSON_IN_PHOTO, { image: await view(c.photo) })
    expect(p >= PERSON_IN_PHOTO.threshold).toBe(c.person)
  })
}
```

The examples inside the criteria matter more than the wording of the question. And the "no" side deserves as much care as the "yes": most failures I've seen were false yeses on things that merely look like the answer.

### Boxes

The box question is new and, for anything visual, a big deal. Ask "where is the denim jacket?" and you get `{ top, bottom, left, right }` as fractions of the image.

What I measured before using it:

- **Large things** come back roughly where a hand would draw them (IoU 0.84–0.91 against boxes I drew myself).
- **Small things** are found but bounded loosely — "1.5 L" on a label came back two and a half times its size. A margin fixes most of it.
- **A box never says "not there."** Ask for a dog in an empty room and you still get a box. So every box question goes out with a yes/no beside it: present things read 0.76–0.95, absent ones 0.02–0.23.
- **It reads parts of the frame as things.** "The top half of the picture" came back as a box from 14 % to 65 % of the height. Edges are arithmetic; don't ask a vision model to do arithmetic.
- **It's a better seed than words.** Asked to segment "roll of bags", a segmentation model (SAM 3) returned two or three masks, or none; seeded with the judge's box, exactly one.

That last point is what fixed the crop row in the audit table. The model used to estimate crop rectangles by eye from a preview; one turn had nineteen crop calls in a row, ending in "I couldn't assemble it." Now the model names the thing and the judge finds it:

```ts
// The model says what to keep; it never estimates coordinates.
async function cropTo(photo: Image, part: string) {
  const { where, there } = await judge.ask(
    {
      where: { type: "box", question: `${part}, in the picture` },
      there: { type: "yes_no", question: `Is ${part} in the picture?` },
    },
    { image: await view(photo) }, // a downscaled view: the judge has an input limit
  )
  if (there < 0.5) {
    return { error: `${part} is not in the photo — name it as it looks there` }
  }
  // A loose crop can be tightened later; a clipped object can't be restored.
  return crop(photo, widen(where, { byShareOfLongSide: 0.2 }))
}
```

Held to hand-drawn boxes, every part came out whole in its crop, thirteen of thirteen.

### Where the judge stops

It is not magic, and the limits are worth knowing:

- **It only knows what you show it.** It can't tell "do it again?" from "want me to try the paid version instead?" when the difference lies in what happened before the question, and the question's text doesn't carry it.
- **It has an input limit and a rate limit.** A 1,536 px image was rejected for me, and so was a thumbnail under 256 px a side; bursts get throttled, under an error that reads like bad credentials. Send a view of 280–1,024 px, and keep concurrency low.
- **A failed call needs a fallback — with a warning.** When the judge can't answer, a removal goes through the old path, a crop is refused. Every fallback logs. A silent one is a bug waiting.
- **Make it a required dependency.** For months my code treated the judge as optional: when it was missing, each step quietly skipped itself. When I made it required, the compiler immediately found two places where my own judge calls were never given the service — the "optional" pattern had been hiding a real bug.

With the judge in production, one more pattern became possible, and it did more for prompt-following than any rewording.

## Send it back, don't tell it

**A rule in a long prompt is a wish; an error at the right moment is a rule.**

My tool descriptions said an image edit's prompt must describe the result, not the change. In about half the eval runs the model wrote "Remove whatever object lies under the painted area…" anyway. With a mask, the editor at least saw where. Without one — after the removal change above — the editor never sees what the words point at, and in the lab that prompt removed the object once in six.

So the eval's check became a gate. Before an edit renders, the judge reads the prompt; if it describes a change, the call comes back to the model as the tool's error, with an example of what to write instead. The model rewrites it — always, in my runs — and the render goes out with the better prompt. The tool-choice eval went from failing three cases on wording to 17 of 17, and nothing is paid for a bad prompt.

<svg viewBox="0 0 720 262" style="width:100%;max-width:720px;display:block;margin:1.5rem auto;font-family:var(--font-sans);font-size:13px" role="img" aria-label="The send-back loop: the model calls the tool with a prompt; the tool handler asks the judge whether the prompt describes a change; if it does, the call returns to the model as the tool's error with an example, at most twice, and the model rewrites it; if the prompt describes the result, the paid step runs">
  <defs>
    <marker id="arrow-s" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
      <path d="M0 0 L10 5 L0 10 z" fill="var(--text-muted)"/>
    </marker>
  </defs>
  <rect x="8" y="80" width="160" height="56" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="88" y="103" text-anchor="middle" fill="var(--text)" font-weight="600">model</text>
  <text x="88" y="123" text-anchor="middle" fill="var(--text-muted)" font-size="12">rewrites — always, so far</text>
  <path d="M168 108 H240" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow-s)"/>
  <text x="204" y="72" text-anchor="middle" fill="var(--text-muted)" font-size="12">the call, with its prompt</text>
  <rect x="240" y="80" width="170" height="56" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="325" y="103" text-anchor="middle" fill="var(--text)" font-weight="600">tool handler</text>
  <text x="325" y="123" text-anchor="middle" fill="var(--text-muted)" font-size="12">before anything is paid for</text>
  <path d="M325 136 V176" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow-s)"/>
  <text x="334" y="152" fill="var(--text-muted)" font-size="12">does the prompt</text>
  <text x="334" y="166" fill="var(--text-muted)" font-size="12">describe a change?</text>
  <rect x="240" y="176" width="170" height="48" rx="8" fill="var(--accent-subtle)" stroke="var(--accent)"/>
  <text x="325" y="196" text-anchor="middle" fill="var(--text)" font-weight="600">judge</text>
  <text x="325" y="214" text-anchor="middle" fill="var(--text-muted)" font-size="12">one yes/no, under a second</text>
  <path d="M240 200 H88 V136" fill="none" stroke="var(--accent)" stroke-width="1.5" marker-end="url(#arrow-s)"/>
  <text x="14" y="222" fill="var(--accent)" font-size="12">yes → back as the tool's error,</text>
  <text x="14" y="238" fill="var(--accent)" font-size="12">with an example to copy —</text>
  <text x="14" y="254" fill="var(--accent)" font-size="12">at most twice, then it goes through</text>
  <path d="M410 108 H552" fill="none" stroke="var(--text-muted)" stroke-width="1.5" marker-end="url(#arrow-s)"/>
  <text x="481" y="72" text-anchor="middle" fill="var(--text-muted)" font-size="12">no → it describes the result</text>
  <rect x="552" y="80" width="160" height="56" rx="8" fill="var(--bg-raised)" stroke="var(--border-strong)"/>
  <text x="632" y="103" text-anchor="middle" fill="var(--text)" font-weight="600">paid step</text>
  <text x="632" y="123" text-anchor="middle" fill="var(--text-muted)" font-size="12">the render goes out</text>
</svg>

The same pattern fixed the fourth row of the audit. The model may ask the user exactly one kind of question — whether to try the paid step next to a free result it already made. Over thirty days it asked 114 questions, and 94 were something else: "do it once more?", "want a version from your originals?", "retry after the error?". People mostly ignored them: 27 answered with a button, 48 just typed a new request, 19 never replied. Now each question is read by the judge before anyone sees it:

```ts
// The one question the agent may ask a seller: the price, when none was given.
const askSeller = {
  name: "ask_seller",
  handler: async ({ question, options }) => {
    if (sentBack < 2) {
      const p = await judge.ask(IS_PRICE_QUESTION, {
        text: `${question} Options: ${options.join(" / ")}`,
      })
      if (p < 0.5) {
        sentBack++
        return {
          error:
            "Nothing was asked: the one question here is the asking price. " +
            "Decide the rest yourself, and name what's unclear in one line of the listing notes.",
        }
      }
    }
    return showToSeller(question, options)
  },
}
```

On the 114 real questions it kept all eight legitimate offers and sent back 87 of the other 106. The cap of two matters: a model rewording the same question forever costs the user a round each time, so the third goes through.

## The model keeps the language

Put together, the audit and the fixes sorted every argument the model was setting into three buckets:

1. **Facts the data already holds → code.** Whether a picture is the user's own photo or one the app drew decides how an edit is merged back. The model was asked to choose and put the wrong mode on people's photos more than once. The app knows where every picture came from; the model was never the right one to ask.
2. **Judgements about a picture or the user's words → the judge.** Is this edit a removal? Where is the thing to crop? Did the user ask for a format? Is anyone in the photo? Is the subject's edge soft enough to need the finer segmentation model? Each is one calibrated question — and on the last one, the judge matched my own hand labelling in 14 of 15 real photos where the model had matched it in 10.
3. **Language and intent → the model.** The prompt for the image editor, which parts of a face to change, whether "for stories" means a vertical frame. This is what a language model is for, and it's what it's good at.

The rule I now use: **a rule the model must follow and keeps breaking is not its choice to make.** Take the argument away — the model has one less thing to get wrong, and the description has one less paragraph to read.

The next step is the same move one level up. Trimming descriptions bought a tenth because the weight is structural: one model that can do everything carries every tool, every rule and the whole conversation into every turn, 40 to 55 thousand tokens before the person's message. The experiment I'm running now is a few narrow roles instead, with the same judge deciding which one a message belongs to — a reading that, on the thirty days of traces, placed 130 of 130 follow-ups correctly. That's a post of its own, once the numbers are in.

## How I work with the coding agent now

The harness changed how I work with the agent more than any prompt to it did.

- **Numbers before edits.** "Present the diagnosis and the intended edits, wait for a go-ahead" is in my instructions to the agent. With traces and evals, a diagnosis comes with a table.
- **Every experiment has a price.** "About 35 cents, six renders" before a run. It keeps both of us from running twenty variants to avoid thinking.
- **Stop at the second failure.** Also in my instructions, from the [last post](/blog/dont-follow-the-agent/): when a fix to the same mechanism fails twice, step back and look for a sturdier design. It fired twice in this work; both times the next step was a different idea, not a third patch.
- **My questions are the contribution.** "What if the paste-back is the problem?" "Shouldn't the judge choose these, since the model doesn't follow them?" "Isn't this just a router with roles?" None of these came from the agent. All of them came from looking at its numbers.

## The harness tells you where you're wrong

The agent built almost all of this — the trace reader, the evals, the labs, the composites, the audits. What the harness gave back was not speed. It was a way to be wrong cheaply, early and visibly: about the model's arguments, about my own paste-back code, about which rules matter, and once — about the model being wrong at all.

If you build an app with a model inside, start with the judge: one fast, cheap, calibrated question, held to known answers. Put it in an eval. Then in a lab. Then, when you trust it, in production. Most of the rest follows from that.
