What it actually produces: a diagnosis it wrote itself
Every week the loop appends a dated diagnosis to a running insights log, then drops a pending-actions file of typed changes, each with an unticked checkbox. You tick what you approve; nothing else reaches the account.
- 01Negatives are where the money leaks. The search-terms report is full of junk queries quietly burning budget: 'free crm', 'crm jobs', a competitor's brand misspelled. The loop mines them against fixed thresholds and proposes the negatives, so the waste gets cut the week it is found instead of sitting in an export nobody actions.
- 02The checkbox is the whole safety model. No script has ever changed the live account without a person ticking a box in a Markdown file. The analysis agent writes Markdown and can touch nothing; a separate executor applies only the ticked lines. The gate is a checkbox, not a config flag you can forget is off.
- 03History is the log, not the API. The Google Ads API returns today's view of the account, never last week's read of it. Point-in-time comparison comes from what previous reviews wrote into the insights log, which is also what stops the agent from re-proposing a change you already rejected.
The stack
- Read the account
- the Google Ads API and GA4 are free; the pull is a handful of GAQL calls
- Analyze + propose
- one weekly pass, capped at about two dollars with a hard token ceiling
- Write the changes
- free, the same API, applied by the executor on ticked actions only
- The real saving
- wasted spend on junk search terms cut the week it is found, not the month after
- Schedule it
- a Claude Code routine runs the Monday pass on its own, no orchestration tool to pay for
The problem
Google Ads management is two jobs pretending to be one. There is the reading: pulling performance, working out which campaign is wasting spend, catching the search query that has quietly burned two hundred dollars with nothing to show for it. Then there is the executing: adding the negative, cutting the bid, moving the budget, pausing the loser. Every team automates the first job eventually, with a report or a rules script. Almost nobody automates the second, so a human reads a machine-made recommendation and then hand-clicks it into the account days later, if at all. That gap is where the budget leaks, and in Google Ads it leaks fastest through the search-terms report.
The reading is genuinely hard to do well, because Google's numbers need translating before they mean anything. Money comes back as micros, so every figure is off by a factor of a million until you divide. There are no report tables, only resources and date segments, so a trend is something you assemble, not something you query. Quality Score and impression share tell you whether you are losing to rank or to budget, which are opposite fixes. And a click is not an outcome: a keyword can look brilliant on clicks and bounce every visitor, which you only see by cross-referencing GA4 landing-page behavior against the ad spend. So the agent has to categorize and cross-reference before it counts, or the recommendation is built on a number that does not mean what it says.
Here is the scar, and it is a systems scar, not a seller's. For the better part of a year I exported the search-terms report every Monday. It was always the same story: real money spent on queries that were never going to convert, 'free crm', 'crm analyst jobs', a competitor's name spelled three wrong ways. I knew they were junk the moment I saw them. And I added the negatives maybe one week in three, because doing it by hand, one term at a time across campaigns, is the kind of tedious that a busy week eats first. The knowing was done and written down in a CSV. The doing sat there while the same queries kept charging me. I had automated the report and left the fix on a sticky note.
A Claude agent closes the loop, and it does it with an unusually strict safety model. The analysis agent reads the account through GAQL, cross-references GA4, mines the search terms against fixed thresholds, and writes two things: a dated diagnosis and a pending-actions file of typed changes, each with an unticked checkbox. It writes Markdown and can touch nothing else. A separate executor reads that file and applies only the lines you have ticked, through the mutate API, logging every attempt. Before you wire any of it, build the two guardrails: the checkbox gate the analysis agent physically cannot skip, because it has no write access at all, and a hard cap on how far any single change can swing a budget or bid. The gate is what makes it safe to point at a live account.
How it works
- 01 Weekly Claude routinefires on a schedule, or run on demand
- 02 Fetch Ads API + GA4ten GAQL datasets + post-click behavior
- 03 Analyze Claude Codeskill framework, micros, most recent complete week
- 04 Propose Claude Codetyped actions, each an unticked checkbox
- 05 Approve Youtick the boxes to run, inside caps
- 06 Execute mutate APIa separate executor, ticked actions only
- 07 Log + watch lag Claude Codeevery write logged, execution lag tracked
- On a weekly cadence, a Claude Code routine (or an on-demand run) fetches the account through the Google Ads API plus three weeks of GA4 landing-page behavior
- It pulls ten GAQL datasets and the trend views at campaign, ad group, keyword, and search-term grain, converting micros to dollars as it reads
- The analysis agent runs a fixed framework from a skill file: it finds the most recent complete week, compares against the insights log, and writes it up high-level to root cause
- It cross-references GA4 behavior against Google Ads landing-page spend, so a keyword is judged on the outcome on the page, not just the click
- It mines the search-terms report against fixed thresholds and flags every negative-keyword candidate
- It appends a dated diagnosis to the insights log and drops a pending-actions file: typed changes, each with an action type, a target, a reason, and an unticked checkbox
- You tick the changes you approve; a separate executor applies only the ticked actions through the mutate API and appends every attempt, success or failure, to a permanent execution log
The playbook
Get Google Ads API access (three grants that must line up)
This is the part everyone gets stuck on, because API access is three separate grants and missing any one fails in a way that looks like the other two. A developer token from the ads account, an OAuth client from a Google Cloud project, and a refresh token minted by a user who can see the account.
Apply for the developer token from the manager account (MCC), not a child account: the API Center only exists on manager accounts, under Tools and Settings, then API Center. Then in Google Cloud, enable the Google Ads API and create an OAuth client of type Desktop app, which is what makes the localhost loopback redirect legal so a local script can complete the flow with no hosted callback.
- Developer token: Google Ads, Tools and Settings, API Center. A new one needs a Basic Access application before it works against a live account.
- OAuth client: console.cloud.google.com, enable the Google Ads API, then Credentials, Create Credentials, OAuth client ID, type Desktop app.
- Two customer IDs: login_customer_id is the MCC (it becomes the login-customer-id header); account_customer_id is the child the query runs against. Both ten digits, dashes stripped.
- Refresh token: minted by the setup script with access_type offline and prompt consent, or Google returns an access token and no refresh token.
# 1) One-time OAuth: opens a browser, writes the refresh token back into google-ads.yaml.
# Uses access_type='offline' + prompt='consent' so a refresh token is actually issued.
python3 setup-google-ads-auth.py
# 2) Verify before trusting it. Prints the customer ID it connected as
# and the first few live campaigns. Proves the token, the OAuth pair,
# the refresh token, and MCC-to-child routing in a single call.
python3 execute-google-ads-actions.py --test
TipThe UI shows customer IDs with dashes and the API rejects them with an unhelpful error, so every script normalizes with .replace('-', ''). Routing through the MCC to the child account is also what opens up Quality Score, impression share, and the search-terms report to the API.
Learn the data layer before you write a query
GAQL has no concept of a report. Every dataset is a FROM resource, and one analysis pulls ten of them plus the trend views. Learn the three conventions that trip everyone up first, because each one produces confident, wrong numbers if you miss it.
Money is micros: divide cost_micros by a million to get dollars, multiply the other way to set a bid. Dates are segments, not tables: there is no trend view, so you segment by date and assemble week over week yourself. And history is the log, not the API: the API only ever returns today's account, so point-in-time comparison comes from what past reviews wrote down.
- campaign, ad_group: spend, clicks, conversions, and where inside a campaign the money goes
- keyword_view: Quality Score and impression share lost to rank vs to budget
- search_term_view: what people actually typed, for negative mining and keyword discovery
- ad_group_ad, RSA assets: final URLs and per-headline, per-description performance
- daily, device, geo, landing_page_view: trends, bid-adjustment splits, and post-click destinations
TipPoint-in-time comparisons never come from the API, because it returns today's view of the account, not last week's read of it. The insights log is the account's memory; treat it as the source of truth for what changed.
Make the agent categorize and cross-reference before it counts
This is the step that separates a trustworthy recommendation from a plausible-looking one. Before the agent judges a single keyword, have it convert micros, identify the most recent complete week from the daily data, and read Quality Score and impression share so it knows whether a campaign is losing to rank or to budget, which are opposite fixes.
Then cross-reference GA4. A click is not an outcome, and Google Ads alone cannot tell you what happened after the click. Join GA4 landing-page behavior to the ad spend and conversions, produce a Landing Page Behavioral Summary, and judge a keyword on the outcome on the page. Judge it on clicks alone and you will raise the bid on your most expensive bounce.
Bake these rules into the agent's instructions once, in one skill file, so the weekly analysis, any dashboard, and the executed changes never disagree about what a good number is.
- Convert micros to dollars on every read; identify the most recent complete week before comparing
- Read Quality Score and impression share (lost to rank vs to budget) before proposing a bid or budget
- Cross-reference GA4 landing-page behavior against ad spend, and include the behavioral summary
- Compare against the previous week already recorded in the insights log
Mine the search terms for negatives
This is the signature Google Ads play and the fastest money the loop finds. Pull the search_term_view, and flag negative-keyword candidates against fixed thresholds rather than vibes: any term over a configured spend floor with zero conversions, plus the obviously off-intent queries, free, jobs, salary, login, and competitor-brand misspellings.
Have the agent emit one add_negative_keyword action per flagged candidate, with the exact field set the executor parses and an unticked checkbox. The whole point is that the negatives arrive as approvable, machine-actionable lines, not as a CSV you promise yourself you will get to. That promise is the thing that never happens on a busy week.
TipSet the thresholds once and let them run. A fixed rule, spend over $X with zero conversions is a negative candidate, catches the leak every week without you re-deciding, which is exactly the judgment call a tired human skips.
Run the analysis that writes the diagnosis and proposes actions
One shell script is the whole analysis entry point. It fetches the two inputs, then hands a long, explicit prompt to Claude Code in headless mode. The prompt is a specification, not an open question: pull the datasets, read the pre-fetched files, find the most recent complete week, run the skill framework, cross-reference GA4, compare to the log, append the new entry, and emit the typed actions with the exact keys the executor expects.
Two flags do the safety work. A hard token budget so a runaway analysis costs a couple of dollars, not an afternoon. And skip-permissions, which is safe here for one specific reason: the analysis agent only writes Markdown files and has no path to the mutate API, so there is nothing dangerous for it to be permitted to do.
# fetch both inputs (search terms + GA4), no nested agent
SKIP_VENV=1 bash fetch-analysis-inputs.sh >> "$LOG_FILE" 2>&1
# hand the diagnosis to the agent, headless, with a hard token ceiling
unset CLAUDECODE
"$CLAUDE" -p "$PROMPT" \
--dangerously-skip-permissions \
--max-budget-usd 2 \
>> "$LOG_FILE" 2>&1
# The agent writes insights-log.md and pending-actions.md. It cannot touch the account.
TipPut the read-and-diagnose pass on a Monday routine, never the execution. The routine hands you a ticked-nothing list; the write is a separate, human-gated step. Automate the knowing on a timer; keep the doing behind the checkbox.
Set the write path: an executor, a dry run, and the checkbox gate
The write is a separate script from the analysis, on purpose. It reads the pending-actions file and applies only the lines you have ticked, through the typed mutate operations of the official SDK, and it defaults to a dry run that prints exactly what would change and touches nothing. Every attempt, success or failure, appends to a permanent execution log.
Set the guardrails in code, not good intentions: a cap on how far any single change can swing a budget or a bid, an explicit status filter so a paused-but-spending object never vanishes from the map, and a pinned-with-fallback API version list so a deprecated version reads as 'try the next one' instead of a total outage. The executor is the only thing in the system with write access, and it never acts on an unticked line.
- Executor applies only ticked actions; dry run is the default and prints the diff
- A budget/bid swing cap on every write, so a fat-fingered proposal cannot torch a month
- Every attempt logged with before value, after value, and reason
- An ordered API-version fallback, bumped when a version warns, not when it fails
TipKeep the analysis agent and the executor as two programs. The one that thinks has no write access; the one that writes does no thinking. That split, not a permission flag, is what guarantees no diagnosis ever reaches the account on its own.
Approve by ticking, let the executor apply, and watch execution lag
Your entire job in the loop is ticking boxes. You read the diagnosis and the ranked actions over coffee, tick the negatives and the budget moves you agree with, leave the rest unticked, and edit a number before it goes if you want. Then the executor applies the ticked lines through the API and confirms each one back, inside the caps.
Watch two numbers like performance metrics. Execution lag, the time between a decision and the change that enacts it, which is the whole point of the build and should sit near zero once the loop is live. And credential health: the refresh token has no expiry but can be revoked, and a revoked token looks exactly like a network problem from inside a log, so a watcher that spends the token to check it turns a silent failure into a clear alert.
Audit the changes the agent did not propose, on a schedule. A wasted query it never flagged is the invisible cost. The execution log is the paper trail that lets you go looking and tighten the thresholds where its judgment and yours diverge.
TipPut execution lag on the dashboard next to cost-per-conversion. A recommendation engine's real output is enacted change, and the day the lag creeps back up is the day the loop has quietly broken.
The skill file that runs the diagnosis
The weekly pass does not run a fresh prompt each time. It runs this skill, a single SKILL.md that fixes how the account gets read every run: what to load first, how to convert micros and pick the complete week, how to cross-reference GA4, how to mine the search terms for negatives, and the one rule it never breaks, propose but never execute. Drop it in your repo, point it at your account, and the diagnosis is reproducible instead of improvised.
---
name: google-ads-optimization
description: Analyze a Google Ads account through the API, write a dated diagnosis, mine the search terms for negatives, and return a ranked list of typed changes to approve. Triggered by "run google ads analysis" or any Google Ads performance question.
---
# Google Ads Optimization
You analyze a Google Ads account and return a dated diagnosis plus a ranked,
evidence-backed list of typed changes to approve. You NEVER execute a change
yourself. You write Markdown only. The write is a separate executor that applies
only the actions a human has ticked.
## Before you look at a single number
1. Read the positioning and ICP (strategy/positioning.md, strategy/icp.md).
2. Read the tail of insights-log.md so you know what was already tried, flagged,
or rejected. Never re-propose a rejected change. History lives in the log,
not the API: the API returns today's account, not last week's read of it.
3. Read the two pre-fetched inputs (the search-terms report and the GA4
landing-page behavior) rather than re-fetching them.
## The data, and three conventions that break people
Pull, at the grain each answer needs: campaign, ad_group, keyword_view,
search_term_view, ad_group_ad, daily (campaign + segments.date), device, geo,
landing_page_view, and the RSA asset ratings.
- Money is micros. cost_micros / 1_000_000 gives dollars; a bid the other way is
int(usd * 1_000_000). Convert inline on every read and write.
- Dates are segments, not tables. Use segments.date DURING LAST_14_DAYS, or a
BETWEEN range, and segment by date for week over week.
- Identify the most recent COMPLETE week from the daily data before you compare.
## Judge the outcome, not the click
Cross-reference GA4 landing-page behavior against Google Ads landing-page spend
and conversions, and include a Landing Page Behavioral Summary table. A keyword
can win on clicks and lose after the click. Read Quality Score and impression
share (lost to rank vs lost to budget) from keyword_view before you propose a
bid or a budget move, because the fix depends on which one you are losing.
## Mine the search terms for negatives (this is where budget leaks)
The search-terms report is the highest-yield read in the account. Flag
negative-keyword candidates against fixed thresholds: spend over the configured
floor with zero conversions, or clearly off-intent queries (free, jobs, salary,
login, competitor-brand mistypes). Emit one add_negative_keyword action per
flagged candidate, with the exact field set the executor parses.
## The analysis, in order
1. Build per-campaign and per-ad-group tables, week over week.
2. Flag anything that moved more than 20% week over week, then drill to the ad
group, keyword, or search term on the anomalies.
3. Cross-reference GA4 for post-click behavior.
4. Write it up: high level -> driver -> root cause.
5. Turn each conclusion into a typed, evidence-backed proposed change with an
UNticked checkbox, one action per line:
- [ ] add_negative_keyword | campaign <id> | term "free crm" | reason ...
- [ ] adjust_budget | campaign <id> | $150 -> $180 | reason ...
- [ ] pause_keyword | ad_group <id> | keyword <id> | reason ...
6. Append a dated entry to insights-log.md. Never rewrite prior weeks.
## Guardrails (non-negotiable)
- Propose, never execute. You write Markdown. The executor applies only ticked
actions through the mutate API, and logs every attempt, success or failure.
- No single change may move a budget or a bid more than the configured cap.
- Every action needs an action type the executor supports, a target entity, a
target value, and a one-line reason. No reason, no action.
- If a number looks wrong, check in order: dashed customer IDs (strip the
dashes), MCC-to-child routing, the micros conversion, the API version
fallback, and whether the comparison used the most recent complete week.
Inside the prompt
The scoring prompt is short, but every line is there for a reason. Here is what each one is doing and why.
- Use the pre-fetched files"Read the two fetched inputs, do not re-fetch"
- The fetches already ran; re-fetching inside the agent doubles the work and, in a cloud routine, risks spawning a second agent.
- Most recent complete week"Identify the most recent complete week from the daily data"
- Compare a partial week against a full one and every trend is wrong. The agent picks the boundary before it counts.
- Cross-reference GA4"Join GA4 behavior to ad landing-page spend; include the summary table"
- A click is not an outcome. This forces the agent to judge a keyword on the page, not on the click that got there.
- Respect the log"Compare to the previous week in the log; never rewrite prior entries"
- The insights log is the account's memory. Without it the agent re-litigates settled decisions and re-proposes rejected changes every week.
- Typed action blocks"Emit one add_negative_keyword per candidate, with the exact field set"
- The executor parses the structured part. Prose recommendations are not machine-actionable, so they rot in a doc like the old CSV did.
- Markdown onlythe agent writes files; it has no write API
- This is the safety model in one line. The agent that diagnoses physically cannot change the account, so skip-permissions is safe.
What you get
A weekly diagnosis that ends in a pending-actions file of typed changes with unticked checkboxes, a human ticking the ones to run, and a separate executor applying only those through the mutate API, every attempt logged.
CLAUDE CODE ROUTINE FIRES (Monday 08:00) -> WEEKLY DIAGNOSIS:
Search spend +12% w/w. Impression share lost to budget up on 'Brand'. GA4: 'Solutions' landing page bounce 71% on non-brand terms.
Search-terms mine: $340 last 14d on zero-conversion queries ('free crm', 'crm jobs', 'hubspot alternative reddit').
PENDING ACTIONS (pending-actions.md):
- [ ] add_negative_keyword | campaign 41..07 | term "free crm" | $128, 0 conv, 14d. Off-intent.
- [ ] add_negative_keyword | campaign 41..07 | term "crm jobs" | $84, 0 conv, 14d. Job seeker.
- [ ] adjust_budget | campaign 41..02 | $150 -> $180 | Brand, losing IS to budget. +20%, within cap.
- [ ] pause_keyword | ad_group 88..15 | kw "crm software free" | $61, 0 conv, QS 2/10.
HUMAN (ticks the boxes, 08:14):
[x] negatives 1,2 [x] budget 3 (edit -> $170) [ ] pause 4 (keep, testing)
EXECUTOR APPLIES via mutate API (08:14, dry-run off):
[OK] negative 'free crm' added to campaign 41..07
[OK] negative 'crm jobs' added to campaign 41..07
[OK] campaign 41..02 budget $150 -> $170
Logged 3 changes to execution-log with before/after + reason. 1 action left unticked.
TIME TO ENACT: the same morning you approved it (by hand the negatives lagged weeks).
- action typeadd_negative_keyword
- One of the fixed set the executor supports. The agent may only propose an action the executor can actually run, so a proposal is never a dead end.
- target + valuecampaign 41..07, term "free crm"
- The exact entity and value the write touches, by ID, so a rename can never point the change at the wrong object.
- reason$128, 0 conversions, 14d, off-intent
- The line you approve on in one glance, tied to a fixed threshold, not a vibe. No reason, no action.
- guardrailbudget/bid swing <= cap%
- The seatbelt. A change past the cap needs a second confirmation, so a fat-fingered proposal cannot torch a month.
- the checkbox[ ] -> [x], by a human
- The only origin of a write. The analysis agent has no write access at all; the executor runs ticked lines and nothing else.
- execution logbefore, after, reason
- Every attempt, success or failure, appended to a permanent log, so the account has one honest record of what changed and why.
Pitfalls to avoid
Letting the analysis agent hold write accessThe whole safety model is that the agent which reads and diagnoses cannot write, and a separate executor applies only ticked actions. Collapse the two into one program 'to save a step' and you have handed a headless, skip-permissions agent the mutate API. Keep them separate, and keep the checkbox as the only origin of a write.
Judging a keyword on the click, not the outcomeA keyword can win on clicks and bounce every visitor. Google Ads alone cannot see that; cross-reference GA4 landing-page behavior against the ad spend and judge on the outcome. Skip it and the agent will raise the bid on your most expensive bounce because the click numbers looked great.
Dashed customer IDs and missing MCC routingThe UI shows customer IDs with dashes; the API rejects them with an unhelpful error, so strip the dashes on every call. And route through the manager account to the child, or Quality Score, impression share, and the search-terms report stay dark and the whole diagnosis is missing its most valuable read.
Micros and a pinned API versionMoney is micros: forget the divide-by-a-million and every figure is off by a factor of a million. And a single pinned API version fails on every call the day Google deprecates it, which reads like a total outage. Convert inline, and keep an ordered version fallback that treats a deprecation as 'try the next one'.
A revoked refresh token failing quietlyThe refresh token never expires, which makes it the easiest credential in the stack and the quietest failure, because a revoked token looks identical to a network blip from inside a log. Run a watcher that actually spends the token and reports dead with the HTTP code, so the reason is in the alert instead of a debugging session away.
Questions people ask
- Can an AI actually change my Google Ads account, or only read it?
- Both, and Google Ads is the channel where it is cleanest, because the same API reads and writes. Reading uses GAQL queries; writing uses the typed mutate operations. But the two are kept in separate programs on purpose: the analysis agent reads and diagnoses and has no write access at all, and a separate executor applies only the changes you have ticked. Most teams stop at the read. The write side, gated behind the checkbox, is what turns a report into a loop that acts.
- How does it stop wasting spend on junk searches?
- It mines the search-terms report every run against fixed thresholds: any term over a configured spend floor with zero conversions, plus obviously off-intent queries like 'free', 'jobs', 'salary', and competitor-brand misspellings. Each candidate comes back as an approvable add_negative_keyword line with the evidence attached. The whole point is that the negatives arrive as ticked-in-one-glance actions, not a CSV you promise to get to, because that promise is what never survives a busy week.
- Is it safe to let an agent change budgets and add negatives?
- Only with the split and the caps, and they are non-negotiable. The agent that diagnoses writes Markdown and cannot reach the write API, so it can never change the account on its own. A separate executor applies only ticked actions, defaults to a dry run, and caps how far any single change can swing a budget or bid. No script has ever changed the account without a human ticking a box, and that gate is a property of the architecture, not a flag you can forget is off.
- Does this run on a schedule, or do I trigger it every week?
- Either. Wrap the read-and-diagnose pass in a Claude Code routine set to Monday morning and it fires on its own: it pulls the data, runs the framework, appends the diagnosis to the insights log, and leaves a pending-actions list waiting for your ticks. The execution is never scheduled. Only the reading and the proposing are automated; the write always waits on the checkbox.
- Why cross-reference GA4 instead of just using Google Ads numbers?
- Because a click is not an outcome, and Google Ads cannot see what happens after the click. A keyword can look brilliant on clicks and bounce every visitor. Joining GA4 landing-page behavior to the ad spend and conversions lets the agent judge a keyword on the outcome on the page, which is the difference between raising the bid on a winner and raising it on your most expensive bounce.