| 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)
- Read
account-context.yaml from the working directory. If missing, run setup first.
- Pull from it:
google_ads.customer_id, brand_terms, competitor_terms, margin_tiers,
data_source (for landing-page URL checks), measurement, and guardrails.
- 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
- 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_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.
- 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.md for 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_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".
- 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-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)
- 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
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