Audits and scores an existing Google Ads account across conversion tracking, wasted spend, account structure, keywords/Quality Score, ads & assets (incl. PMax, AI Max, Demand Gen), and settings. Read-only. Generalized fork of the ads-google audit: reads everything from account-context.yaml and applies the operator's guardrails — no brand/vertical hardcoding. Use when the user says "audit", "score my account", "what's wrong with my ads", "wasted spend", "account review", "health check".
Installs into .claude/skills of the current project.
Are you the author of Audit?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/chanktb-audit)
---
name: google-ads-audit
description: >
Audits and scores an existing Google Ads account across conversion tracking, wasted spend, account
structure, keywords/Quality Score, ads & assets (incl. PMax, AI Max, Demand Gen), and settings.
Read-only. Generalized fork of the ads-google audit: reads everything from account-context.yaml and
applies the operator's guardrails — no brand/vertical hardcoding. Use when the user says "audit",
"score my account", "what's wrong with my ads", "wasted spend", "account review", "health check".
---
# Google Ads — Audit
Read-only diagnostic. Score the account, surface issues by severity, and — critically — **never fire a
finding on an unverified assumption**. Reuse the live MCP detection proven in `setup`.
## STEP 0 — Load context (MANDATORY, before any scoring)
1. Read `account-context.yaml` from the working directory. If missing, run `setup` first.
2. Pull from it: `google_ads.customer_id`, `brand_terms`, `competitor_terms`, `margin_tiers`,
`data_source` (for landing-page URL checks), `measurement`, and **`guardrails`**.
3. Output is written to the **working directory** (`GOOGLE-ADS-AUDIT.md`), never into the plugin.
## ⚠️ The six guards (apply to EVERY check — they prevent the most common wrong findings)
These are generic; `guardrails` in the context may strengthen or add to them. Honor both.
- **GUARD-1 Conversions — check the WHOLE picture, not the action list.** Inspect *campaign-level*
conversion goals + which actions each active campaign actually fires (segment by
`segments.conversion_action_name`), and each action's source. A primary action no campaign uses is
inert; a second purchase action kept *secondary* (e.g. GA4 purchase alongside the Shopify channel "App
Purchase") is the CORRECT anti-double-count setup, not a bug. Only flag a real micro-conversion goal
or two value-carrying purchase sources both primary and both firing.
**Before flagging ANY conversion-action contamination (out-of-brand / micro / duplicate), check
`primary_for_goal`.** A non-primary action does not enter Smart Bidding and does not affect performance —
it's harmless; surface it as a NOTE (tidy-up), never a scored finding. Only a PRIMARY out-of-brand/micro
action that an active campaign optimizes toward is a real issue. See `${CLAUDE_PLUGIN_ROOT}/references/conversion-tracking-logic.md`.
- **GUARD-2 Negatives — FOUR sources, merge ALL.** (1) `campaign_criterion` (negative=true) — campaign-level;
(2) `campaign_shared_set` → `shared_set` → `shared_criterion` — SHARED LISTS (the bulk: e.g. "Account Level
Negative", "OPI Negative", brand blocks); (3) `customer_negative_criterion` — ACCOUNT-LEVEL negatives applied
account-wide; (4) `campaign_criterion.brand_list.shared_set` — brand exclusions. Only flag a gap if terms are
absent across ALL FOUR; if a shared/account list covers them, cite it, don't flag. **A `campaign_criterion`-only
pull is the #1 false-positive in this audit — it looks "thin" when coverage is actually 100+ terms/campaign.**
After merging all four, declare `pulled:[...,"negatives_complete"]` so the report renders a real verdict;
without that assertion a campaign-only bundle renders VERIFY (re-pull), never a false "weak negatives".
- **GUARD-3 Budget scaling — check `change_event` first.** Before recommending a budget/bid increase,
pull recent change history (~14d). A recent budget/bid/asset/structure change → recommend cooldown,
not scale. (Strengthen with any `change-event-cooldown` guardrail in context.)
- **GUARD-4 Ad strength — confirm ENABLED + impressions.** Before flagging POOR ad strength, confirm
`ad_group.status` AND `ad_group_ad.status` are ENABLED and the group has impressions. Paused =
intentional; do not flag.
- **GUARD-5 ROAS — apply margin tiers from context.** Read `margin_tiers`; a line with a tier accepts its
`min_roas` (house brands run lower ROAS by design). Never apply a generic ROAS threshold to such a
line or a strategic campaign. If unsure, ASK rather than flag.
- **GUARD-6 No false "absence".** MCP results can be truncated (token cap or your `limit`). Before
claiming "no video / no signal / no negative / no X", re-query filtered on the enum NAME (e.g.
`field_type = 'YOUTUBE_VIDEO'`) or pull with returned-rows < limit (proof of completeness). NEVER
infer presence/absence by decoding enum integers from memory. Track failed fetches as a G-SYS1
diagnostic; never silently skip a check — explain why data is unavailable.
## Audit scope — only what's LIVE in the window (build this FIRST, before any check)
Establish the **active campaign set**: campaigns that are ENABLED **AND had impressions > 0 during the
audit window**. Exclude removed, paused, and ENABLED-but-dormant (zero-impression / long-off) campaigns.
Then **scope EVERY other pull to that set** — asset groups, ad groups, ads/ad-strength, keywords, search
terms. Entity-level `status = ENABLED` is NOT sufficient: a campaign turned off years ago can still contain
ENABLED asset groups / ad groups, and auditing them produces stale, misleading findings. Filter downstream
queries by `campaign.id IN (active set)`, or pull them WITH metrics over the window and drop zero-impression
rows app-side (metrics can't go in a GAQL WHERE). Never audit a campaign that hasn't served in the window.
## Model dispatch (run cheap, decide expensive) — see `${CLAUDE_PLUGIN_ROOT}/references/model-tier-dispatch.md`
- **Scout (`haiku`)** — STEP 0 context read; the `money_leak_report.py` render (→ the single `AUDIT.html`) after the Judge writes `audit-result.json`; single URL/landing 200 checks.
- **Routine (`sonnet`)** — build the active-campaign set; the account data-bundle pull (campaigns, assets, change history); the dual-source search-term pull (per-PMax `campaign_search_term_insight` loop). Dispatch as `general-purpose` sub-agents (they keep MCP); tell them to **return raw rows, not score**.
- **Judge (main session)** — applying GUARD-1…6, every PASS/WARN/FAIL call, the score, the Gap-to-100 ledger, Quick Wins. **Never delegate a guard or a verdict** — a Scout pulls numbers, the Judge says what they mean.
## Process
1. **Pre-flight**: load context + guards; **build the active campaign set** (above); stage the GUARD data
pulls (campaign conversion goals, merged negatives, `change_event`, `ad_group.status`) scoped to it.
2. **Collect** account data via the Google Ads MCP, **scoped to the active campaign set**. Honor GAQL gotchas
in `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/gaql-notes.md` (dedup keywords; ENABLED-only +
impressions-in-window; explicit YYYY-MM-DD date ranges; `search_term_view` can't use a 90-day DURING).
**MANDATORY pulls — a missing one = a false finding, not a clean account (this is the checklist a dispatched
Scout MUST be handed in full):**
- campaigns + metrics (active set), budgets, bidding strategy + tROAS
- conversion_action (account) + per-campaign `segments.conversion_action_name` usage (GUARD-1)
- asset_group / ad_group_ad ad-strength; keyword_view + Quality Score (deduped)
- **campaign extensions coverage** (D9): `SELECT campaign.id, campaign.status, campaign_asset.field_type,
campaign_asset.status FROM campaign_asset WHERE campaign.status='ENABLED' AND campaign_asset.status='ENABLED'`.
`field_type` gives the coverage (SITELINK / CALLOUT / STRUCTURED_SNIPPET / PRICE / IMAGE). **Two gotchas,
both seen live:** (1) `campaign.status` MUST be in the SELECT because it's filtered (else
`EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`); (2) do NOT also select `asset.type` here — it throws
INVALID_ARGUMENT on many MCPs (pull asset text separately if needed). Count `field_type` **per campaign**
(not just presence) so "thin extensions" findings use real counts. If it still errors, flag extensions
**UNVERIFIED — verify in UI**, never assume present/absent.
- search terms **both** sources: `search_term_view` + per-PMax `campaign_search_term_insight`
- **negatives from ALL FOUR sources (GUARD-2) — NOT just `campaign_criterion`:**
(a) `campaign_shared_set` (which shared lists attach to each campaign — **count only `status=2` ENABLED;
many lists are `status=3` REMOVED/old**) → `shared_criterion` (the terms in each ENABLED list);
(b) `customer_negative_criterion` (account-level negatives applied to every campaign — a separate resource,
easy to miss; the first ND pull omitted it and produced a false "weak negatives" finding that had to be
corrected mid-audit). A brand block / cross-brand block / location block usually lives in a SHARED or
ACCOUNT-level list, so a `campaign_criterion`-only pull shows "no negatives" when coverage is actually
extensive. **Never flag G07/G14/G-PM5/G-PM6 (brand exclusion / negative coverage) without having pulled
`shared_criterion` AND `customer_negative_criterion`. After merging, declare `pulled:[...,"negatives_complete"]`.**
- **PMax Brand exclusions are a SEPARATE layer from negative keywords — pull them explicitly:**
`campaign_criterion` selecting `campaign_criterion.brand_list.shared_set` + `.negative`
(filter `campaign_criterion.brand_list.shared_set IS NOT NULL`), then resolve each `shared_set.name`.
These rows have an EMPTY `keyword.text`, so a keyword-text-only pull DROPS them and you'll wrongly
report "no brand exclusion". Check whether a brand list matching the own brand (a list named after the
own brand) is excluded on each PMax campaign. An account can run BOTH a brand negative-keyword list AND a brand-list
exclusion (belt-and-suspenders) — read both before judging.
- `change_event` (~14d)
- **channel split** (D8): `segments.ad_network_type` per campaign — **this WORKS for PMax too** (network 2
Search/Shopping dominates; small YouTube/Display/cross). NEVER report channel as "verify in UI". Pull
`metrics.conversions` alongside cost — a cost-only pull makes every network look like 0-conv burn.
- **device split** (D10): `segments.device` per campaign (2 Mobile · 3 Tablet · 4 Desktop · 5 CTV · 6 Other).
- **dayparting** (D3/D4): `segments.hour` + `segments.day_of_week` per campaign + metrics — for the hourly
heatmap and best/worst hour/day. And **ad schedule**: `campaign_criterion.ad_schedule.*` + `bid_modifier`
(a flat `bid_modifier=0` across the window = NO dayparting bid strategy — a real opportunity finding).
- **geo — ALL regions** (D3): `geographic_view` scoped to active campaigns, `metrics.cost_micros > 0`, **no
top-N limit**. Then resolve EVERY `segments.geo_target_region` id to a name via `geo_target_constant`
(id, name, canonical_name). Raw `geoTargetConstants/NNNNN` ids in the report = an unfinished pull, never ship them.
- **asset TEXT + per-asset metrics** (D9) — pull the TEXT and cost, NOT just counts (a counts-only pull is
wrong; the verbatim text + per-asset spend is what surfaces "this headline spent $X at 0 conv"):
`SELECT campaign.id, asset_group_asset.field_type, asset.text_asset.text, metrics.cost_micros,
metrics.conversions, metrics.conversions_value, metrics.clicks FROM asset_group_asset WHERE
campaign.status='ENABLED' AND asset_group_asset.status='ENABLED' AND segments.date BETWEEN ...` (HEADLINE
ft2 / DESCRIPTION ft3 / LONG_HEADLINE ft18). `asset_group_asset` DOES return per-asset metrics — a
text-only or count-only pull just *looks* empty (gaql-notes). For **Search RSAs**, `ad_group_ad.ad.
responsive_search_ad.headlines` is a RepeatedComposite the serializer can't return — get RSA asset text +
metrics from **`ad_group_ad_asset_view`** instead (don't write "verify in UI"). The ONLY genuine UI-only
asset field is `asset_group_asset.performance_label` (UNRECOGNIZED on some API versions).
- **extension TEXT** (D9) — not just field_type counts: pull the sitelink/callout/snippet/price **text**
so the report shows the actual extensions. Either `campaign_asset` joined to the `asset` text fields
(`asset.sitelink_asset.link_text`, `asset.callout_asset.callout_text`, `asset.structured_snippet_asset.values`,
`asset.price_asset.*`) or the `asset` resource by id. bundle key `ext_text:[{campaign_id,field_type,text}]`.
- **conversion lag** (D12): `segments.conversion_lag_bucket` — the "can I trust short-window ROAS?" gate.
- **settings & measurement that DO read** (pull these — they were once wrongly punted to UI): **location type**
`campaign.geo_target_type_setting.positive_geo_target_type` — **official enum: 5=PRESENCE_OR_INTEREST,
6=SEARCH_INTEREST (both leaks → recommend Presence-only), 7=PRESENCE (correct, NOT a leak)**. Do NOT invert
this: 7 is the GOOD value. (Verified live + against the v21 proto 2026-06-28.) **Enhanced Conversions** `customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled`
+ `.accepted_customer_data_terms`; **final URLs** via `landing_page_view` / `expanded_landing_page_view`;
**RSA text + per-asset metrics** via `ad_group_ad_asset_view`; **placement exclusions** via
`campaign_criterion.placement.url`; **content-label exclusions** (digital content labels DV-G/PG/T/MA) via
`campaign_criterion` WHERE `type='CONTENT_LABEL'` (`content_label.type`) — **this READS; an empty result =
none configured, NOT unreadable** (do not punt it to UI). bundle key `content_labels:[{campaign_id,content_label_type}]`.
(Exact queries: `references/gaql-notes.md`.)
- **competitive pressure — impression share (PUBLIC, always pull)** (D-competitive): per active campaign pull
`metrics.search_impression_share`, `metrics.search_rank_lost_impression_share`,
`metrics.search_budget_lost_impression_share` (+ abs-top) with a date range. rank-lost IS = losing the
auction to COMPETITORS on Ad Rank; budget-lost IS = losing to budget (a scaling lever). Put on each
`active_campaigns` row. These are public metrics — never punt to UI.
- **competitor DOMAINS — Auction Insights (try, then degrade)** (competitor input): the "phiên đấu giá"
competitor list IS in the API — `segments.auction_insight_domain` + `metrics.auction_insight_search_*`
(impression_share / overlap_rate / outranking_share / position_above_rate / top & abs-top %). **BUT these
are RESTRICTED "not publicly available" metrics → most dev tokens get `METRIC_ACCESS_DENIED` unless Google
allowlisted the token.** So: TRY the pull; on success write bundle key `auction_insights:[{domain,
impression_share,overlap_rate,outranking_share,position_above_rate}]` (the report renders a Competitors card
and you MERGE the domains into the setup competitor list). On `METRIC_ACCESS_DENIED`, do NOT call it an API
gap — note "Auction Insights metrics not allowlisted on this token" and fall back to the impression-share
signal above + the competitors the user gave at setup. (Set `meta.own_domain` so your own row is tagged.)
- **⚠️ "verify in UI" is ONLY these confirmed-blocked fields** (every variant tested 2026-06-28) — do NOT
mark anything else UI-only: Final URL Expansion toggle (`url_expansion_opt_out` UNRECOGNIZED), asset
automation (`asset_automation_settings` PROHIBITED/RepeatedComposite), the **video brand-safety inventory
mode** (`campaign.video_brand_safety_suitability` UNRECOGNIZED on this version — Expanded/Standard/Limited),
PMax `asset_group_asset.performance_label` (UNRECOGNIZED — use
`asset_group.ad_strength` + per-asset cost/conv), and **Consent Mode v2 / server-side-CAPI** (tag-side by
design — verify in GTM, NOT a pull failure). EVERYTHING ELSE is in the API: a fetch that returns nothing =
wrong method (field/resource/missing `metrics.*`/enum-to-drop), **retry** — see `references/gaql-notes.md`.
A "we can't see it, check the UI" on API-available data is the #1 way this audit loses a user's trust.
- **GAQL gotcha:** any field used in a `WHERE` filter MUST also appear in the `SELECT` clause, or the API
returns `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE` (e.g. filtering on `campaign.status` requires
selecting it). If a field is rejected, drop just that field and re-run — don't abandon the whole pull.
- **COMPLETENESS — loop every campaign, never truncate.** Per-campaign pulls (`asset_group_signal`,
`asset_group_asset`, `campaign_search_term_insight`) must cover **ALL** PMax/Search campaigns — do NOT stop
"to save time" at the first few. A partial pull (e.g. signals for 3 of 7 PMax) makes the generator emit
FALSE "0 audience signals" findings for the un-pulled ones. Big results: save to a file + parse, don't drop.
- **DECLARE what you pulled.** `bundle.json` MUST carry a top-level **`pulled: [...]`** listing every
dimension you FULLY pulled (all campaigns) — e.g. `["active_campaigns","channel","device","geo","dayparting",
"schedule","extensions","signals","negatives","assets","products","conversion_lag","change_events"]`.
**Omit any dimension you could not complete** — the generator renders those VERIFY ("not pulled — re-run")
instead of fabricating GOOD/FIX. Honesty-by-construction: an absent `pulled` entry is safe; a half-filled
dimension passed off as complete is what burns the user.
3. **Validate coverage**: confirm ≥30 days of data and a Search Terms Report before scoring; if a source
is down, degrade gracefully (flag it, continue) per the fallback chain in `setup`.
4. **Evaluate** each applicable check as PASS / WARNING / FAIL — applying GUARD-1…6 so nothing fires on an
assumption. Use `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/google-audit-checks.md` for the check catalog and severities.
5. **Score** per `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/scoring-system.md` (weighted by severity × category).
6. **Report** with the per-category Gap-to-100 ledger and an action plan.
## Money-Leak deep-dive (MANDATORY — this is what makes the audit worth paying for)
Beyond scoring, run the money-leak diagnostics in `${CLAUDE_PLUGIN_ROOT}/references/diagnostic-playbook.md`
(D1 bid/target health · D2 budget pacing & allocation · D3 geo waste · D4 dayparting · D5 search-term/spam/
wrong-brand · D6 structure/setup · D7 Quality Score · D8 PMax channel/placement · D9 ad copy/assets/extensions ·
D10 settings hygiene · D11 audience signals/search themes · D12 conversion-lag gate · D13 change-history timeline ·
**D14 product feed & Shopping performance**). **For ECOM, D14 is the highest-$ diagnostic.** It has THREE
layers, each from a different source — degrade cleanly, don't lump them as "needs Merchant":
1. **Product PERFORMANCE/identity (Ads, always available)** — `shopping_performance_view` returns full product
data through the Merchant LINK (item_id, title, brand, type, condition + cost/impr/conv). The "which product
burns / converts" leak. The products ARE in Google Ads — never say "no product data".
2. **Out-of-stock (STORE connector)** — cross-ref the spending item_ids against the store's live inventory
(Shopify/Woo). OOS spenders = pure waste, and this needs NO Merchant connector — only the store you already
connected. Do this whenever a store is connected.
3. **Approval STATUS (Merchant Content API only)** — disapproved / GTIN / policy issues are NOT a GAQL field.
If a Merchant connector is present, pull it; if not, flag ONLY this layer as verify-via-Merchant (not the
whole of D14).
**D14 gets its OWN rendered HTML — `D14-PRODUCT-CHECK.html` — not just markdown.** When a store is connected,
join the zero-conversion burn products (layer 1) against live store inventory (layer 2) into ONE per-item JSON
(one row per burning product) with these fields per item: `campaign`, `title`, `brand`, `cost_30d`,
`clicks_30d`, `imp_30d`, `product_onlineStoreUrl` (the live PDP link — MANDATORY so the user can click to
check each SKU), `variant_sku`, `variant_price`, `variant_compareAtPrice`, `variant_inventoryPolicy`,
`total_available_all_locations`, `classification` (`OOS_DENY` | `LOW_STOCK` | `IN_STOCK_NEEDS_PDP_AUDIT`),
`reason`. Then render:
`python ${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/d14_product_report.py <items>.json --out-dir <workdir>
--account "<name>" --window "<range>"` → a visual, self-contained `D14-PRODUCT-CHECK.html`: KPI strip, one
color-coded section per classification (OOS=red / low-stock=amber / in-stock=blue), a burn bar per row, and a
**full SKU appendix where every product title links to its live storefront URL**. ALWAYS pull the PDP URL +
SKU (`product.onlineStoreUrl` / variant SKU from the store) — a D14 product list without clickable URLs is
incomplete; the user must be able to verify each item in one click. Each diagnostic yields a **Money-Leak
Report** row: leak · evidence
(real numbers) · diagnosis (root cause, not symptom) · **$/month at risk** (formula shown) · exact fix ·
discipline · confidence. Decode `bidding_strategy_system_status` FIRST (one field separates budget-capped vs
tROAS-too-high vs starved vs learning). A generic "score 85" without these specific, dollar-quantified leaks is
the low-value audit we are explicitly NOT shipping. The diagnostics feed both the category scores and the
Money-Leak Report in the output.
**One-command pipeline (model-tier dispatch) → ONE comprehensive report:** dispatch a **Routine** sub-agent
(`general-purpose`, sonnet) to run the PULL MANIFEST in
`${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/money_leak_report.py` (its docstring lists every GAQL query + the
`bundle.json` schema) → it writes `bundle.json`. **Write `audit-result.json` (the score — STEP 5) into the SAME
directory** so the generator merges score + money-leak. Then **Scout**-run
`python ${CLAUDE_PLUGIN_ROOT}/skills/audit/scripts/money_leak_report.py bundle.json --out-dir <workdir>` to
generate `MONEY-LEAK-REPORT.md` + `DETAILED-ACCOUNT-REPORT.md` AND the **single comprehensive `AUDIT.html`** —
score donut + grade, the 6 weighted category bars (fixable/unverified findings), money-leak $ chart + findings,
a **visual per-campaign section** (ROAS-vs-target bar with target tick, status pill, channel-split bar,
extension/signal chips), Merchant feed table, quick wins, change timeline, verify-in-UI. It auto-detects
`audit-result.json` next to the bundle (or pass `--audit-result`); without it AUDIT.html degrades to
money-leak-only. **AUDIT.html is THE — and the ONLY — HTML deliverable: one file, score + full check + leaks +
per-campaign visuals together.** Do not render any second HTML report (no `audit-report.html`); there is exactly
one HTML output per audit.
Then the **Judge** (you) adds verify-in-UI verdicts. **When a pull returns nothing, assume wrong method (field
name / resource / missing `metrics.*` / repeated-enum serialize), not an API limit — retry before writing
"verify-in-UI"** (see gaql-notes principle).
## What to analyze (category weights)
- **Conversion Tracking (25%)** — campaign-level goals (GUARD-1), Enhanced Conversions, Consent Mode v2,
value tracking, attribution, offline import (lead gen), conversion lag. Cross-check Google Ads vs GA4
(flag >35% divergence) when `measurement.ga4` is enabled.
- **Wasted Spend / Negatives (20%)** — review search terms from **BOTH sources** (`search_term_view` does
NOT contain PMax terms): `search_term_view` for Search/Branded campaigns + `campaign_search_term_insight`
**looped per active PMax/Demand-Gen campaign** (it requires a single `campaign_id` filter; see
gaql-notes.md). Merged negative coverage (GUARD-2), brand/non-brand separation (use `brand_terms`), broad
match only with Smart Bidding, geo precision. Source negatives from actual irrelevant search terms; prefer
Exact/Phrase; recommend shared lists. Only flag wasted on Search terms with >$10 spend AND 0 conv; for
PMax, flag irrelevant categories with clicks but no conversions. **Never propose blocking brand terms.**
- **Account Structure (15%)** — business-logic organization, tightly themed ad groups, ≥3 RSAs/group,
PMax asset-group/signal structure, naming consistency.
- **Keywords & Quality Score (15%)** — match-type strategy, QS distribution (≥7 target), cannibalization
(watch cross-campaign + use `brand_terms`), impression share. Apply the legacy-BMM heuristic (BROAD +
Manual CPC = legacy, not intentional broad).
- **Ads & Assets (15%)** — RSA headlines/descriptions count & strength (GUARD-4), pin discipline,
extensions (sitelinks/callouts/snippets/image), PMax/AI Max/Demand Gen specifics (below).
- **Settings & Targeting (10%)** — Smart Bidding vs deprecated ECPC, budget pacing, ad schedule, device
adjustments, location = "Presence" not "Presence or Interest", network settings.
## Deep dives (only if those campaign types exist)
- **PMax** — asset-group diversity, audience signals, URL-expansion control, search themes, Insights tab,
and **brand handling — VERIFY, don't just "confirm"**. When a Brand Search campaign runs alongside PMax,
do NOT flag "missing brand exclusion" on the surface. First DIG IN and check ALL THREE block mechanisms:
(a) **campaign-level negative keywords** (`campaign_criterion`, `negative=true`, type KEYWORD);
(b) a **shared negative list** (`campaign_shared_set` → `shared_criterion`) — brand blocks are MOST often a
named shared list (e.g. "Brand Keywords — do not apply to Branded campaign") applied to PMax + non-brand campaigns
and excluded from the Branded campaign; (c) a **brand-exclusion brand list** (brand-list criteria). Check
whether the account's `brand_terms` are actually blocked by ANY of these.
**If brand is already blocked → it's a PASS; re-score, don't flag.** Only flag G07/G-PM3 if brand
terms are genuinely unblocked (and corroborate with G-PM3: are >15% of PMax conversions on brand terms?).
Honor any `pmax-brand-exclusion` guardrail.
- **AI Max for Search** — `campaign.ai_max_setting.enable_ai_max`, broad-match+Smart-Bidding combo, search
term matching distribution, AI Brief, text-customization rules, FUE controls, brand exclusions. DSA/ACA
auto-migration pre-flight (Sept 2026) — stage LOW→HIGH risk; strong negatives are a prerequisite.
- **Demand Gen** — video+image asset mix, audience signals, funnel-aligned conversion goals (no frequency
capping — monitor manually).
## Key thresholds
| Metric | Pass | Warning | Fail |
|--------|------|---------|------|
| Quality Score (avg) | ≥7 | 5-6 | <5 |
| CTR (Search) | ≥6.66% | 3-6.66% | <3% |
| CVR (Search) | ≥7.52% | 3-7.52% | <3% |
| CPC (Search) | ≤$5.26 | $5.26-8.00 | >$8.00 |
| Wasted Spend | <10% | 10-20% | >20% |
| Ad Strength | Good+ | Average | Poor |
(Thresholds are **ecommerce-Search defaults** and niches vary widely — apparel ≠ electronics ≠ supplements.
**Derive bands from the account's OWN historical data first**; fall back to these only for a brand-new account,
and state the basis. Never FAIL a campaign on a benchmark its niche legitimately runs below.)
## Report architecture — PER-CAMPAIGN VISUAL EXPLAINER (see `${CLAUDE_PLUGIN_ROOT}/references/per-campaign-report-template.md`)
The audit is a **per-campaign visual explainer**, not a generic account summary. Document order: **Account
panel → one chart-rich block per active campaign → Action Plan**.
**The 3-step rule for EVERY item — ① DATA shown → ② VERDICT (GOOD / WATCH / FIX / VERIFY) → ③ ACTION (only
if needed).** There is NO separate "money-leak summary" block: the leak IS the verdict+action on the section
it belongs to (evaluated inline). Every quantitative metric renders as its own **inline-SVG chart**, never a
wall of text:
- ROAS vs target → **semicircle gauge** (fill = ROAS, tick = target) · Budget pacing → **radial ring**
- Channel mix → **donut** · Device & Geo → **horizontal bars colored by ROAS** · Schedule → **24-hour heatmap**
- Extension/signal counts → **meter bars** vs the recommended count.
Each campaign block shows a **scorecard** (N good · N watch · N fix · N verify) and covers: performance +
decoded `bidding_strategy_system_status`, channel, device, geo, schedule, products × Merchant, audience
signals + search themes, ad copy (headline/description text), extensions **with text**, negatives + shared
lists, final URLs/FUE, change/cooldown — each field marked **[API]** or **[VERIFY]** (read-only API can't
see it). The **account panel lists & rates EVERY account-level setting** (conversion tracking, Enhanced
Conv, Consent, account/brand negative lists, Content Suitability, structure, conversion-lag) the same 3-step
way. **Localize per campaign**; only cross-campaign / account-wide items (budget misallocation, tracking,
content suitability, structure) sit in the account panel. Flat/modern style (no heavy gradients).
The blueprint (every section, its chart, data source, verdict thresholds) is
`references/per-campaign-report-template.md`. The generator (`money_leak_report.py`) builds it: account
panel + Σ(campaign block) + action plan → one self-contained `AUDIT.html`. It auto-detects `audit-result.json`
next to the bundle for the score + the account panel's category verdicts.
## Output — `GOOGLE-ADS-AUDIT.md` (to the working dir)
1. **Health Score** 0-100 + grade, with the six category bars.
2. **Per-category Gap-to-100 ledger** (MANDATORY for any category < 100). Split every non-PASS into:
- 🔧 **Fixable (verified)** — check ID, status, severity, **points recoverable**, concrete fix.
- 🔍 **Unverified** — API/MCP can't see it (Enhanced Conversions recording, Consent Mode mode, gtag
firing). Do NOT score as FAIL; mark "verify in UI" and state the ceiling if confirmed. A low score
driven by unverified items is not breakage — say so.
3. **Money-Leak Report** (the headline deliverable) — the D1-D14 findings as rows ranked by $/month
recoverable, each with evidence + root-cause diagnosis + exact fix + discipline + confidence. Top 3 = Quick
Wins. This is what the operator reads first.
4. **Detailed Account Report** (the differentiated value — granular, not just a score). Beyond the summary:
- **Account summary** + *what is protecting it* (e.g. "no Display burn BECAUSE Content Suitability blocks junk
apps" — explain the cause, don't just report the healthy symptom).
- **Per active campaign**, a deep ledger: bidding/tROAS + **decoded `bidding_strategy_system_status`**, ROAS
vs target, channel split (D8), device split (D10), geo top/bottom (D3), dayparting flags (D4), extension
inventory — sitelinks/callouts/snippets counts (D9), ad-copy notes incl. duplicates/Ad-Strength (D9), and
the **settings layer (D10)** — Final URL Expansion + URL exclusions, asset automation / text customization,
text/brand guideline (exists? appropriate?), content-suitability / placement exclusions, device exclusions.
**Mark each field API-read or verify-in-UI.**
- **Per-campaign Verify-in-UI checklist** — the settings the read-only API can't see (FUE, asset automation,
content suitability, text-guideline content) listed as explicit checkboxes. Never silently skip a setting.
5. Wasted-spend estimate (monthly $), PMax/AI Max notes.
6. **Findings feed `optimizer` and `plan`** — but audit itself changes nothing.
### Also emit a structured result — it feeds the ONE comprehensive HTML report
- Write **`audit-result.json`** (schema: `${CLAUDE_PLUGIN_ROOT}/templates/audit-result.json`): health_score, grade, per-category
scores + fixable/unverified findings, quick_wins, wasted_spend_monthly, notes. **Put it next to `bundle.json`.**
- The client report is **`AUDIT.html`** from `money_leak_report.py` (above): a **per-campaign visual explainer**
that auto-merges this score JSON (account panel + category verdicts) with the per-campaign chart blocks
(gauge / ring / donut / bars / heatmap) where every item is **data → verdict → action**. ONE self-contained
file. **Do NOT ship the score and the money-leak as two separate HTML files** — the audit is one document.
- **There is exactly ONE HTML report: `AUDIT.html`.** The old score-only `audit_to_html.py`/`audit-report.html`
path has been REMOVED — do not look for it or generate a second file.
## To build / refine later
- [x] **Per-campaign visual-explainer `AUDIT.html`** — `scripts/money_leak_report.py` renders the chart-rich,
data→verdict→action report (gauge/ring/donut/bars/heatmap + account panel + scorecards). This is the SOLE HTML
report; the legacy `audit_to_html.py` was removed. Done.
- [x] Full granular check-ID catalog (G01…G61 + extensions) in `${CLAUDE_PLUGIN_ROOT}/skills/audit/references/google-audit-checks.md`. Done.
- [ ] Vertical-specific benchmark bands in `${CLAUDE_PLUGIN_ROOT}/references/`.
- [ ] Per-keyword QS + RSA pin/headline-count detail for Search campaigns (currently summarized).
- [ ] Geo as a US choropleth (currently colored bars).