| name | google-ads-optimizer |
| description | 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".
|
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