I run Google Ads for a handful of B&Bs, on my own. The whole system — pulling data, analysing it, producing a weekly report that goes to the owner, changing settings on live ad accounts — was built by me and an AI agent together. Dozens of scripts, a dozen or so scheduled CI lanes.
It saves me an enormous amount of time. It also exhausts me, and the reason turned out not to be the one I assumed.
“82 scripts, 5 test files, and CI has never run a single test. So every bug runs all the way to the finish line — and the finish line is ‘a report lands in the owner’s inbox’ or ‘an ad account gets changed’. The only test layer this system has is you.”
—— the agent's structural diagnosis of the system, 2026-07-13
I · How it startedNot with a bug
It started with a sentence. I typed: “I’ve been feeling really tired lately.”
The natural response to that is sympathy. It didn’t. It went and measured.
The answer wasn’t “too much work”. It was three layers of things that lie — and I was the only one checking any of them.
One. The tools lie: no bad news gets treated as good news. An audit tool only reports failure when it actively explodes; everything else prints ✅. So checking zero pages prints “all targets met”. An account it couldn’t connect to counts as fine. A write that Google silently ignored prints “cleared”.
Two. The LLM fabricates, and we were fighting it with prose. A weekly report contains 852 numbers, every one of them typed out by the AI. To stop it getting them wrong we wrote 1,664 lines of prompt teaching it the rules, plus a verification layer to catch the ones it made up — and when caught, we rolled the dice again. Those two places alone were edited 93 times in 60 days. And it still got them wrong.
Three. The only test layer is me. That day I caught six errors on my own. Not one of them was found by a tool.
The diagnosis ended with four things to do. We finished three. And in the doing, we found that the real cause of each one was never the cause written on the diagnosis.
II · First findingThe problem isn’t “no check”. It’s “a check with no teeth”
The third layer said: dangerous operations should be structurally safe, not safe because I nod at them. I thought the job was simple — 22 tools that change live ad accounts, each needs a “read it back after writing”.
Then I found that the most important ones already read it back.
They write, re-read, compare, find a mismatch — then print a ❌, and exit 0.
⚠️ Some changes could not be verified — check above and confirm in Google Ads UI.
process exit code: 0 A keyword pause that never took. A negative-keyword list that was never attached to the account. A Google auto-apply that never got switched off. All of them reported as “done”.
The verification was complete. It just had no teeth. And that is worse than no verification: it launders the failure. Nobody checks a thing twice after it looks checked once.
The most naked line of all
console.log(`created ${res.results?.length ?? 0} link(s) ✅`); Then there’s the domain-swap tool: it deletes a serving ad first, then creates its replacement. If the create step fails silently, the ad group ends up with no ad at all — and the script prints “Domain fix complete”.
III · Second findingThe AI audited by hand three times, and was wrong three times
The findings above started as a grep. And the grep said:
Tool A has no read-back → it does (hand-rolled).
Tool B has no read-back → it does too.
Tool C has no read-back → it reads back, but with no teeth.
Three times, three different ways of being wrong. Because every script lies in its own dialect — and a reader (human or AI) instinctively believes a script’s own account of itself.
So it stopped auditing by hand. It wrote a test that makes the machine ask one precisely defined, uninterpretable question: “Did you call one of the two read-back parts that actually throw?”
That test carries a “not yet wired” list — and it is a DEBT list, not an exemption list. Two more tests watch it: an entry that has since been wired makes it fail (the debt may never look bigger than it is), and parking something irrelevant there to buy silence makes it fail too.
This is the pattern I most want to keep: when you discover your own judgement is unreliable, don’t judge harder — hand the judging to a machine that can be asked a question with exactly one answer.
IV · Third findingThe guard we built was itself a false green light
Along the way we added a CI gate: catch “used a name that was never imported”. That class of error compiles fine (a free identifier is just a global lookup), tests never reach it (it only fires inside the closure that calls it), and it detonates at runtime — which, for these tools, means while a live ad account is being modified.
The gate ran and reported: 0 problems.
And sitting in one of the scripts was a variable that does not exist — on the path that builds a new client’s ad campaigns. Written that same day.
"include": ["src/**/*"],
"rootDir": "./src" We spent an entire day hunting checks that look like they’re protecting you while looking at nothing — and then found we had built one the day before.
V · The deepest layerThe anti-fabrication check was grounded in the fabrications
The second layer said the LLM fabricates. The system’s defence against that is a stage called verify: it collects every trusted number, then checks that each number in the report exists in that set. Anything that doesn’t is an “orphan”, and we roll the dice again.
Here is how the trusted set was built:
walkCollect(data); // the real data — trusted
walkCollect(findings); // the AI's own analysis — ALSO trusted It could never have caught anything the AI invented — because the AI’s output was its reference.
Running the new check over two reports that had already gone out
Sessions the ads brought to the site. The report told the owner: “of the 58 visits the ads brought, not one converted.”
A keyword’s quality score. 3 and 5 are entirely different diagnoses. A 3 says “your landing page is the problem — go rebuild it.” That’s real money and real work.
Three numbers in a sentence. “These clicks cost over NT$1,650, nearly 50% of total spend” — the AI’s own arithmetic over the search-term rows. Might be right. Nothing ever checked.
A sitelink’s destination. The truth is a URL sending paid clicks to an OTA. And the evidence for that finding was the number 0.
The fix was sitting there the whole time
Every piece of evidence the AI produces already carries a source path — “this number lives at this position in the data.”
And those paths were all correct. The place it pointed at really is where that number lives.
The AI is excellent at naming where a number is. It is bad at copying it.
So: code resolves the path and overwrites whatever the model typed. Even when it happened to get it right — overwrite anyway. Being right by luck is not a source you can put in a client’s report.
Then delete the line that swept the AI’s output into the trusted set. Its analysis is not a source of truth. It is a CLAIM.
“Check whether the AI got the number right” is a checklist that never ends.
“Give the number it typed no way into the report” is impossible-by-construction.
The difference is between caught and cannot happen.
VI · Where it standsFour things, three done
The AI may not write numbers
Root cause found (the guard trusted the AI’s own output). Every number in the analysis stage is now resolved by code from its source path; the trusted set is real data ∪ deterministic computation. Every hallucination caught gets PRINTED — a correction nobody sees is not a correction.
A skip may never be counted as a pass
The part exists (denominator declared before the work, a skip is never a pass, forgetting to declare throws). But it is not wired into every tool — only the ones that genuinely walk a set and render a verdict. I’m not going to force it somewhere it isn’t needed just to make a number look good.
Dangerous operations are structurally safe, not safe because I nod
22 write tools, 22 of them read back to confirm the write landed. Forgetting to name the account is now a hard failure (it used to silently default to one particular client). A test watches this, and the debt list can only shrink.
Machine health: broken things raise their own hand
Not a new script — a section of the morning digest, which already ships daily and is already a heartbeat. It checks when each schedule last SUCCEEDED (not when it last ran), whether credentials are dead, whether reports actually got built. A red goes straight into the subject line.
Found along the way: the real cost isn’t lines
When we started merging scripts, the AI assumed the goal was fewer lines. I cut it off:
“Merge four scripts into one tool with four sub-commands and the line count doesn’t move — but the things you have to remember go from four to one. THAT’s the real cost.”
So the test for a family isn’t a shared name prefix (44 scripts start with ads-; that is not a family). It’s: “To change something in this domain, how many files do I have to reassemble the knowledge from?”
Budgets had four scripts, each holding one shard of “how Google Ads budgets will lie to you”. Negatives had eight, and the sentence “zero conversions, therefore safe to block, is a self-fulfilling prophecy” had no home at all. Merging isn’t about fewer lines. It’s about giving knowledge somewhere to live.
And one family we refused to merge — the three site-measurement tools read from a real browser, a Google API, and an analytics platform. Three data sources, three questions. Merging them would only have made the count look better.
VII · What’s leftFour sentences
1. “I couldn’t look” is not “it’s fine”
An account you couldn’t query, an API you couldn’t reach, a query that failed to run — none of them may be counted as normal. The denominator must be declared before the work starts, every item must be accounted for as pass / fail / not-checked, and a mismatch is a bug in the program, not an audit result.
2. A detector you haven’t tested is a detector you can’t trust
Every guard built over those two days had a fake bug planted in it first, to prove it fired. The times we skipped that, we were wrong — including the type-check gate that confidently reported “0 problems”.
3. A guard must say out loud what it cannot catch
The dead-reference check can’t see certain files — and that sentence is written in the test’s own header, so it can’t pretend to cover everything. A guard that honestly declares its blind spot is more useful than one that appears comprehensive.
4. Noise kills signal
If the machine-health section had the wrong permissions, it would honestly report “I can’t see the run history” — every single day. A monitor that cries wolf daily is a monitor nobody reads — and at that point it is exactly as useful as not existing.
VIII · The honest endingIt retracted itself six times
What actually exhausted me over those two days wasn’t the number of bugs. It was being handed a conclusion, and then having it taken back.
It said 82 when the number was 83. It used grep to judge read-back coverage and got it wrong three times. It trusted a type-check gate it had never tested. And while merging the scripts it planted five landmines that would have detonated in production — an invisible space that makes a tool blow up only at runtime, where the bundler can’t see it and no test reaches it. One of them sat on the path that actually changes live ads.
The difference is this: this time, every one of them was called out by a machine, not walked into by me.
The first landmine was found by accident. After that test was written, the other four were shouted out by the test itself.
So what those two days really produced isn’t a system that got fixed. It’s a system where “it’s lying to me” got LOUD.
The only detector used to be me. Now the detector is a machine — including the one watching itself.
Every number here was read out of the repo by code, on 2026-07-14; the diagnosis is from 2026-07-13. Client names are redacted; numbers, dates and code fragments are verbatim.
Making up a number in an article about making up numbers would have been the funniest thing to happen all week.