Google Ads — Optimizer (act)
Find what's dragging the account and fix it — with data backing and root-cause reasoning, not symptom
swatting. The optimizer proposes changes; it applies them only through pusher (approval gate). For a
scored health check use audit; this skill is about performance and money.
Operating rules
- Every recommendation has data backing (specific numbers, not vague advice).
- Read everything from
account-context.yaml (margin_tiers, brand_terms, guardrails, AOV). If the context
is missing, run setup first — never optimize a live account without it.
- Read the context
connections block first. If store/GA4 is missing, do the in-platform analysis and
label every true-ROAS / store-revenue conclusion UNVERIFIED — connect store/GA4; never fabricate a
store-revenue figure to compute "true ROAS". Prefer guiding the user to connect over guessing.
- 3-source attribution (see
${CLAUDE_PLUGIN_ROOT}/references/optimization-playbook.md): store revenue = ground truth;
Google Ads in-platform = for Smart Bidding; GA4 = channel mix. A 20-35% Ads-vs-GA4 gap is normal.
- Honor guardrails: change-event cooldown, ignore paused, margin-tier ROAS. Don't flag a house-brand line
for low ROAS above its tier
min_roas.
Model dispatch (run cheap, decide expensive) — see ${CLAUDE_PLUGIN_ROOT}/references/model-tier-dispatch.md
- Scout (
haiku) — running tiering.py and search_term_miner.py (scripts return their own output).
- Routine (
sonnet) — STEP 1 performance pull + store/GA4 fetch; STEP 3 dual-source search-term pull (per-PMax campaign_search_term_insight loop). Dispatch as general-purpose sub-agents; return raw, don't conclude.
- Judge (main session) — tier verdicts, what to block vs keep (esp. never-block-brand), STEP 5 profitability call, STEP 6 root-cause, STEP 7 dated action plan. The numbers come cheap; the decisions stay here.
STEP 1 — Collect performance
Scope to the active set first (ENABLED + impressions in the window) — never tier or "optimize" a
campaign that hasn't served in the period; entity status=ENABLED can include long-dead campaigns' assets
(see ${CLAUDE_PLUGIN_ROOT}/skills/audit/references/gaql-notes.md). Then pull campaign performance, search terms (≤30d or explicit dates),
and asset-group performance via the MCP; pull store revenue + GA4 channel mix via the data-source/GA4
fallback chain. Note what's unavailable.
STEP 2 — Tier campaigns
Classify Gold / Silver / Bronze / Dead by ROAS relative to the margin-tier target, plus conversion
volume per period. Output a ranked verdict table (Scale / Keep / Reduce / Pause / Kill) with the numbers.
Runnable: python ${CLAUDE_PLUGIN_ROOT}/skills/optimizer/scripts/tiering.py campaigns.json [--target-roas T] (margin-tier aware;
flags learning risk + pause-candidate savings).
STEP 3 — Mine search terms → negatives (BOTH sources)
Pull from both: search_term_view (Search/Branded) AND campaign_search_term_insight looped per
active PMax/Demand-Gen campaign (requires a single campaign_id filter — see gaql-notes.md; it returns
categories + conv/value, not per-term cost). search_term_view does NOT contain PMax terms, so PMax-heavy
accounts need the insight or you'll miss most of the spend. Find top spend, top revenue, wasted (Search:
$10 spend & 0 conv; PMax: irrelevant categories with clicks but no conversions). Propose Exact/Phrase
negatives in themed lists; respect the "what NOT to block" rules (store / coupon / cheap-brand) in the
playbook; never block brand terms (brand intent, even at 0 conv). Catch cross-brand leakage via
brand_terms.
🛑 RESELLER BRAND RULE — never negate a brand the store SELLS (the #1 false-positive)
For a distributor/reseller, a search term containing a brand you carry is buying intent, not a
competitor — auto-negating it kills your own sales. Classify every brand-bearing term into THREE buckets,
not two:
- OWN house brand → never block (brand defense). [
brand_terms]
- CARRIED brand (a brand in your catalog/feed) — split by whether it already has its own campaign:
- has a dedicated campaign → blocking it in a catch-all (e.g. "Lite") is OK only to ROUTE traffic to
the specialist campaign (anti-cannibalization). Label it "route to campaign", NOT "competitor".
- no dedicated campaign → NEVER block. The catch-all is its ONLY home — it lives there because it
hasn't been split out yet, not because it's a competitor. A 0-conv carried-brand term is a stock/PDP/feed
problem OR a split-into-its-own-campaign candidate once volume justifies it. Recommend the split, never
a negative.
- NOT carried (store genuinely doesn't sell it) → a candidate competitor negative — flag for the user to
CONFIRM it isn't carried before blocking. Never assert "competitor" on your own.
Populate the miner's brand inputs every run (don't run it blind):
carried_brands = the distinct brand
values from the store catalog / the Shopping feed (shopping_performance_view product brand, or the D14
inventory join) ∪ any brands_carried in account-context.yaml; brands_with_own_campaign = brands that map
to an existing dedicated campaign (derive from the active-campaign names, e.g. "ND pMax OPI" → opi). Without
these, the miner will mislabel carried brands (Kupa/Chaun Legend/Kiara Sky on ND) as competitors — exactly the
bug to avoid.
Runnable: python ${CLAUDE_PLUGIN_ROOT}/skills/optimizer/scripts/search_term_miner.py terms.json
(input keys: brand_terms, carried_brands, brands_with_own_campaign, min_spend, terms — each term may
carry conversions/conv_value) → wasted spend + categorized, ready-to-copy wrapped negatives, with
carried-brand / routing / competitor-candidate buckets kept separate and carried-brand-without-a-campaign held
back from blocking.
N-gram modifiers + conflict detection (built in to the miner)
- N-gram surfacing: the miner aggregates 1- and 2-word tokens across the whole waste pool, ranked by $ and
by how many terms each appears in (≥2 = recurring). One phrase negative on a recurring junk modifier (e.g.
"how to", "jobs", "tutorial") kills many wasted queries at once — far more efficient than per-term
negatives. Own-brand and carried-brand tokens are excluded from the surfacing (never propose cutting a brand
you sell). Treat as candidates — a broad modifier can over-reach; eyeball before pushing.
- Conflict detection — NEVER negate a converter: the miner cross-checks every proposed negative against the
CONVERTING queries (
conversions/conv_value > 0) and drops any that would block one (exact = same query,
phrase = substring), listing them under "Conflict-blocked". Always pass terms WITH their conversion fields so
this fires. At push time, also run validate_changeset.py --converting-terms <converters.json> (a JSON
list of converting queries) so the pusher gate re-blocks any negative that would cut proven revenue —
defense-in-depth alongside the never-block-brand and carried-brand gates.
STEP 4 — Asset groups / creative
Flag asset groups with ROAS below the tier target and meaningful spend, POOR ad strength (ENABLED + has
impressions — GUARD-4), and URL overlap. Recommend creative refresh via assets.
STEP 5 — Cross-reference store + GA4 (true profitability)
True ROAS = store revenue (by product/vendor) ÷ Google Ads spend. If a line's TOTAL store revenue (all
channels) is below its Ads spend, it's losing money regardless of in-platform ROAS. Surface channel mix
(how much is organic/direct/email) so pausing decisions account for non-paid revenue.
STEP 5.5 — Run the seven money-leak diagnostics (the core engine)
Work through D1-D14 in ${CLAUDE_PLUGIN_ROOT}/references/diagnostic-playbook.md against the active set: D1 bid/
target health (decode bidding_strategy_system_status first — budget-capped vs tROAS-too-high vs starved vs
learning; breakeven ROAS = 1/margin), D2 budget pacing & allocation (misallocation: shift $ low-ROAS→high-ROAS),
D3 geo waste, D4 dayparting, D5 search-term/spam/wrong-brand, D6 structure, D7 Quality Score, D8 PMax
channel/placement distribution (don't assume "Display burns it" — pull segments.ad_network_type, name the
real surface; most levers DON'T work on PMax), D9 ad copy/assets/extensions (missing sitelinks/callouts,
duplicate headlines, LOW assets). Each becomes a prescription with a real $ impact, a step size, and a cooldown
gate. Caveat baked in: budget-lost-IS is blind on Smart Bidding/PMax — diagnose budget constraint via
spend-vs-budget + system_status, not budget-lost-IS. For any tROAS/budget scale move, follow the Scaling
Ladder (budget first then target, never both same week, ≤+20% budget / 10–20% target steps, 14-day cooldowns,
never reverse inside the window).
STEP 6 — Diagnose root cause (System Thinking)
Don't stop at symptoms. For each problem ask "why" until you reach the cause (e.g. low ROAS → irrelevant
search terms → missing negatives → no weekly review process). Check interactions: campaigns competing for
the same queries, cross-brand budget leakage, over-reliance on paid vs organic. Project the cost of
inaction vs the savings from fixing.
STEP 7 — Action plan
Produce a dated plan in three buckets, each item with: what to do · exact UI/Editor steps · why (data) ·
status checkbox.
- Today: pause Dead campaigns, cut budget on Bronze, add negative lists, fund under-invested branded.
- Tomorrow: review yesterday; raise tROAS ≤0.2-0.3x (only if ≥15 conv/week, else consolidate first);
scale Gold (+budget gradually); plan consolidations.
- 2-week roadmap: dated timeline, each action typed (urgent/optimize/plan).
Respect the tROAS step-up discipline and change-event cooldown throughout.
STEP 8 — Emit the change-set (the pusher hand-off)
Serialize the action plan into a change-set.json (contract: ${CLAUDE_PLUGIN_ROOT}/references/change-set.md;
template: ${CLAUDE_PLUGIN_ROOT}/templates/change-set.json) — a typed list of edits to the LIVE account
(add_negatives, exclude_geo, exclude_products, adjust_budget, adjust_target_roas, pause,
add_extensions, add_audience_signal, exclude_placements). This is distinct from a builder's
campaign-spec.json (which creates a NEW campaign).
- Every action carries a
reason (the $ leak / numbers), its diagnostic D-code, est_impact_per_mo, and a
push_path. Express a budget reallocation as a pair (a down/pause on the source + an adjust_budget
up on the receiver) so each leg is gated on its own.
- Encode the discipline in the data, don't rely on prose: mark
target.within_cooldown: true for any
campaign with a recent change_event (D13) or under the change-event-cooldown guardrail, set
target.last_change_direction, and pass conv_per_week on a tROAS raise. The pusher's validator BLOCKS a
scale move on a within-cooldown target — so a campaign that "shouldn't scale yet" must NOT carry a do-now
scale action (defer it to the prose roadmap, or omit it). PMax scales DOWN by pausing a low-ROAS asset
group, never a budget cut.
- The optimizer proposes the change-set; it never writes. Hand it to
pusher (or /google-ads-push),
which validates (validate_changeset.py) and renders operator actions (changeset_to_actions.py) behind
the approval gate.
Applying changes
Account mutations go through pusher (approval gate + spend cap). The optimizer prepares the change-set
(STEP 8); it does not write to the account directly.
To build / refine later
1---2name: google-ads-optimizer3description: Acts on a running account to improve performance: campaign tiering, search-term mining into negatives, budget reallocation, target ROAS/CPA adjustments, bid-strategy changes, asset refresh, and consolidation. Diagnoses root cause (not just symptoms) and produces a dated action plan. Proposes changes — applies via pusher with approval. Generalized fork of a production optimizer; reads account-context.yaml, respects guardrails and margin tiers. Use when the user says "optimize", "improve ROAS", "lower CPA", "add negatives", "raise tROAS", "reallocate budget", "fix underperformers", "weekly ads review".4---56# Google Ads — Optimizer (act)78Find what's dragging the account and fix it — with data backing and root-cause reasoning, not symptom9swatting. The optimizer **proposes** changes; it applies them only through `pusher` (approval gate). For a10scored health check use `audit`; this skill is about performance and money.1112## Operating rules13- Every recommendation has data backing (specific numbers, not vague advice).14- Read everything from `account-context.yaml` (margin_tiers, brand_terms, guardrails, AOV). **If the context15 is missing, run `setup` first — never optimize a live account without it.**16- **Read the context `connections` block first.** If store/GA4 is `missing`, do the in-platform analysis and17 label every true-ROAS / store-revenue conclusion **UNVERIFIED — connect store/GA4**; never fabricate a18 store-revenue figure to compute "true ROAS". Prefer guiding the user to connect over guessing.19- **3-source attribution** (see `${CLAUDE_PLUGIN_ROOT}/references/optimization-playbook.md`): store revenue = ground truth;20 Google Ads in-platform = for Smart Bidding; GA4 = channel mix. A 20-35% Ads-vs-GA4 gap is normal.21- Honor guardrails: change-event cooldown, ignore paused, margin-tier ROAS. Don't flag a house-brand line22 for low ROAS above its tier `min_roas`.2324## Model dispatch (run cheap, decide expensive) — see `${CLAUDE_PLUGIN_ROOT}/references/model-tier-dispatch.md`25- **Scout (`haiku`)** — running `tiering.py` and `search_term_miner.py` (scripts return their own output).26- **Routine (`sonnet`)** — STEP 1 performance pull + store/GA4 fetch; STEP 3 dual-source search-term pull (per-PMax `campaign_search_term_insight` loop). Dispatch as `general-purpose` sub-agents; **return raw, don't conclude**.27- **Judge (main session)** — tier verdicts, what to block vs keep (esp. never-block-brand), STEP 5 profitability call, STEP 6 root-cause, STEP 7 dated action plan. The numbers come cheap; the decisions stay here.2829## STEP 1 — Collect performance30**Scope to the active set first** (ENABLED + impressions in the window) — never tier or "optimize" a31campaign that hasn't served in the period; entity `status=ENABLED` can include long-dead campaigns' assets32(see ${CLAUDE_PLUGIN_ROOT}/skills/audit/references/gaql-notes.md). Then pull campaign performance, search terms (≤30d or explicit dates),33and asset-group performance via the MCP; pull store revenue + GA4 channel mix via the data-source/GA434fallback chain. Note what's unavailable.3536## STEP 2 — Tier campaigns37Classify Gold / Silver / Bronze / Dead by ROAS **relative to the margin-tier target**, plus conversion38volume per period. Output a ranked verdict table (Scale / Keep / Reduce / Pause / Kill) with the numbers.39Runnable: `python ${CLAUDE_PLUGIN_ROOT}/skills/optimizer/scripts/tiering.py campaigns.json [--target-roas T]` (margin-tier aware;40flags learning risk + pause-candidate savings).4142## STEP 3 — Mine search terms → negatives (BOTH sources)43Pull from **both**: `search_term_view` (Search/Branded) AND `campaign_search_term_insight` looped **per44active PMax/Demand-Gen campaign** (requires a single `campaign_id` filter — see gaql-notes.md; it returns45categories + conv/value, not per-term cost). `search_term_view` does NOT contain PMax terms, so PMax-heavy46accounts need the insight or you'll miss most of the spend. Find top spend, top revenue, wasted (Search:47>$10 spend & 0 conv; PMax: irrelevant categories with clicks but no conversions). Propose Exact/Phrase48negatives in themed lists; respect the "what NOT to block" rules (store / coupon / cheap-brand) in the49playbook; **never block brand terms** (brand intent, even at 0 conv). Catch cross-brand leakage via50`brand_terms`.5152### 🛑 RESELLER BRAND RULE — never negate a brand the store SELLS (the #1 false-positive)53For a distributor/reseller, a search term containing a **brand you carry** is **buying intent, not a54competitor** — auto-negating it kills your own sales. Classify every brand-bearing term into THREE buckets,55not two:561. **OWN house brand** → never block (brand defense). [`brand_terms`]572. **CARRIED brand** (a brand in your catalog/feed) — split by whether it already has its own campaign:58 - **has a dedicated campaign** → blocking it in a catch-all (e.g. "Lite") is OK *only to ROUTE* traffic to59 the specialist campaign (anti-cannibalization). Label it "route to <brand> campaign", NOT "competitor".60 - **no dedicated campaign** → **NEVER block.** The catch-all is its ONLY home — it lives there *because it61 hasn't been split out yet, not because it's a competitor.* A 0-conv carried-brand term is a stock/PDP/feed62 problem OR a **split-into-its-own-campaign candidate** once volume justifies it. Recommend the split, never63 a negative.643. **NOT carried** (store genuinely doesn't sell it) → a *candidate* competitor negative — flag for the user to65 CONFIRM it isn't carried before blocking. Never assert "competitor" on your own.66**Populate the miner's brand inputs every run** (don't run it blind): `carried_brands` = the distinct `brand`67values from the store catalog / the Shopping feed (`shopping_performance_view` product brand, or the D1468inventory join) ∪ any `brands_carried` in `account-context.yaml`; `brands_with_own_campaign` = brands that map69to an existing dedicated campaign (derive from the active-campaign names, e.g. "ND pMax OPI" → `opi`). Without70these, the miner will mislabel carried brands (Kupa/Chaun Legend/Kiara Sky on ND) as competitors — exactly the71bug to avoid.72Runnable: `python ${CLAUDE_PLUGIN_ROOT}/skills/optimizer/scripts/search_term_miner.py terms.json`73(input keys: `brand_terms`, `carried_brands`, `brands_with_own_campaign`, `min_spend`, `terms` — each term may74carry `conversions`/`conv_value`) → wasted spend + categorized, ready-to-copy wrapped negatives, with75carried-brand / routing / competitor-candidate buckets kept separate and carried-brand-without-a-campaign held76back from blocking.7778### N-gram modifiers + conflict detection (built in to the miner)79- **N-gram surfacing**: the miner aggregates 1- and 2-word tokens across the whole waste pool, ranked by $ and80 by how many terms each appears in (≥2 = recurring). One phrase negative on a recurring junk modifier (e.g.81 `"how to"`, `"jobs"`, `"tutorial"`) kills many wasted queries at once — far more efficient than per-term82 negatives. Own-brand and carried-brand tokens are excluded from the surfacing (never propose cutting a brand83 you sell). Treat as candidates — a broad modifier can over-reach; eyeball before pushing.84- **Conflict detection — NEVER negate a converter**: the miner cross-checks every proposed negative against the85 CONVERTING queries (`conversions`/`conv_value` > 0) and drops any that would block one (exact = same query,86 phrase = substring), listing them under "Conflict-blocked". Always pass terms WITH their conversion fields so87 this fires. **At push time, also run** `validate_changeset.py --converting-terms <converters.json>` (a JSON88 list of converting queries) so the pusher gate re-blocks any negative that would cut proven revenue —89 defense-in-depth alongside the never-block-brand and carried-brand gates.9091## STEP 4 — Asset groups / creative92Flag asset groups with ROAS below the tier target and meaningful spend, POOR ad strength (ENABLED + has93impressions — GUARD-4), and URL overlap. Recommend creative refresh via `assets`.9495## STEP 5 — Cross-reference store + GA4 (true profitability)96True ROAS = store revenue (by product/vendor) ÷ Google Ads spend. If a line's TOTAL store revenue (all97channels) is below its Ads spend, it's losing money regardless of in-platform ROAS. Surface channel mix98(how much is organic/direct/email) so pausing decisions account for non-paid revenue.99100## STEP 5.5 — Run the seven money-leak diagnostics (the core engine)101Work through D1-D14 in `${CLAUDE_PLUGIN_ROOT}/references/diagnostic-playbook.md` against the active set: D1 bid/102target health (decode `bidding_strategy_system_status` first — budget-capped vs tROAS-too-high vs starved vs103learning; breakeven ROAS = 1/margin), D2 budget pacing & allocation (misallocation: shift $ low-ROAS→high-ROAS),104D3 geo waste, D4 dayparting, D5 search-term/spam/wrong-brand, D6 structure, D7 Quality Score, **D8 PMax105channel/placement distribution** (don't assume "Display burns it" — pull `segments.ad_network_type`, name the106real surface; most levers DON'T work on PMax), **D9 ad copy/assets/extensions** (missing sitelinks/callouts,107duplicate headlines, LOW assets). Each becomes a prescription with a real $ impact, a step size, and a cooldown108gate. **Caveat baked in:** budget-lost-IS is blind on Smart Bidding/PMax — diagnose budget constraint via109spend-vs-budget + system_status, not budget-lost-IS. **For any tROAS/budget scale move, follow the Scaling110Ladder** (budget first then target, never both same week, ≤+20% budget / 10–20% target steps, 14-day cooldowns,111never reverse inside the window).112113## STEP 6 — Diagnose root cause (System Thinking)114Don't stop at symptoms. For each problem ask "why" until you reach the cause (e.g. low ROAS → irrelevant115search terms → missing negatives → no weekly review process). Check interactions: campaigns competing for116the same queries, cross-brand budget leakage, over-reliance on paid vs organic. Project the cost of117inaction vs the savings from fixing.118119## STEP 7 — Action plan120Produce a dated plan in three buckets, each item with: what to do · exact UI/Editor steps · why (data) ·121status checkbox.122- **Today**: pause Dead campaigns, cut budget on Bronze, add negative lists, fund under-invested branded.123- **Tomorrow**: review yesterday; raise tROAS **≤0.2-0.3x** (only if ≥15 conv/week, else consolidate first);124 scale Gold (+budget gradually); plan consolidations.125- **2-week roadmap**: dated timeline, each action typed (urgent/optimize/plan).126Respect the tROAS step-up discipline and change-event cooldown throughout.127128## STEP 8 — Emit the change-set (the pusher hand-off)129Serialize the action plan into a **`change-set.json`** (contract: `${CLAUDE_PLUGIN_ROOT}/references/change-set.md`;130template: `${CLAUDE_PLUGIN_ROOT}/templates/change-set.json`) — a typed list of edits to the LIVE account131(`add_negatives`, `exclude_geo`, `exclude_products`, `adjust_budget`, `adjust_target_roas`, `pause`,132`add_extensions`, `add_audience_signal`, `exclude_placements`). This is distinct from a builder's133`campaign-spec.json` (which creates a NEW campaign).134- Every action carries a `reason` (the $ leak / numbers), its `diagnostic` D-code, `est_impact_per_mo`, and a135 `push_path`. Express a budget **reallocation** as a pair (a `down`/`pause` on the source + an `adjust_budget`136 `up` on the receiver) so each leg is gated on its own.137- **Encode the discipline in the data, don't rely on prose:** mark `target.within_cooldown: true` for any138 campaign with a recent `change_event` (D13) or under the `change-event-cooldown` guardrail, set139 `target.last_change_direction`, and pass `conv_per_week` on a tROAS raise. The pusher's validator BLOCKS a140 scale move on a within-cooldown target — so a campaign that "shouldn't scale yet" must NOT carry a do-now141 scale action (defer it to the prose roadmap, or omit it). PMax scales DOWN by **pausing** a low-ROAS asset142 group, never a budget cut.143- The optimizer **proposes** the change-set; it never writes. Hand it to `pusher` (or `/google-ads-push`),144 which validates (`validate_changeset.py`) and renders operator actions (`changeset_to_actions.py`) behind145 the approval gate.146147## Applying changes148Account mutations go through `pusher` (approval gate + spend cap). The optimizer prepares the change-set149(STEP 8); it does not write to the account directly.150151## To build / refine later152- [x] Runnable analyzers: `scripts/tiering.py` + `scripts/search_term_miner.py`. Done.153- [x] Emit changes as a structured change-set the pusher can consume (STEP 8 → `change-set.json`;154 validated/rendered by pusher's `validate_changeset.py` + `changeset_to_actions.py`). Done.