A harness for an AI-native app: evals, labs and a fast 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.
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:
- The hypothesis is written before the run. One sentence: what I expect and why.
- A variant against the baseline, on the same scenarios, at the same settings. One thing differs.
- The cost is stated up front. “Six renders, about 35 cents.” It keeps experiments small and honest.
- 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: 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.
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:
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:
// 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:
// 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.
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:
// 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:
- 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.
- 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.
- 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: 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.