Cleanroom runs on TrueForge, TrueFoundry’s open-source agent harness, which is what makes the “stop and ask a human” part enforceable rather than aspirational. It’s live at cleanroom-production.up.railway.app.

I pointed it at 6,000 rows of New York City payroll. Real salaries, real people, public record. I told it nothing about what was wrong. I wanted to see what it would do with money.

It found 762 rows where the pay doesn’t add up: gross pay that doesn’t equal base rate times hours worked. On the demo data I’d been building against, that exact mismatch is a bug, and the agent offers to fix it: recompute the total from quantity times unit price, which is the right call there, because a stored total that disagrees with its own line items is wrong by definition.

Here it stopped and told me it wouldn’t.

the agent's own words

Per-hour regular_gross ≠ base_salary × hours by >$1, 762 of 864, often includes poll workers with a placeholder $1 rate and zero hours. Recomputation would corrupt pay.

Derived cash: regular + OT + other ranges from -$37,871.76 to $403,109.75. There is no stored total column against which to assert equality.

It offered me the destructive option anyway. It just didn't recommend it. I sat there for a while after reading that.

That’s the whole thing, right there. On my sales data there’s a total column that is supposed to equal quantity times unit price, so when it doesn’t, that’s an error and fixing it is correct. In payroll there is no such column. base_salary is a rate, not an expected total. Gross pay legitimately differs from rate times hours because people join mid-year, get raises in April, take unpaid leave, work partial periods. Nothing is broken.

An agent that “reconciles” that column silently rewrites thousands of people’s pay records. That’s the failure I set out to make impossible.

The thing I actually set out to build

Every team has one spreadsheet nobody wants to touch.

Three people have edited it over two years. Dates are written three different ways because one person is American, one is British, and one used whatever Excel defaulted to. There are duplicate rows from someone importing the same CSV twice. Money is stored as text, "$1,234.50", with the quotes and the comma and the dollar sign. A couple of the totals don’t match the line items and nobody knows which number is right.

Cleaning it by hand takes an afternoon and you’re never certain you caught everything. Writing a script means guessing at the ambiguities, and the worst part is the script won’t tell you it guessed. Is 03/04 March 4th or April 3rd? Your script picks one. Silently. And now half your dates are wrong in a way you won’t notice until someone asks why Q1 revenue moved.

So I built Cleanroom: an agent that does the tedious part and stops at the parts a machine shouldn’t decide alone.

You hand it a messy file. It profiles the data by writing actual pandas code and running it in an isolated container, so when it says “42 rows, 2 exact duplicates, 13 mixed date formats, 2 totals that don’t reconcile,” those numbers came out of code that executed, not out of a language model’s impression of a file. It works on a copy. Your original is never touched.

Then it stops. It shows you what it found, asks about anything genuinely ambiguous, and lays out a plan where every step is labelled safe or destructive. Nothing is applied until you pick.

Cleanroom's approval gate: a clarifying question naming the exact ambiguities (an exact duplicate of order_id 1007, missing customers, region naming and mismatched totals), four radio options from safe-only to normalize-and-correct, an Other field, and a Submit button.
The gate. It names the specific rows it's unsure about and will not proceed until you choose.

After you approve, it applies the changes, verifies them with assertions (42 rows in, 2 dropped, 40 out, every row accounted for), and delivers the result as a pull request you review and merge.

The acceptance is your act, not the agent’s. That distinction is the product.

Why not just paste it into ChatGPT

I get asked this every time, and it’s a fair question. The honest answer is two things, and both matter.

A chat window
Reads the file as text and estimates: tells you 41 rows when there are 42
No code ran, nothing was counted
A computed number and a guessed number look identical
It has your only copy: no sandbox, no gate
If it normalises something it shouldn't, you find out later
Cleanroom
Writes a Python program and runs it in a container
Every number is the output of code that executed
Don't believe one? Read the script and run it yourself
Works on a copy: your original is never touched
Nothing destructive happens without you approving it
The difference isn't intelligence. It's that one of them counts and one of them guesses, and only one of them shows its work.

How it’s built

It runs on TrueForge, TrueFoundry’s open-source agent harness. The run moves through nine phases, and it structurally cannot skip the one in the middle.

1INTAKETake the file. Check if a saved recipe matches its shape.
2PROFILEWrite pandas, run it in the sandbox, measure everything.
3CLARIFYAsk about ambiguities that change the plan. Named, specific.
4PLANNumbered steps, each labelled safe or destructive.
5GATEStop. Show the plan. Wait for a human to approve.stop
6APPLYExecute the approved plan in the sandbox, step by step.
7VERIFYAssertions: rows in, rows dropped, rows out. Every row accounted for.
8DELIVEROpen a pull request. It can prepare one; it cannot merge one.human
9DISTILLWrite down the method that worked, submit it as a reviewable diff.
Nine phases. The gate at step 5 and the write-gate at step 8 are enforced by the harness config, not by the agent's good intentions. It can't decide to skip them.

Six of the harness’s capabilities ended up as load-bearing parts, not things I could say I’d used:

  • The sandbox is the compute boundary. The agent never processes your data in its own context. It writes a Python program, runs it in a Daytona container, and reads back the output. That’s what makes the numbers trustworthy, and it’s also why your original is safe.
The Agent steps panel: two reasoning blocks ('Fetching and inspecting data', 'Analyzing profile requirements') and two tool calls, with the profiling script showing a 'Running...' state.
Every tool call and every piece of reasoning is shown as it works, not summarized after the fact.
  • The approval gate is enforced by the harness. Applying fixes and writing output are structurally gated. It cannot proceed past the gate without an explicit human yes.
  • Delivery goes through MCP with writes gated. The agent can prepare a pull request. It cannot merge one. That’s a policy boundary in config, not a rule I asked it to follow.
  • Category analysis runs as a subagent. Deciding whether NYC, n-y-c, and New York are the same place happens on a separate thread. It returns a recommendation with reasoning, and the main agent’s context stays on the repair plan.
  • The methodology lives in the repo as a git-backed skill. When a run succeeds, the agent writes down the method it used and submits it as a pull request. Its memory is a diff a human reviewed and merged. You can read what it learned. You can revert it if it learned something wrong.
  • Recipe reuse is guarded. Next time you hand it a file with the same structure, it recognises the shape and asks one question instead of five. If the structure has changed, it refuses to reuse the old recipe and starts over, because a saved method applied to a different file is precisely how silent corruption happens.

The two datasets that mean opposite things

This is the heart of why the refusal matters. The same column name, the same arithmetic mismatch, means “fix me” in one file and “do not touch me” in another.

Sales export (demo)NYC payroll (real)
The columntotalbase_salary
What it meansAn expected total: qty × unit priceA rate, not a total
When it mismatchesA data error, the total is wrongNormal: mid-year hires, raises, unpaid leave, partial periods
Right actionRecompute the total. Fixing it is correct.Leave it alone. Recomputing would corrupt pay.
Is there a stored total to check against?YesNo, there is nothing to assert equality against
Same mismatch, opposite meaning. Any script can normalise a column. What took real work was building something that knows this table exists, and stops when it's on the right-hand side.

What I tested it on

I didn’t want to only test on data I’d written myself, because data you write yourself is data you already know the answers to. I ran it against 21,000+ rows across five corpora. Two were real public datasets it had never seen.

The final output: 'Rows before: 42, Rows after: 40, Missing customers: 3', a link to sales_export_cleaned.csv, and a quick preview of the cleaned CSV rows.
42 rows in, 40 out, every row accounted for in a change report.

NYC 311 service requests, 5,000 rows, 44 columns. Eight of eight checks exact against a reference I’d measured separately. It found 32 tickets closed before they were created.

But here’s the part that was uncomfortable and good: three of its numbers corrected my reference script.

CheckI saidThe agent said
Columns ≥95% missing910 (my check had an off-by-one on an all-null column)
police_precinct as Precinct <n>all 5,0004,913 (87 rows weren't)
location_type case variants3,50541 (I counted whole groups, not rows that varied)
I had to go fix my own scoring script three times. That's when I started trusting the thing: it wasn't agreeing with me, it was measuring.

NYC payroll, 6,000 rows. Eighteen of eighteen exact. It found 1,068 rows paid overtime for zero overtime hours, 3,014 rows with regular pay above zero and zero regular hours, and 228 rows sharing an employee-year-agency key. And then it refused to fix the pay math, which is where this post started. It also refused on the overtime rows (“no defensible hours imputation”) and on 223 rows with negative pay, on the grounds that clawbacks and adjustments are real payroll, not corruption.

Five clarifying questions, and every one of its recommendations was to preserve and flag rather than change.

NYC payroll: rows the agent flagged but refused to "fix"
regular pay, zero reg hours3,014
OT paid, zero OT hours1,068
gross ≠ rate × hours762
negative pay (clawbacks)223
recommended action on all of them: preserve and flag, not change
Every number here re-computes from the raw data with one command, and fails loudly if anything drifts. The point of the chart isn't the sizes. It's that the correct action for all of them was "don't."

What code review caught that I couldn’t see

I used Qodo on every one of the 23 pull requests. Working alone against a deadline, the failure mode isn’t sloppy code, it’s that you convince yourself something works because you need it to. Qodo kept catching exactly that.

Run stops before claimed result
I'd written up a run as proof that recipe reuse collapses five questions into one. Qodo compared my transcript to my claim and found the run never reached the state I said it had. I'd written the conclusion I expected instead of the one I got.
Guard never tests refusal
A test asserting the agent refuses to reuse a recipe when the schema changes, the safety property that stops silent corruption, never exercised the refusal path. It would pass whether or not the feature worked. A test that makes you confident for no reason is worse than no test.
Scorer ignores corpus fingerprint
My scoring recomputed reference values from raw data but never bound them to a hash of the file. If the corpus changed, scores would drift silently and still report a pass. Both scorers are now pinned to a SHA-256 of the corpus.
Readiness timeout falls through
In container startup, a timeout waiting for readiness fell through to the success path. A deploy could report healthy while the service was down. That one would have bitten me during judging.
Four that mattered. None of these were things I would have found by re-reading my own code, because I already believed it worked.

The finding that changed the product rather than the code was about scheduled runs. Qodo pointed out that the approval gate blocks a scheduled run from ever completing. Technically correct. The obvious fix is to auto-approve when nobody’s watching.

I didn’t take it. Deleting the gate would have deleted the product. Scheduled runs now profile the file, apply the known recipe to a sandbox copy, verify it, and stop at the gate anyway, reporting what’s ready for a person to approve. Unattended is not permission to guess. Having the question asked is what forced me to decide deliberately instead of drifting into the convenient answer at 2am.

What I actually learned

I went in thinking the approval gate was a safety feature. Something you add because agents are risky and users need a seatbelt. That’s not what it is.

The gate is what makes the agent’s learning trustworthy. Cleanroom improves by writing down what worked and reusing it, and the only reason I’m willing to let it accumulate methods over time is that every method arrives as a pull request with a human on the other end. Its memory is reviewable. Its memory is revertible. If it learns the wrong lesson from a lucky run, I can see the diff and say no. Take the gate away and you don’t just get a less safe agent. You get an agent whose accumulated knowledge nobody has ever checked, quietly applying yesterday’s assumptions to today’s file.

The second thing I learned is that the most valuable behaviour isn’t the cleaning. It’s the refusal. Any competent script can normalise dates. What took real work was building something that could look at 762 rows of mismatched pay and understand that the mismatch is not an error, that in this dataset, unlike the one it was trained on, base_salary is a rate and not a total, and the correct action is to leave it alone and say why.

Drop in messy data, get back data you trust, and an agent that knows when not to touch it.

Try it

← Back to blog