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)
- Read
account-context.yamlfrom the working directory. If missing, runsetupfirst. - Pull from it:
google_ads.customer_id,brand_terms,competitor_terms,margin_tiers,data_source(for landing-page URL checks),measurement, andguardrails. - 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), checkprimary_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. Acampaign_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, declarepulled:[...,"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_eventfirst. Before recommending a budget/bid increase, pull recent change history (~14d). A recent budget/bid/asset/structure change → recommend cooldown, not scale. (Strengthen with anychange-event-cooldownguardrail in context.) - GUARD-4 Ad strength — confirm ENABLED + impressions. Before flagging POOR ad strength, confirm
ad_group.statusANDad_group_ad.statusare 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 itsmin_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; themoney_leak_report.pyrender (→ the singleAUDIT.html) after the Judge writesaudit-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-PMaxcampaign_search_term_insightloop). Dispatch asgeneral-purposesub-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
- 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. - 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_viewcan't use a 90-day DURING). MANDATORY pulls — a missing 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_nameusage (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_typegives the coverage (SITELINK / CALLOUT / STRUCTURED_SNIPPET / PRICE / IMAGE). Two gotchas, both seen live: (1)campaign.statusMUST be in the SELECT because it's filtered (elseEXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE); (2) do NOT also selectasset.typehere — it throws INVALID_ARGUMENT on many MCPs (pull asset text separately if needed). Countfield_typeper 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-PMaxcampaign_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 onlystatus=2ENABLED; many lists arestatus=3REMOVED/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 acampaign_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 pulledshared_criterionANDcustomer_negative_criterion. After merging, declarepulled:[...,"negatives_complete"]. - PMax Brand exclusions are a SEPARATE layer from negative keywords — pull them explicitly:
campaign_criterionselectingcampaign_criterion.brand_list.shared_set+.negative(filtercampaign_criterion.brand_list.shared_set IS NOT NULL), then resolve eachshared_set.name. These rows have an EMPTYkeyword.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_typeper campaign — this WORKS for PMax too (network 2 Search/Shopping dominates; small YouTube/Display/cross). NEVER report channel as "verify in UI". Pullmetrics.conversionsalongside cost — a cost-only pull makes every network look like 0-conv burn. - device split (D10):
segments.deviceper campaign (2 Mobile · 3 Tablet · 4 Desktop · 5 CTV · 6 Other). - dayparting (D3/D4):
segments.hour+segments.day_of_weekper campaign + metrics — for the hourly heatmap and best/worst hour/day. And ad schedule:campaign_criterion.ad_schedule.*+bid_modifier(a flatbid_modifier=0across the window = NO dayparting bid strategy — a real opportunity finding). - geo — ALL regions (D3):
geographic_viewscoped to active campaigns,metrics.cost_micros > 0, no top-N limit. Then resolve EVERYsegments.geo_target_regionid to a name viageo_target_constant(id, name, canonical_name). RawgeoTargetConstants/NNNNNids 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_assetDOES 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.headlinesis a RepeatedComposite the serializer can't return — get RSA asset text + metrics fromad_group_ad_asset_viewinstead (don't write "verify in UI"). The ONLY genuine UI-only asset field isasset_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_assetjoined to theassettext fields (asset.sitelink_asset.link_text,asset.callout_asset.callout_text,asset.structured_snippet_asset.values,asset.price_asset.*) or theassetresource by id. bundle keyext_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 Conversionscustomer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled.accepted_customer_data_terms; final URLs vialanding_page_view/expanded_landing_page_view; RSA text + per-asset metrics viaad_group_ad_asset_view; placement exclusions viacampaign_criterion.placement.url; content-label exclusions (digital content labels DV-G/PG/T/MA) viacampaign_criterionWHEREtype='CONTENT_LABEL'(content_label.type) — this READS; an empty result = none configured, NOT unreadable (do not punt it to UI). bundle keycontent_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 eachactive_campaignsrow. 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 getMETRIC_ACCESS_DENIEDunless Google allowlisted the token. So: TRY the pull; on success write bundle keyauction_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). OnMETRIC_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. (Setmeta.own_domainso 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_outUNRECOGNIZED), asset automation (asset_automation_settingsPROHIBITED/RepeatedComposite), the video brand-safety inventory mode (campaign.video_brand_safety_suitabilityUNRECOGNIZED on this version — Expanded/Standard/Limited), PMaxasset_group_asset.performance_label(UNRECOGNIZED — useasset_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/missingmetrics.*/enum-to-drop), retry — seereferences/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
WHEREfilter MUST also appear in theSELECTclause, or the API returnsEXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE(e.g. filtering oncampaign.statusrequires 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.jsonMUST carry a top-levelpulled: [...]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 absentpulledentry is safe; a half-filled dimension passed off as complete is what burns the user.
- 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. - 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.mdfor the check catalog and severities. - Score per
${CLAUDE_PLUGIN_ROOT}/skills/audit/references/scoring-system.md(weighted by severity × category). - 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":
- Product PERFORMANCE/identity (Ads, always available) —
shopping_performance_viewreturns 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". - 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.
- 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-containedD14-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. Decodebidding_strategy_system_statusFIRST (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.ga4is enabled. - Wasted Spend / Negatives (20%) — review search terms from BOTH sources (
search_term_viewdoes NOT contain PMax terms):search_term_viewfor Search/Branded campaigns +campaign_search_term_insightlooped per active PMax/Demand-Gen campaign (it requires a singlecampaign_idfilter; see gaql-notes.md). Merged negative coverage (GUARD-2), brand/non-brand separation (usebrand_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'sbrand_termsare 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 anypmax-brand-exclusionguardrail. - 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)
- Health Score 0-100 + grade, with the six category bars.
- 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.
- 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.
- 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.
- Wasted-spend estimate (monthly $), PMax/AI Max notes.
- Findings feed
optimizerandplan— 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 tobundle.json. - The client report is
AUDIT.htmlfrommoney_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-onlyaudit_to_html.py/audit-report.htmlpath has been REMOVED — do not look for it or generate a second file.
To build / refine later
- Per-campaign visual-explainer
AUDIT.html—scripts/money_leak_report.pyrenders the chart-rich, data→verdict→action report (gauge/ring/donut/bars/heatmap + account panel + scorecards). This is the SOLE HTML report; the legacyaudit_to_html.pywas removed. Done. - 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).