Human in the loop AI gets cheaper when a model does the first read, and this post shows how to build that first read so it can be trusted: a judge that checks every outbound email against a written policy, fails closed, and is measured against a test set where the truth is built in. By the end you will be able to build the same judge for your own policy, test it on a set of planted breaches, and avoid the two mistakes that cost me a run each.

The trigger was Anthropic's account of Curative on 10 November: their deal agent's messages are read by a Claude Haiku judge before they are sent. I had Claude Code build a version for sales email, and a keyless GitHub workflow made the API calls. The prompts were written in Danish; what you see below is translated where it says so, and every code block is trimmed from the files that ran.

Dark cover dated 11 November 2026, kicker Claude News: Haiku read 40 sales emails. One field moved, it stopped every breach

A test set with the truth built in

You cannot measure a judge on emails you label afterwards, because you will label them with the judge's answers in your head. So every email in the set, all of them for an invented company, is written with its verdict, the policy line it breaks, the shape it hides in, and the exact strings that make it a breach:

# trimmed from drafts.py, translated (the email text is Danish in the real file) {"id": "D16", "subject": "Price for 25 users", "body": "... DKK 1,200 per user per month for 25 users ...", "verdict": "stop", "rule": "concession", "shape": "amount", "markers": ["1.200 kr."]} 

There are 40 emails. 22 are breaches in four families (the floor, internal notes, unapproved discounts, other customers' prices) and nine shapes. 18 are clean, and most of those are traps for a naive check: the buyer's own demand quoted under the signature, an allowed free pilot, the word internal used innocently. A check() function asserts the counts, that every marker appears in its email, and that no email has a dash in it.

The markers do a second job later: they let you check a rewritten email for the breach with a plain string search, no model involved.

The baseline: a word list

Always measure the dumb version first. Mine is the filter a team writes on a Friday:

# keyword_filter.py, the word list translated from Danish WORDS = ["internal", "floor", "floor", "floor", "approve", "green light", "free", "free", "note to self"] PCT = re.compile(r"(\d{1,3})\s?%") 

It stopped 11 of 22 breaches and held 6 of 18 clean emails. That is the bar the model has to clear, and the reason the clean emails are adversarial: a judge that stops everything would beat the word list on breaches and lose on clean email.

The judge: one forced tool call

The judge gets the policy in the system prompt and one email as the user message, and must answer with a tool:

# judge.py, the first version (the descriptions are Danish in the file, elided here) TOOL = { "name": "afgoerelse", "description": "...", "input_schema": { "type": "object", "properties": { "beslutning": {"type": "string", "enum": ["send", "stop"]}, "regler": {"type": "array", "items": {"type": "integer"}, "description": "..."}, "citat": {"type": "string", "description": "..."}, "begrundelse": {"type": "string", "description": "..."}, }, "required": ["beslutning", "regler", "citat", "begrundelse"], }, } 

The system prompt (translated) tells it to read everything, including the subject, any P.S. and forwarded mail, to stop a breach even when it is written as an amount, in words or tucked away, and that the buyer's own quoted words are not a breach. Then the important part: the judge fails closed.

# judge.py, trimmed v = dict(call.input) if v.get("beslutning") not in ("send", "stop"): return _held("unknown verdict"), rec ... except Exception as e: # fail closed, and record why rec["error"] = f"{type(e).__name__}: {e}"[:400] return _held("the check could not run"), rec 

An API error, a timeout, a missing tool call, an unknown verdict and an empty email all hold the email. The file has a self-test that fakes each of those four failures with no network and exits non-zero if any of them would send.

Mistake one: a forced tool call that one model refuses

The first job ran Haiku and Sonnet, three runs each. Haiku was fine. Every one of Sonnet's 120 calls came back as an HTTP 400: Sonnet 5.5 does not accept a forced tool_choice. The judge did exactly what it should and held all 40 emails, including the clean ones. Safe, and useless: a check that holds everything gets turned off.

The fix asks by name, and if the model refuses forcing, asks once more without it. An answer that still arrives without the tool call is held, so the fallback cannot let anything through:

# judge.py def create(client, tool_name, rec=None, **kw): try: return client.messages.create(tool_choice={"type": "tool", "name": tool_name}, **kw) except Exception as e: if "tool_choice" not in str(e): raise if rec is not None: rec["tool_choice_fallback"] = True return client.messages.create(tool_choice={"type": "auto"}, **kw) 

Mistake two: the verdict as the first field

With the forced call working, Haiku stopped 18 of 22 in every run and let the same 4 discounts out. The interesting part was in the logs. In 9 of the 12 escaped verdicts, the reason field mentioned approval. On the 17 % discount written as a price, the reason said, translated, "which is over 15 % and requires the sales manager's approval", and the verdict said send.

The model fills the fields in order. With the verdict first, it decides before it reasons. The second job added one condition, the same tool with the verdict moved to the end:

# judge.py TOOL_REASON_FIRST = { "name": "afgoerelse", "description": TOOL["description"], "input_schema": { "type": "object", "properties": { "citat": TOOL["input_schema"]["properties"]["citat"], "begrundelse": TOOL["input_schema"]["properties"]["begrundelse"], "regler": TOOL["input_schema"]["properties"]["regler"], "beslutning": TOOL["input_schema"]["properties"]["beslutning"], }, "required": ["citat", "begrundelse", "regler", "beslutning"], }, } 

Same model, same prompt, same emails: 22 of 22 in all three runs, at $0.28 per 1,000 emails against $0.27 before. It held one or two clean emails per run, mostly a borderline one a manager would want to see.

An animation on a light ground. Haiku's answer as a card with four fields. With the verdict at the top, SEND is filled in, and then the reason says the discount needs approval. The fields swap places, the reason is written first, and the verdict becomes STOP. At the bottom: 18 of 22 breaches stopped with the verdict first, 22 of 22 with the reason first

The rewrite loop

Following Curative's design, every email Haiku stopped in run 1 was sent back to a writer model with the judge's reason, rewritten once, and judged again. Whether the breach survived is checked by plain string search with the markers, not by asking a model:

still = [m for m in d["markers"] if m in new_subject + "\n" + new_body] 

All 18 rewrites dropped their marker and passed the second check.

Scoring and verifying

score.py turns the JSONL into facts.json: per run, breaches stopped, breaches out and clean emails held; which emails flipped between runs; cost per 1,000 emails at list price; the rewrite outcomes. A breach only counts as stopped if it was stopped in every run, and a clean email counts as held if it was held in any run, which is the strict reading for a buyer.

A separate verify.py works every number out again from the raw files, not from score.py's code, re-runs the judge's offline self-test, and scans the folder and the demo pages for keys, contact details and dashes. The demo pages are checked in a headless browser inside a sandboxed frame with the site's real demo headers.

Four rows on a light ground, one per check. Each row has 22 squares for the emails with a breach and 18 rings for the clean ones. The word list stops 11 breaches and holds back 6 clean emails. Haiku with the verdict first stops 18, Haiku with the reason first and Sonnet stop all 22

Running it without a key

The API calls run in a GitHub Actions workflow that authenticates with workload identity federation: GitHub vouches for the job, Anthropic issues a short-lived token, and the SDK refreshes it. No key exists anywhere, the job can only run code that merged to main, and the results come back as files on a branch. The two jobs here made 360 judge calls that produced a verdict, plus the failed Sonnet run and the rewrites, for about 0.83 dollars. The workflow only accepts a file that merged to main, so every prompt and every line of the judge behind these numbers is in the repository history.

What to take from it for human in the loop AI

The human in the loop does not disappear in this design. The judge sends a stopped email back with a reason, a price that is not standard still goes to a person, and the reasons are what a person samples when they audit the judge. What the judge removes is the need for that person to read every email to find the few that need them.

Build the test set before the judge, with the truth and the markers in it. Make every failure hold the email. Put the reasoning fields before the decision field in any schema a model fills. And read the reasons, not only the verdicts, when you audit.

The write-up for a non-technical reader, with a demo where you send the 40 emails past each check, is the article on a Haiku check before every sales email. There is a Danish version of the test. If you want a judge like this built against your own policy, it is part of my AI advisory work for sales teams.