# AdPilot — Google Ads Operator

*An AI operator for a live ad account that is never allowed to apply its own advice.*

**Category:** AI SYSTEMS / PERFORMANCE
**Status:** live
**Period:** May – September 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**8 days** — the account ran dark while every status field read healthy

**Stack:** Python 3.9+, FastAPI, Pydantic v2, Next.js 14, TypeScript, Google Ads API v24 (REST), Search Console API, OpenAI API

## The challenge

I run the Google Ads for my own robotics company. The first version I had built was a dashboard of threshold rules, and it told me what was already obvious: stop this, add that as a negative. It was also wrong about the business, recommending I pause robocafe and coffee robot, the exact terms the company sells against. Then the account stopped delivering for eight days and nothing noticed, because every status field the Ads API exposes read healthy the whole time.

## What I built

I rebuilt it from a fresh set of specs around one rule: a recommendation has to arrive as a prepared change, with the money at stake attached and its reverse already written. I sync Google Ads and Search Console into one canonical model on a FastAPI backend, run waste and growth analysers over it, and compile each finding into a staged mutation that only a human click can apply. Over that I put a Next.js command centre with fifteen surfaces: health triage, a ranked recommendation queue, a growth plan, a search-term inbox, an optimise workbench, experiments, outcomes, reports, an audit, and a copilot that can stage a change but never apply one. It runs on the company's own server, behind an nginx reverse proxy, a login layer and TLS at the edge.

## The detail

### Enforced, not promised

A gate test I wrote walks the AST of every Python file under backend/ and contracts/ and fails the build if a live mutate is called outside the apply engine; only the port implementations that define it are otherwise allowed. At runtime the same gate forges an apply with no approval row and asserts it is rejected. Each staged action carries its reverse — a create undone by the resource name the mutate response returns, an update by the values captured before staging — and the reversibility gate asserts byte-for-byte account equality after apply-then-undo against the fixture port.

### Protected keywords

After it recommended pausing robocafe and coffee robot, the core terms are read out of the business context and removed from the dead-keyword and negative detectors entirely. An underperforming core keyword now produces an optimise insight instead: relaunch the paused campaign, fix the landing page or tracking, clean the junk traffic, tighten the match type. Insights aggregate by keyword text, so one core term produces one recommendation rather than one per ad group.

### The guardian layer

A separate audit found the eight-day blackout, not the product. The only consumer of the hourly cost series fired on overspend, so zero delivery was invisible to it. A delivery deadman now builds a per-weekday, per-hour expectation of cumulative spend from the account's own archived hourly costs using median and MAD, excludes already-dark days so an outage never becomes the new normal, and alarms on absence without asserting a cause the API cannot observe; a sync sentinel treats no successful sync on record as blindness rather than a default pass, and a version canary probes the pinned Ads API version and the two above it.

### A version sunset

"Refresh dies at 6%" was the report I got. Google was progressively rejecting Ads API v21, about 7% of requests at random measured on the live account, and a full sync makes eighteen sequential GAQL calls, so it survived roughly a quarter of the time and died at whichever query drew the short straw. Two things had hidden it from me: Google buries errorCode several hundred characters into its JSON and my caller truncated at 400, and a read timeout escapes urlopen as a bare socket.timeout rather than a URLError, so it bypassed the handler entirely.

### The undo window

An adversarial review found the undo window was seven days while the measurement window was fourteen, so the system could prove a change harmful a week after losing the ability to reverse it. I now dry-run the reverse before it commits and moved the contract default to twenty-one days, but every action class still stages its own seven-day window, so the gap is only half closed. The same review found a paired budget move could half-apply through the batch path, turning a flat reallocation into a spend increase; those actions now carry an atomic flag.

## The outcome

I claim no performance figures for the ad account itself, and that is the point: I built the measurement layer to refuse a number it cannot support. Below a minimum weight of controlled evidence, the monthly statement says it cannot honestly measure the month and shows its working instead of producing a figure. What is checkable: 1,118 backend tests pass, six of them stop-the-line gates covering recommend-only, reversibility, grounded evidence, money units and a no-network guarantee, and the whole product boots and demonstrates on a fictional fixture account with no credentials and no network calls. I have it deployed against the company's real account behind a login layer, with production writes switched off, so the apply-and-undo loop is proven against the fixture port rather than against live spend.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/adpilot.html)
