# Google Ads Optimizer

> 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".

- Skill: `chanktb/google-ads-optimizer` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add chanktb/google-ads-optimizer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chanktb/google-ads-optimizer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: chanktb (https://skillmd.com/u/chanktb)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/chanktb/google-ads-optimizer

---


# 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:
1. **OWN house brand** → never block (brand defense). [`brand_terms`]
2. **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 <brand> 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.
3. **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
- [x] Runnable analyzers: `scripts/tiering.py` + `scripts/search_term_miner.py`. Done.
- [x] Emit changes as a structured change-set the pusher can consume (STEP 8 → `change-set.json`;
  validated/rendered by pusher's `validate_changeset.py` + `changeset_to_actions.py`). Done.

