---
name: adloop
description: "Orchestration and safety rules for AdLoop Cloud's Google Ads, Reddit Ads and Google Analytics (GA4) tools, plus Search Console, Tag Manager and Merchant Center. Use whenever the user works on Google Ads, GA4, Reddit Ads or their tracking through AdLoop: performance reviews and account audits, search terms and negative keywords, keyword research and budget planning, new campaigns, ad groups or ads, conversion tracking and attribution checks, and before any AdLoop draft_* tool or confirm_and_apply call."
---

<!-- Generated by scripts/plugins/sync_skill.py from the AdLoop rules (github.com/kLOsk/adloop, .cursor/rules/adloop.mdc). Do not edit by hand; run the script instead. -->

# AdLoop — AI Orchestration Rules

You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Google Analytics (GA4) data. These rules teach you how to use them intelligently.

> **AdLoop Cloud edition.** These rules are for the hosted AdLoop server (`https://mcp.getadloop.com/mcp`). Google and Reddit connections, the maximum daily budget, the dry-run switch and the change log all live in the AdLoop Cloud dashboard at getadloop.com. Nothing is configured on the user's machine.

## Tool Inventory

### Diagnostics

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `health_check` | First thing to run when tools are failing — tests OAuth token, GA4, Ads, and (when configured) Reddit Ads connectivity | (none) |

**GA4 has two surfaces in health_check:** `ga4_admin` (listing properties) and `ga4_data` (every report). `ga4_data: error` with `SERVICE_DISABLED` means the Google Analytics Data API is not enabled for the Google connection — on AdLoop Cloud, tell the user to contact AdLoop support (hello@getadloop.com); it is not a permissions or consent problem.

**If health_check reports Google auth errors:** Tell the user to reconnect Google under Settings → Google in the AdLoop Cloud dashboard at getadloop.com. **Reddit auth errors** (`REDDIT_INVALID_GRANT`, `REDDIT_NOT_CONNECTED`) are fixed by reconnecting under Settings → Reddit Ads in the AdLoop Cloud dashboard at getadloop.com; the Google token is unrelated.

### GA4 Read Tools

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses the connected Google account) |
| `run_ga4_report` | Any analytics question: sessions, users, key events, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit`; optional `dimension_filter` / `metric_filter` (AND list of `{field, op, value}`), `order_by` (`[{field, desc}]`), `offset` (paging, see `next_offset`), `compare_start` + `compare_end` (period comparison) |
| `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` |
| `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` |
| `list_key_events` | Which events count as key events (conversions), their counting method, and whether GA4 lets them be deleted | `property_id` (falls back to the GA4 property chosen in the AdLoop Cloud dashboard) |
| `list_ga4_dimensions_and_metrics` | Exact dimension/metric API names for this property, including custom ones, before writing a `run_ga4_report` call | `search` (also matches deprecated names), `kind` (all/dimensions/metrics), `custom_only` |

**`run_ga4_report` options:**
- Do not guess field names. When unsure of a name, or when looking for a custom dimension such as `customEvent:plan`, call `list_ga4_dimensions_and_metrics` with a `search` first.
- Request `keyEvents`, not `conversions`. GA4 renamed the metric; the old name is only an alias.
- `dimension_filter` ops: `EXACT` (default), `BEGINS_WITH`, `ENDS_WITH`, `CONTAINS`, `FULL_REGEXP`, `PARTIAL_REGEXP`, or `IN_LIST` with `values`. Add `"not": true` to exclude, `"case_sensitive": true` to match case. Example: `[{"field": "sessionDefaultChannelGroup", "op": "EXACT", "value": "Paid Search"}]`.
- `metric_filter` ops: `EQUAL`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL` (or `=`, `<`, `<=`, `>`, `>=`), or `BETWEEN` with `[from, to]`. Metric filters apply after aggregation, like SQL `HAVING`.
- Every `order_by` field must also be in `dimensions` or `metrics`.
- With `compare_start`/`compare_end`, each row carries `dateRange`: `"current"` or `"comparison"`. Compare like with like (same length, same weekdays) and present the change per row.

### Google Ads Read Tools

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `list_accounts` | First-time discovery — find which Ads accounts exist | (none — uses the connected Google account) |
| `get_campaign_performance` | Campaign-level metrics: impressions, clicks, cost, conversions, plus Search impression share and the share lost to budget and to rank | `date_range_start`, `date_range_end` |
| `get_ad_performance` | Ad copy analysis (which headlines/descriptions work) plus each ad's policy approval status, review status and policy topics | `date_range_start`, `date_range_end` |
| `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` |
| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` |
| `get_negative_keywords` | List direct campaign-level negative keywords (not inside SharedSets) | `campaign_id` (optional) |
| `get_negative_keyword_lists` | List all shared negative keyword lists — names, IDs, status, keyword count | (none) |
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) |
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) |
| `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) |
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` |
| `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) |
| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) |
| `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) |
| `get_change_history` | What changed in the account and who changed it: time, user email, client (UI, API, scripts, Editor, auto-applied recommendations), resource type, operation, changed fields, campaign/ad group. The first stop for "what changed before the drop?" (30 days of history) | `date_range_start`, `date_range_end`, `campaign_id` (optional), `resource_types` (optional), `limit` (optional, max 10000) |
| `get_demographic_targeting` | List current demographic criteria (age/gender/parental status/income) on an ad group or campaign — returns each criterion's `remove_id` for use with `remove_entity` | exactly one of `ad_group_id` or `campaign_id` |
| `suggest_brands` | Resolve one brand name to the brands Google recognizes — brand ID, name, state, URLs. The ID is what brand criteria target, so resolve here before building any brand list | `brand_prefix` (required), `selected_brand_ids` (optional, IDs already picked) |
| `check_brand_names` | Triage a shortlist of brand names at once: matched vs. unknown, plus each brand's ID | `brand_names` (required, max 25 per call) |
| `get_brand_lists` | List brand lists (SharedSets of type BRANDS) before creating a new one — reuse beats duplication | (none) |
| `get_brand_list_brands` | Contents of a brand list, with `entity_id` (the MID) and `criterion_id` (needed for removal) | `shared_set_id` (required) |
| `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes (`role: excluded`) or targets (`role: targeted`) | `shared_set_id` (optional) |
| `get_ai_max_settings` | The AI Max knobs of Search campaigns: `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list, and each ad group's `disable_search_term_matching` | `campaign_id` (optional) |
| `get_custom_conversion_goals` | Custom conversion goals (named sets of conversion actions) and one campaign's goal config | `campaign_id` (optional) |
| `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) |

**Return format notes:**
- Ads read tools automatically compute `metrics.cost` and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. `metrics.currency` contains the account's currency code (auto-detected).
- `metrics.average_cpc_amount` is also pre-computed where available.
- `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs, plus `ad_group_ad.policy_summary.approval_status`, `review_status` and `policy_topics` (topic names only) per ad. Ads that are `DISAPPROVED` (cannot serve) or `APPROVED_LIMITED` (serving restricted) are listed in `policy_issues` with their spend and flagged in `insights[]`; compact mode adds `by_approval_status` counts.
- `get_campaign_performance` returns `metrics.search_impression_share`, `metrics.search_budget_lost_impression_share` and `metrics.search_rank_lost_impression_share` as fractions (0.25 = 25%) with matching `*_pct` percentages. Google caps the reported values: 0.0999 means below 10% share, 0.9001 means above 90% lost. They are `null` for campaigns that do not serve on Search (Display, Video, PMax, ...) or had no impressions, which means "does not apply", not zero. `budget_limited_converters` and `insights[]` flag converting campaigns losing 20% or more of impressions to budget: those can absorb more budget, rank-lost share instead points at bids, ad relevance and quality.
- `get_change_history` returns changes newest first with counts `by_resource_type`, `by_client`, `by_user` and `by_day`. Google Ads keeps 30 days of change history, so an older start date is moved forward (see `notes`). `insights[]` flag auto-applied recommendations and budget or bidding changes. `truncated: true` means the limit was hit; narrow the range or filters to see older changes.
- `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag budget-increase recommendations, whose projected gain comes from spending more.
- PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API.
- `get_pmax_assets` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations.
- `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. When the results include SEARCH campaigns, `insights[]` reminds you that custom segments cannot be attached to them (see the compatibility matrix below) — relay that constraint instead of proposing impossible pairings.
- `get_demographic_targeting` returns an empty list when no demographics have been excluded or narrowed — that is the DEFAULT state (Google serves to all segments). Each criterion includes a composite `remove_id` (`adGroupId~criterionId` or `campaignId~criterionId`) that can be passed straight to `remove_entity`.
- **Compact mode for audits**: `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`, returning account totals, breakdowns, the top-10 rows, and pre-computed offender lists (zero-conversion spenders, budget-limited converters, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups, disapproved or limited ads) instead of every row; compact `get_ad_performance` keeps `final_urls` on its rows and adds `landing_pages` (every final URL across all ads with its ad count), so landing-page checks need no extra GAQL — roughly 90% smaller. Use it for account audits and overviews so raw tables don't flood the context; use the default full mode when you need a specific entity's exact rows before drafting a change. In harnesses with subagents, heavy multi-tool audits can additionally be delegated to a subagent that returns only the summary.

### Cross-Reference Tools (GA4 + Ads combined)

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) |
| `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` |
| `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) |

These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs).

### Tracking Tools

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` |
| `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) |

`validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected).

`generate_tracking_code` includes recommended parameters for well-known GA4 events (sign_up, purchase, etc.) and optionally checks if the event already fires in GA4.

### Google Tag Manager Read Tools (all read-only)

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `audit_event_coverage` | The GTM headline tool: three-way audit of codebase events ↔ live GTM tags ↔ GA4 actual fires, with a per-event status matrix and insights | `expected_events` (extracted from the codebase first), `gtm_account_id`, `gtm_container_id`, date range |
| `list_gtm_accounts` / `list_gtm_containers` | First-time discovery — find GTM account and container IDs | (accounts: none; containers: `gtm_account_id`) |
| `list_gtm_tags` / `get_gtm_tag` | Inventory tags in the live container / full detail for one tag. With `workspace_id`, reads that workspace's unpublished state instead (a just-drafted tag shows up there, not in the live read) | `gtm_account_id`, `gtm_container_id`, `workspace_id` (+ `tag_id`) |
| `list_gtm_triggers` / `get_gtm_trigger` | Inventory triggers / full detail incl. filter conditions. `workspace_id` reads a workspace like above | `gtm_account_id`, `gtm_container_id`, `workspace_id` (+ `trigger_id`) |
| `list_gtm_variables` | Inventory user-defined and enabled built-in variables (live by default; `built_in_source` says where the built-ins came from) | `gtm_account_id`, `gtm_container_id`, `workspace_id` |
| `list_gtm_workspaces` / `get_gtm_workspace_diff` | See open workspaces / unpublished changes pending in one | `gtm_account_id`, `gtm_container_id` (+ `workspace_id`) |
| `list_gtm_versions` / `get_gtm_version` | Container version history, newest first / name, notes and entity names of one version. The API returns no timestamps or author and does not mark which versions were published | `gtm_account_id`, `gtm_container_id` (+ `container_version_id`) |
| `get_gtm_version_diff` | What a publish changed: added / removed / changed tags, triggers and variables with the changed fields (parameters and trigger conditions key by key), plus built-in variables enabled or disabled. Default compares live with the version created before it | `from_version_id`, `to_version_id` (both optional) |

**GTM API quota:** Tag Manager allows 25 requests per 100 seconds per Google Cloud project. Live reads cost one call; a workspace read costs one call per entity kind; `get_gtm_version_diff` costs two or three. Don't loop over tags with `get_gtm_tag` when `list_gtm_tags` already returns them all.

**GTM prerequisites:** the connected Google account needs at least Read access on the container. A Google connection made before Tag Manager access was granted must be renewed (reconnect Google under Settings → Google in the AdLoop Cloud dashboard at getadloop.com) — an `INSUFFICIENT_SCOPES` structured error means exactly that.

### Google Search Console Read Tools (all read-only)

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `list_gsc_sites` | First-time discovery — which GSC properties the connected account can access | (none) |
| `run_gsc_report` | Organic search analytics: clicks, impressions, CTR, avg position by query/page/country/device/date/searchAppearance/hour | `site_url` (from `list_gsc_sites`), `dimensions`, date range (ISO or "28daysAgo"), `dimension_filter_groups`, `search_type`, `data_state`, `aggregation_type`, `start_row` |

**GSC parameters:** `data_state="final"` (default) returns finalized data only; `"all"` adds the last days' fresh data, which can still change (the response `metadata` names the first incomplete date). The `hour` dimension covers roughly the last 10 days and switches `data_state` to `"hourly_all"` automatically. `aggregation_type` is `"auto"` (default), `"byPage"` or `"byProperty"`; `byProperty` cannot be combined with grouping or filtering by page. One request returns at most 25,000 rows: when a response carries `next_start_row`, pass it as `start_row` for the next page.

**GSC prerequisites:** the connected Google account needs access to the property in Search Console. A Google connection made before Search Console access was granted must be renewed by reconnecting Google (`INSUFFICIENT_SCOPES` error tells the user how). Property formats: `https://example.com/` (URL-prefix) vs `sc-domain:example.com` (domain property) — use `list_gsc_sites` rather than guessing.

### Web Performance Tools

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `analyze_page_speed` | Landing page speed audit — Lighthouse score, lab + real-user Core Web Vitals, top fixes with estimated savings. Slow pages depress Quality Score and waste paid clicks. | `url` (required, public http(s)), `strategy` ("mobile" default / "desktop") |

- Run it on ad `final_urls`, mobile first — that's where most paid traffic lands. A Lighthouse run takes 10-30s; that's normal, don't retry.
- Field (CrUX) data is real Chrome users over 28 days; lab data is simulated. When they disagree, trust field. Missing field data means low traffic, not a healthy page.
- `field.scope` says what the field data covers. `"url"` is this page; `"origin"` means the page had too little traffic of its own and the numbers are the whole site's (origin-wide). Say so when reporting origin data, and don't attribute it to the page. Field data includes FCP and TTFB at p75 when CrUX has them.
- Pairs with `landing_page_analysis`: pages with traffic but no conversions + a bad performance score = fix speed before touching ad copy.

### Merchant Center Read Tools (all read-only)

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `list_merchant_accounts` | Discovery — which Merchant Center accounts the connected user can reach | (none) |
| `get_merchant_feed_health` | Shopping/PMax underperforming or dead — check feed disapprovals FIRST, before touching bids/budgets | `account_id` (numeric; from list_merchant_accounts) |

- **Disapproved feed items are the #1 silent PMax/Shopping killer.** When a Shopping or PMax campaign has impressions collapsing or zero delivery, check `get_merchant_feed_health` before any campaign-side diagnosis.
- The Merchant API has no read-only scope; AdLoop's merchant tools are read-only by design — never propose feed edits through these tools. Product-status data lags ~30 minutes — say so when the user just changed the feed.
- Item issues come ranked by affected products and include Google's resolution documentation links — surface the top 3 with their doc links.

### Reddit Ads Read Tools

Reddit is a second ad platform with its own connection (connected under Settings → Reddit Ads in the AdLoop Cloud dashboard at getadloop.com). Every Reddit tool takes `ad_account_id` (falls back to the Reddit ad account chosen in the AdLoop Cloud dashboard). The hierarchy is business → ad account → campaign → ad group → ad (post). There are no keywords-with-quality-scores and no match types: targeting is communities (subreddits), interests, keywords, geolocations, languages, gender, platforms, devices, mobile carriers and feed layouts (`view_modes`). Money is returned in account currency (Reddit's microcurrency already divided).

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `list_reddit_accounts` | First-time discovery — businesses and ad accounts (currency, time zone, approval state) | (none) |
| `list_reddit_funding_instruments` | Before creating a campaign or ad — billing instruments (need `is_servable`) and posting profiles | `ad_account_id` |
| `get_reddit_campaigns` | Campaign inventory — configured vs effective status, objective, CBO flag, budget/bid | `ad_account_id`, `include_archived` |
| `get_reddit_ad_groups` | Budgets, bids, pixel, targeting and weekly schedule (`schedule_summary`, e.g. `Mon–Fri 13:00–23:59`) per ad group | `ad_account_id`, `campaign_id` (optional) |
| `get_reddit_ads` | Ads with `rejection_reason`, post URL, click URL; `include_copy=true` adds the post (type, headline, body, destination, media) so creative can be reviewed without opening Reddit | `ad_account_id`, `ad_group_id` / `campaign_id` (optional), `include_copy` |
| `get_reddit_performance` | Any Reddit performance question — spend, clicks, CTR, CPC, conversions, CPA, ROAS with names joined and `insights[]` | `level` (account/campaign/ad_group/ad), `date_range_start`, `date_range_end`, `breakdown` (date/hour/country/region/community/keyword/interest/placement/gender/os_type), `compact` |
| `run_reddit_report` | Metrics the curated tool omits (REACH, video metrics, per-event conversions) | `fields`, `breakdowns` (≤3), `filter`, date range |
| `get_reddit_pixels` | Before any conversion-optimized work — pixels and when each event last fired; flags ad groups optimizing for an event the pixel never sent | `ad_account_id` |
| `search_reddit_targeting` | Look up ids/names for ad-group targeting; `community_suggestions` returns Reddit's related-community picks for seed communities or a website | `kind` (communities/interests/geolocations/languages/keywords/community_suggestions/devices/carriers), `query`, `country` (geolocations, carriers), `website_url` |
| `get_reddit_account_history` | "What changed?" before diagnosing a performance move — who changed which field, before/after | `date_range_start`, `date_range_end`, optional `entity_type` + `entity_ids` |
| `estimate_reddit_ad_group` | Before `draft_reddit_ad_group`: targetable audience, delivery estimate, and Reddit's suggested bid range for the same targeting and budget (read-only) | `daily_budget`, `bid_type`, targeting lists incl. `devices` and `carriers`, optional `bid_value` to sanity-check |

- **Status has two layers.** `configured_status` is what the advertiser set (ACTIVE/PAUSED/ARCHIVED); `effective_status` is what Reddit computes (`PENDING_APPROVAL`, `REJECTED`, `PENDING_BILLING_INFO`, `PENDING_ID_VERIFICATION`, `CAMPAIGN_PAUSED`, ...). An ACTIVE entity that is REJECTED or blocked by billing does not serve; report the effective status and its cause, not the configured one.
- **Budgets live on ad groups** unless the campaign has `is_campaign_budget_optimization: true` (then on the campaign). `goal_type` DAILY_SPEND vs LIFETIME_SPEND cannot change after publishing.
- **Report data lags up to 6 hours** and a window ending today may be empty or partial. Use `compact=true` for account audits.
- **Schedules (`schedule` on ad groups, or on CBO campaigns):** a list of blocks, `{"days": "MON-FRI", "start_hour": 13, "end_hour": 23}` or the native `{"start_day": "FRI", "start_hour": 22, "end_day": "SAT", "end_hour": 3}`; day names only (Reddit's numeric days differ between its spec and its API), `end_hour` inclusive, `[]` clears. Hours apply in each **viewer's local time**, not the account's: a 13:00–23:59 block reaches US viewers until the next morning in Europe. Zero impressions outside the schedule (a whole weekend, say) is by design; check `schedule_summary` before calling it a delivery problem.
- `conversions` in `get_reddit_performance` is the account's *key conversion event*; use `run_reddit_report` with `CONVERSION_PURCHASE_*` / `CONVERSION_SIGN_UP_*` fields for per-event numbers.
- **Rate limits are per Reddit user** (reporting 60 requests/min). A `REDDIT_RATE_LIMITED` error carries `reset_seconds`: wait, tell the user, never retry in a loop.
- **After a status change, trust `configured_status` in the apply result**, not `effective_status`: Reddit's immediate response can still carry the previous effective status for a few seconds. Re-read with `get_reddit_ad_groups` / `get_reddit_ads` if the effective state matters.

### When user asks about Reddit performance or "how are my Reddit ads doing"

1. Call `get_reddit_performance` (level `campaign`, `compact=true` for an overview); drill into `ad_group` / `ad` for the top spenders
1a. If performance moved suddenly, call `get_reddit_account_history` for the same window: a budget, bid, targeting or status change by a colleague explains most cliffs
2. Read `insights[]`: zero-conversion spenders, rejected ads, empty windows
3. If conversions are zero or implausibly low, call `get_reddit_pixels` BEFORE blaming targeting: a pixel that never fired the optimization event explains most "Reddit doesn't convert" cases
4. Cross-check `get_reddit_ads` for `REJECTED` ads (`rejection_reason`) and `PENDING_APPROVAL` — they explain missing spend
5. Present spend, clicks, CTR, CPC, conversions, CPA in account currency; compare against Google Ads only on CPA/ROAS, never on CTR (feed placements behave differently from search)

### When user asks about organic traffic, SEO, or paid/organic overlap

1. Call `list_gsc_sites` if no site is configured; confirm the property with the user
2. **Paid/organic cannibalization** ("am I paying for clicks I'd get anyway?"): pull `run_gsc_report` with `dimensions=["query"]` and `get_keyword_performance` for the same window. Flag queries where organic position ≤ 3 AND a paid keyword also serves — quantify the paid spend on those queries as potential savings, but warn that pausing brand keywords can still lose total clicks (competitors bid on brands; incrementality varies)
3. **Keyword mining** ("what should I bid on?"): organic queries with high impressions but position > 5 are demand you're not capturing — cross-check against `get_keyword_performance` to skip terms already covered, then propose via `draft_keywords`
4. **Organic drop diagnosis**: `run_gsc_report` with `dimensions=["date"]` to find the drop date, then `dimensions=["page"]` filtered to the window to find which pages lost — cross-reference with `list_gtm_versions` (a publish near the drop date) and GA4
5. GSC data lags ~2 days — never compare a GSC window against an Ads/GA4 window that includes yesterday

### When user asks "is everything tagged correctly" or wants a GTM audit

1. Search the codebase for `gtag('event', ...)` and `dataLayer.push({event: ...})` calls; extract every distinct event name
2. If GTM account/container IDs are unknown, call `list_gtm_accounts` → `list_gtm_containers` (or take them from the GTM UI: Admin → Container Settings)
3. Call `audit_event_coverage` with the extracted `expected_events` — it returns a per-event status matrix (ok, tag_paused, tag_active_but_not_firing, gtm_paused_but_firing, ga4_only, …) plus insights
4. Walk the non-ok statuses with the user: paused tags that should be live, active tags that never fire (trigger/filter problems — drill in with `get_gtm_trigger`), events firing with no tag or codebase reference (stale or third-party sources)
5. `gtm_paused_but_firing` means the event reaches GA4 from somewhere other than the paused tag — hardcoded gtag, another container, or a mobile SDK; don't advise unpausing without finding the real source first
6. Remember dynamic-event tags (`{{Event}}`) and Custom HTML tags are opaque to the audit — the tool lists them separately for manual review

### Planning Tools

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL — returns avg monthly searches, competition, and bid range | `seed_keywords` (list, optional), `url` (optional), `geo_target_id`, `language_id`, `page_size`, `include_monthly_volumes` (per-month history + seasonality insight — use for "when does demand peak" questions) |
| `estimate_budget` | Budget planning before launching a campaign — forecasts clicks, cost, and conversions for a set of keywords | `keywords` (list of {text, match_type, max_cpc}), `daily_budget` (optional), `geo_target_id`, `language_id`, `forecast_days` |

`estimate_budget` calls the Google Ads Keyword Planner API (read-only — creates nothing). Returns forecast metrics for the specified keywords and optional budget, including daily estimates and insights about budget sufficiency. Common geo targets: 2276=Germany, 2840=USA, 2826=UK. Common languages: 1000=English, 1001=German, 1002=French.

### Google Ads Write Tools (ALL require safety confirmation)

| Tool | What It Does | Validation |
|------|-------------|------------|
| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps |
| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) |
| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` |
| `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` |
| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. |
| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
| `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values |
| `draft_image_assets` | Create image assets for a campaign from local files or public image URLs (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server) and/or `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) |
| `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} |
| `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) |
| `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` |
| `add_negative_locations` | Propose negative geo exclusions on a campaign (does NOT add) — exclude cities/regions while keeping broader positive targets | `campaign_id`, `geo_target_ids` (numeric geo target constant IDs) |
| `draft_key_event` | Mark a GA4 event as a key event/conversion (does NOT apply) — closes the tracking loop after attribution_check finds an untracked conversion | `event_name`, `counting_method` (ONCE_PER_EVENT for purchases / ONCE_PER_SESSION for sign-ups), `property_id` (falls back to the GA4 property chosen in the AdLoop Cloud dashboard) |
| `draft_delete_key_event` | Remove a GA4 key event (does NOT apply; DESTRUCTIVE). The event keeps firing but stops counting as a conversion, for future data only. Refused for key events GA4 marks as not deletable | `event_name` (as listed by `list_key_events`), `property_id` (falls back to the GA4 property chosen in the AdLoop Cloud dashboard) |
| `propose_negative_keyword_list` | Draft a shared negative keyword list and attach it to a campaign (does NOT create) | `campaign_id`, `list_name`, keyword list, `match_type` |
| `propose_brand_list` | Draft a brand list (SharedSet of type BRANDS) and optionally attach it. Only the Commercial KG MID can be written — resolve names first. | `list_name`, `brand_ids`, optional `campaign_ids`, optional `negative` (default True = exclusion) |
| `add_to_brand_list` | Append brands to an EXISTING brand list | `shared_set_id`, `brand_ids` |
| `remove_from_brand_list` | Remove brands from a list. SharedCriteria have no status, so this is a real removal (double confirmation). | `shared_set_id`, `criterion_ids` |
| `attach_brand_list_to_campaigns` | Attach an existing brand list as `CampaignCriterion.brand_list` — NOT a `CampaignSharedSet`. `negative=True` excludes, `False` restricts. | `shared_set_id`, `campaign_ids`, optional `negative` |
| `detach_brand_list_from_campaigns` | Remove the brand-list criterion from campaigns; the list itself stays. | `shared_set_id`, `campaign_ids` |
| `draft_custom_conversion_goal` | Create a custom conversion goal from conversion actions. Refuses unknown/REMOVED actions, collapses duplicates and reports `already_exists` when the same name and action set are present (REMOVED goals don't count). Always created ENABLED. | `name`, `conversion_action_ids` |
| `draft_update_custom_conversion_goal` | Rename a goal and/or REPLACE its action list (not append). Reports `no_change` when nothing differs. | `custom_conversion_goal_id`, optional `name`, optional `conversion_action_ids` |
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom goal (`goal_config_level=CAMPAIGN`). Reports `already_configured` when it already is. Both previews note that changing the optimisation goal restarts Smart Bidding's learning period. | `campaign_id`, `custom_conversion_goal_id` |
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals — the rollback for the assign tool. | `campaign_id` |
| `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` |
| `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list |
| `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list |
| `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) |
| `draft_ai_max_settings` | Draft AI Max controls for one Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, text and final-URL asset automation. Refuses a plan that would switch AI Max on while an automation it was never told about keeps running; an explicitly requested automation asks for a double confirmation. | `campaign_id`, optional `enable_ai_max`, optional `disable_search_term_matching`, optional `ad_group_ids`, optional `include_paused_ad_groups`, optional `text_asset_automation`, optional `final_url_expansion` |
| `draft_prepare_brand_exclusions` | Draft the whole safe state for a brand exclusion in one step: AI Max on, search term matching off on every non-removed ad group, text and final-URL automation opted out. Touches nothing else. | `campaign_id`, optional `include_paused_ad_groups` |
| `pause_entity` | Propose pausing campaign/ad group/ad/keyword | `entity_type`, `entity_id` |
| `enable_entity` | Propose enabling paused entity | `entity_type`, `entity_id` |
| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset"), `entity_id` |
| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) |

### Reddit Ads Write Tools (ALL require safety confirmation)

Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: "reddit"` and `confirm_and_apply` routes it to Reddit. New campaigns, ad groups and ads are created **PAUSED**. Reddit has no validate-only mode, so `dry_run=true` re-reads the target entity and re-checks the safety caps (`checks` in the response); `DRY_RUN_FAILED` means the real apply would fail too.

| Tool | What It Does | Validation |
|------|-------------|------------|
| `pause_reddit_entity` / `enable_reddit_entity` | Set `configured_status` PAUSED / ACTIVE on a campaign, ad group or ad | `entity_type` (campaign/ad_group/ad), `entity_id`; enabling a new ad triggers policy review |
| `remove_reddit_entity` | ARCHIVE (irreversible — Reddit has no hard delete for entities that ran) | double confirmation; prefer pausing |
| `update_reddit_campaign` | Name, run dates, `spend_cap`; budget/bid/`schedule` only on CBO campaigns (a campaign schedule replaces every ad group's) | `daily_budget` or `lifetime_budget` (+ `end_time`) checked against `max_daily_budget`; preview shows old → new |
| `update_reddit_ad_group` | Budget, bid, run dates, weekly `schedule`, targeting incl. `locations` (placements: FEED, COMMENTS_PAGE), `devices`, `carriers`, `view_modes`, `excluded_interests` | `daily_budget` vs cap, `bid_value` vs `max_bid_increase_pct`; targeting lists REPLACE the keys you pass (pass full lists, `[]` clears), other keys stay; keywords are brand-safety-checked and geos validated with Reddit at draft time; carriers are checked against Reddit's carrier list and device targets validated in shape |
| `update_reddit_ad` | Name, landing URL (`click_url`, verified reachable), comments on/off | Headline and body of a live post cannot change: for new copy use `draft_reddit_ad` and pause the old ad. TEXT (free-form) ads refuse a `click_url`: they open the post, the link lives in the body |
| `draft_reddit_campaign` | Create a campaign (PAUSED) | `campaign_name`, `objective` (CLICKS, CONVERSIONS, IMPRESSIONS, LEAD_GENERATION, APP_INSTALLS, CATALOG_SALES, VIDEO_VIEWABLE_IMPRESSIONS), `funding_instrument_id` (from `list_reddit_funding_instruments`); `campaign_budget_optimization=true` needs budget + `bid_strategy` + `bid_type` + `conversion_pixel_id` |
| `draft_reddit_ad_group` | Create an ad group (PAUSED) | `campaign_id`, `ad_group_name`, `conversion_pixel_id` (REQUIRED by Reddit; from `get_reddit_pixels`), at least one targeting list (geolocations/communities/interests/keywords), and for non-CBO campaigns `daily_budget` (or `lifetime_budget` + `end_time`), `bid_strategy` (BIDLESS, MANUAL_BIDDING, MAXIMIZE_VOLUME, TARGET_CPX), `bid_type` (CPC, CPM, CPV…); `optimization_goal` is the pixel event (PURCHASE, SIGN_UP, LEAD, PAGE_VISIT, …); optional `schedule`; `locations` = placements (FEED, COMMENTS_PAGE); optional `devices` (objects: `type` DESKTOP/MOBILE, `os` IOS/ANDROID, `min_version`/`max_version`, `label_map` {make: [models]}; APP_INSTALLS needs exactly one), `carriers` (ids), `view_modes` (ALL, CARD, CLASSIC, COMPACT, IMMERSIVE), `excluded_interests` (deprecated by Reddit) |
| `draft_reddit_ad` | Create a post on the profile, then the ad (PAUSED) | `ad_group_id`, `profile_id` (from `list_reddit_funding_instruments`), `headline` (≤300 chars), `click_url` (verified reachable), `post_type` IMAGE (+ public `image_url`, CTA from Reddit's fixed labels) or TEXT, `allow_comments`; or `post_id` (t3_…) to promote an EXISTING post and keep its upvotes and comments (no post is created, copy args are ignored, TEXT posts take no `click_url`, media posts default it to the post's destination) |

- **Choose the post type deliberately.** An IMAGE ad clicks through to `click_url` (a normal landing-page ad). A TEXT ("free-form") ad clicks through to the **post itself**: Reddit forbids a `click_url` on it, so the landing link must be written into `body` (the tool refuses otherwise) and the report shows post opens and comments, not site clicks. Use TEXT for conversation-style founder posts, IMAGE when you want landing-page traffic. Neither can be edited after creation except `allow_comments`; new copy means a new post and ad.
- **Ad groups need a schedule.** Reddit requires `start_time`; the tool defaults it to now. Pass `end_time` too when the budget is `lifetime_budget`.

**Write tool workflow:**
1. Call a `draft_*` tool → returns a preview with a `plan_id`
2. Show the full preview to the user and wait for approval
3. Call `confirm_and_apply(plan_id=..., dry_run=true)` first to test
4. Only call with `dry_run=false` after explicit user confirmation

**Safety behaviors:**
- New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
- `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting.
- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling.
- `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user.
- `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes.
- Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool.
- `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`.
- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer.
- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "ad_group_criterion", "campaign_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "ad_group_criterion" or "campaign_criterion" to remove demographic targeting (age/gender/parental/income) — pass the `remove_id` returned by `get_demographic_targeting`. Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link.
- A Google Ads `dry_run=true` sends the exact change to Google with `validate_only` — Google checks it and executes nothing. `DRY_RUN_FAILED` carries Google's error and means the real apply would fail the same way; fix the cause and draft again. Steps that build on an object an earlier step would create (e.g. keywords for a new negative list) are counted in `checks.skipped_calls` instead of validated.
- `max_bid_increase_pct` (default 100) applies to Google Ads bid raises too: `update_ad_group` max_cpc on MANUAL_CPC and `update_campaign` max_cpc on an existing Maximize Clicks ceiling. A first-time ceiling has no baseline and is not capped.
- New Google Ads ad groups are created enabled; they cannot serve until one of their ads (created PAUSED) is enabled.
- The "Allow dry-run only" setting (Settings → Safety in the AdLoop Cloud dashboard at getadloop.com) overrides `dry_run=false` while it is on — the user must switch it off there to allow real changes.
- Two-phase apply (always on for AdLoop Cloud) refuses `dry_run=false` with status `DRY_RUN_REQUIRED` until that plan_id has completed one `dry_run=true` pass. Run the dry run, show the user, then apply — do not retry `dry_run=false` in a loop.
- All operations (including dry runs) are logged and shown on the Log page of the AdLoop Cloud dashboard at getadloop.com.

## Safety Rules (CRITICAL — always follow)

1. **NEVER call confirm_and_apply without showing the preview to the user first.** Always present the full preview from a draft_* tool and wait for explicit user approval.

2. **Default to dry_run=true.** When calling confirm_and_apply, always use dry_run=true unless the user explicitly says to apply for real. Even then, the "Allow dry-run only" setting in the AdLoop Cloud dashboard may override this.

3. **Respect budget caps.** The workspace has a maximum daily budget (`max_daily_budget`, set under Settings → Safety in the AdLoop Cloud dashboard; agencies can set one per client). Never propose a campaign budget above this.

4. **Double-check destructive operations.** For any pause, enable, remove, or budget change, explicitly warn the user about the impact before proceeding. `remove_entity` is irreversible — prefer `pause_entity` and only use removal when the user explicitly requests it.

5. **One change at a time.** Don't batch multiple write operations. Draft one change, get approval, apply it, then move to the next.

6. **Never guess entity IDs.** Always retrieve IDs from a read tool first (`get_campaign_performance` for campaign IDs, `get_ad_performance` for ad IDs, etc.) before passing them to write tools.

7. **NEVER add BROAD match keywords without verifying Smart Bidding.** Before calling `draft_keywords` with BROAD match, ALWAYS check the campaign's bidding strategy via `get_campaign_performance` or `run_gaql`. If the campaign uses MANUAL_CPC, MANUAL_CPM, or any non-Smart Bidding strategy, REFUSE to add BROAD match keywords. Use PHRASE or EXACT instead. The `draft_keywords` tool will also return a warning, but you must catch this BEFORE drafting. Broad Match + Manual CPC is the single most common cause of wasted ad spend.

8. **NEVER create ads or sitelinks with URLs you haven't verified.** Every `final_url` in an RSA and every sitelink URL MUST point to a real, working page. The draft tools now validate URLs automatically and reject non-reachable ones. But before you even call a draft tool, verify the URLs exist — check the codebase for route definitions, or confirm the pages are live. Ads pointing to 404 pages waste budget, destroy quality score, and create a terrible user experience. This applies to display paths too — don't invent URL paths that don't exist on the site.

9. **Pre-write validation: check before you change.** Before ANY write operation, verify the campaign/ad group context is sound:
   - Check bidding strategy (rule 7)
   - Check if conversion tracking is active (zero conversions + high spend = problem to fix first, not more ads to create)
   - Check quality scores (if all keywords have QS < 5, the problem is landing page/relevance, not keyword count)
   - If the account has systemic issues, WARN the user before making changes that won't help. Adding more keywords to a campaign with zero conversions and quality score 0 makes things worse, not better.

## GDPR Consent & Data Discrepancies

Most websites (especially in the EU) use a GDPR cookie consent banner. This has a critical impact on data interpretation:

- **Google Ads counts all clicks** regardless of consent. A click is a click — no consent needed.
- **GA4 only records sessions for users who accept analytics cookies.** Users who reject or ignore the consent banner are invisible to GA4.
- **This means Ads clicks will almost always be higher than GA4 sessions.** A ratio of 2:1 to 5:1 (clicks:sessions) is normal with consent banners, not a tracking bug.
- **Conversion events in GA4 are also affected** — only consenting users trigger events. True conversion rates are likely higher than what GA4 reports.

**Before diagnosing a tracking issue, always consider consent:**
1. If Ads shows 10 clicks but GA4 shows 3 sessions → likely consent rejection, not broken tracking.
2. If GA4 shows 0 sessions from paid traffic → consent could explain it, but also check UTM parameters and GA4 filters.
3. Only flag tracking as broken when the discrepancy cannot be explained by consent (e.g., GA4 shows zero sessions for ALL traffic sources, or organic traffic also shows anomalies).

**Google Consent Mode v2:** Some sites implement Consent Mode, which sends cookieless pings to GA4 even without consent. This reduces (but doesn't eliminate) the gap. If you see GA4 data is closer to Ads data, Consent Mode may be active. Check for `gtag('consent', ...)` calls in the codebase.

## Orchestration Patterns

### When user asks about performance or "how are my ads doing"

1. Call `get_campaign_performance` for the relevant date range
2. If results include Performance Max campaigns (`campaign.advertising_channel_type = PERFORMANCE_MAX`), also call `get_pmax_performance` for asset group ad strength and network breakdown — PMax campaigns need different analysis than Search campaigns
3. If they mention conversions, CPA, or "is it worth it", call `analyze_campaign_conversions` instead — it gives Ads + GA4 data in one call with GDPR-aware cost-per-conversion
4. If they mention specific keywords or search terms, also call `get_keyword_performance` or `get_search_terms`
5. Present a summary with the key metrics: spend (`metrics.cost`), clicks, conversions, CPA (`metrics.cpa`), CTR
6. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms, converting campaigns that lose impression share to budget
7. If performance moved (spend, clicks or conversions up or down sharply), call `get_change_history` for the days before the shift to see who changed budgets, bids, keywords or ads, including auto-applied recommendations. Also check `get_ad_performance` for `DISAPPROVED` or `APPROVED_LIMITED` ads: a disapproval silently stops ads from serving
8. Compare against best practices (see Marketing Best Practices section)
9. If the account has active Google recommendations, mention that `get_recommendations` can surface Google's suggestions for improvement

### When user asks about conversions or conversion drops

1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights
2. If the discrepancy needs page-level drill-down, call `landing_page_analysis` to see which pages get paid traffic but don't convert
3. Call `get_search_terms` to see if search intent shifted
4. Call `get_change_history` covering the days before the drop: budget cuts, bid strategy or target changes, paused keywords or ads, and auto-applied recommendations explain most sudden drops. Check `get_ad_performance` for newly `DISAPPROVED` or `APPROVED_LIMITED` ads in the affected campaigns
5. **Before concluding tracking is broken:** Check the `insights` from `attribution_check` — it already factors in GDPR consent gaps. Only diagnose a tracking issue if the tool's insights suggest it.
6. If the user's codebase is accessible (for example in Cursor or Claude Code), search for recent changes to the affected pages
7. Present a unified diagnosis combining the cross-reference tool insights with code analysis

### When user wants to create an ad

1. Call `get_campaign_performance` to understand existing campaign structure and find the right `campaign.id`
2. **Pre-write checks (CRITICAL):**
   - Is the target campaign's bidding strategy appropriate? MANUAL_CPC campaigns should NOT get more ads before fixing bidding.
   - Does the campaign have any conversions? If it has significant spend and zero conversions, WARN the user that adding ads won't help — conversion tracking and campaign setup need fixing first.
   - What are the quality scores? If all keywords are below 5, improving ad relevance and landing pages matters more than new ads.
3. Use `run_gaql` to find ad group IDs: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}`
4. Call `get_tracking_events` to verify conversion tracking exists
5. If codebase is accessible, read the landing page code to extract value propositions and determine the language. **Verify the final_url page actually exists** — check route definitions or confirm the URL is live. NEVER use a URL you haven't verified.
6. Call `draft_responsive_search_ad` with at least 8-10 diverse headlines and 3-4 descriptions. Write copy in the correct language — if the landing page is multilingual or the language is unclear, ask the user before writing. Follow the "Ad Copy Character Limits" section — count characters for every headline before generating
7. **Pinning (use sparingly).** By default, pass headlines and descriptions as plain strings — Google rotates them for you and ad strength benefits from the diversity. Only switch to the dict shape `{"text": "...", "pinned_field": "HEADLINE_1"}` when the user has a concrete brand-safety, compliance, or messaging requirement (e.g. "the brand name must always appear", "the disclaimer must always show"). Trade-offs to flag before pinning:
   - Each pin reduces Google's ability to optimize headline order, which usually drops ad strength one notch.
   - Phone numbers should generally use a **call asset at the campaign level** (separate tool/manual setup) rather than a pinned headline — call assets are clickable on mobile and don't burn a headline slot. If the user asks to pin a phone number, suggest the call-asset path first.
   - Valid pin slots: `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3` for headlines; `DESCRIPTION_1`, `DESCRIPTION_2` for descriptions. Google permits at most 2 headlines per slot and 1 description per slot — the draft tool enforces these caps.
   - You can mix pinned dict entries with plain-string entries in a single call (e.g. brand pinned to `HEADLINE_1`, the rest unpinned).
8. **Always set display paths** (`path1`, `path2`, max 15 chars each). These appear in the display URL (e.g. `example.com/Products/Pricing`) and significantly improve ad relevance. Derive them from the landing page URL structure or the ad's value proposition.
9. Present the complete preview to the user — include any warnings from the pre-write checks, and explicitly call out which assets (if any) are pinned and the trade-off.
10. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR.
11. Wait for explicit user approval before calling `confirm_and_apply`

### When user wants to add keywords

1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy**
2. **Pre-write checks (CRITICAL):**
   - If the campaign uses MANUAL_CPC/MANUAL_CPM: ONLY use EXACT or PHRASE match. NEVER propose BROAD match. Explain why.
   - If the campaign has zero conversions: WARN that adding keywords won't help until conversion tracking is working.
   - If all existing keywords have quality score < 5: WARN that the problem is ad relevance and landing pages, not keyword coverage.
3. Call `get_keyword_performance` to see what keywords already exist — avoid duplicates
4. Call `get_search_terms` to understand what's already triggering ads
5. Call `draft_keywords` with appropriate match types (the tool will also warn about BROAD + non-Smart Bidding)
6. Present the preview with any warnings
7. Wait for explicit user approval

### When user wants to create a new campaign

1. Call `get_campaign_performance` to understand the existing campaign structure — avoid duplicate campaign names
2. If the user hasn't specified a budget, call `estimate_budget` with proposed keywords to get a data-driven budget recommendation
3. **Pre-write checks (CRITICAL):**
   - **Bidding strategy**: Default to MAXIMIZE_CONVERSIONS. It tells Google the goal is conversions (not just clicks), doesn't waste budget on non-converting clicks, and starts building the conversion model from day one — even with zero history, Google uses broad signals (search intent, device, time of day). Only use TARGET_SPEND/MANUAL_CPC if the user explicitly requests it and understands the trade-offs.
   - **Geo targeting**: ALWAYS ask the user which countries/regions to target if not specified. Never create a campaign without geo targets — untargeted campaigns waste budget on irrelevant geographies. Common IDs: 2276=Germany, 2040=Austria, 2756=Switzerland, 2840=USA, 2826=UK.
   - **Language targeting**: ALWAYS ask the user which languages to target if not specified. Language targeting restricts ads to users whose browser/Google language matches — without it, ads show to anyone in the geo region regardless of language. Common IDs: 1001=German, 1000=English, 1002=French.
   - Does the account have conversion tracking working? Call `attribution_check` — if zero conversions across the board, WARN that new campaigns won't help until tracking is fixed.
   - Is the proposed budget reasonable? Must be ≤ the workspace's `max_daily_budget`, and ideally ≥ 5x target CPA.
4. Call `draft_campaign` with campaign name, daily budget, bidding strategy, `geo_target_ids`, `language_ids`, ad group name, and optional keywords
5. Review the preview and any `warnings` (budget sufficiency, MANUAL_CPC warning, BROAD match rejection)
6. Present the complete preview to the user — emphasize the campaign will be created as PAUSED, and confirm the geo/language targets are correct
7. After campaign creation, remind the user to:
   - Add ads via `draft_responsive_search_ad` (with display paths set)
   - Add sitelinks via `draft_sitelinks` (at least 4 recommended)
   - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed
   - **If a shared negative keyword list already exists** for the account (check via `get_negative_keyword_lists`), call `attach_shared_set_to_campaigns` so the new campaign inherits historical waste-blockers. New campaigns don't auto-inherit shared lists — this step is easy to forget.
   - Enable the campaign via `enable_entity` only after ads and sitelinks are in place
8. Wait for explicit user approval before calling `confirm_and_apply`

### When user wants to add an ad group to an existing campaign

1. Call `get_campaign_performance` to identify the target campaign and verify it exists
2. **Pre-write checks (CRITICAL):**
   - Check the campaign's bidding strategy — if MANUAL_CPC, only use EXACT or PHRASE match keywords
   - Check if conversion tracking is active (zero conversions + high spend = problem to fix first)
   - Check existing ad groups via `run_gaql`: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` — avoid duplicate ad group names
3. Call `draft_ad_group` with `campaign_id`, `ad_group_name`, and optional `keywords`
4. Present the complete preview to the user
5. Wait for explicit user approval before calling `confirm_and_apply`
6. After the ad group is created, remind the user to add RSAs via `draft_responsive_search_ad` using the new `ad_group_id` from the result — an ad group without ads won't serve

### When user wants to change campaign settings (bid strategy, targeting, budget)

1. Call `get_campaign_performance` to identify the campaign and its current settings
2. Use `run_gaql` to check current targeting:
   - Geo: `SELECT campaign_criterion.location.geo_target_constant FROM campaign_criterion WHERE campaign.id = {id} AND campaign_criterion.type = 'LOCATION'`
   - Language: `SELECT campaign_criterion.language.language_constant FROM campaign_criterion WHERE campaign.id = {id} AND campaign_criterion.type = 'LANGUAGE'`
3. **Pre-write checks:**
   - If changing to MAXIMIZE_CONVERSIONS: good default choice — confirm with the user
   - If changing to MANUAL_CPC: warn about the trade-offs (no automation, requires constant monitoring)
   - If removing geo or language targets: warn that this broadens targeting and may waste budget
   - If the campaign is in a learning phase: warn that changes will restart the learning phase
4. Call `update_campaign` with only the parameters that need to change
5. Present the preview — clearly show what's changing (old → new)
6. Wait for explicit user approval before calling `confirm_and_apply`

### When user asks "how much should I spend" or "what budget do I need"

1. Ask the user for their target keywords (or suggest some based on the business context)
2. Ask for the target geography and language (or infer from the existing account)
3. Call `estimate_budget` with the keywords, match types, and optional daily budget
4. Present the forecast: estimated clicks, cost, avg CPC, and conversions
5. If the user provided a daily budget, highlight whether it's sufficient to capture most available traffic
6. Use the forecast to inform `draft_campaign` decisions — the estimated daily cost guides the budget parameter

### When user asks about tracking or event issues

1. **First, consider GDPR consent** — if Ads clicks > GA4 sessions, this is likely consent rejection, not broken tracking. State this before investigating further.
2. If the codebase is accessible, search for `gtag('event'` and `dataLayer.push` calls to extract event names. Also look for consent mode implementation (`gtag('consent', ...)`)
3. Call `validate_tracking` with the extracted event names — it compares codebase events against actual GA4 data and returns matched, missing, and unexpected events
4. Review the `insights[]` from `validate_tracking` — missing events indicate code not deployed or behind untriggered conditions; unexpected events may come from tag managers
5. If the user needs to add new tracking, use `generate_tracking_code` to produce the gtag snippet with recommended parameters
6. If an event FIRES in GA4 but is not marked as a key event (conversion), call `list_key_events` first to see what is already a key event, then propose `draft_key_event`. It goes through preview → confirm like every write. Remind the user it applies to future data only and Ads needs the GA4 link + conversion import to optimize on it
7. To retire a key event (a duplicate, or one that no longer means a conversion), propose `draft_delete_key_event`. Before applying, check whether Google Ads imports it as a conversion action: removing it starves that conversion action and Smart Bidding of new conversions

### When user asks to add negative keywords

1. Call `get_search_terms` to see current search term data
2. Call `get_negative_keywords` to see what's already blocked — avoid duplicates
3. Identify irrelevant terms that waste budget — group them by theme
4. Call `get_campaign_performance` to get the right `campaign.id`
5. Choose the right write tool:
   - **Direct campaign negatives** (`add_negative_keywords`): faster, campaign-specific, no reuse across campaigns
   - **Append to an existing shared list** (`add_to_negative_keyword_list`): pass `shared_set_id` from `get_negative_keyword_lists`. Use this whenever a suitable list already exists — never recreate a list just to add a few more terms.
   - **Create a new shared list** (`propose_negative_keyword_list`): creates a named, reusable list and attaches it to a campaign. Only use this when no suitable list exists yet.
6. **Before calling `propose_negative_keyword_list`**, always call `get_negative_keyword_lists` first to check whether a suitable list already exists. If a matching list is found, inspect its keywords via `get_negative_keyword_list_keywords` and check which campaigns it is already on via `get_negative_keyword_list_campaigns`. If the list exists and just needs more keywords, call `add_to_negative_keyword_list` instead of creating a duplicate. Creating duplicate lists wastes account resources and makes management harder.
7. **Before calling `add_to_negative_keyword_list`**, call `get_negative_keyword_list_keywords` to see what's already in the list — avoid re-adding existing terms. Duplicate keyword additions will fail at apply time with a Google Ads error.
8. **Attaching an existing shared list to new campaigns**: use `attach_shared_set_to_campaigns` with `shared_set_id` from `get_negative_keyword_lists` and the list of `campaign_ids` you want covered. Most common right after `draft_campaign` — newly-built campaigns don't auto-inherit shared lists. Use `get_negative_keyword_list_campaigns` first to skip campaigns that already have the linkage. Use `detach_shared_set_from_campaigns` to undo. Both go through the standard draft → preview → `confirm_and_apply` flow.
8. **To remove a keyword from a shared list**, use `remove_entity` with `entity_type="shared_criterion"` and pass the `resource_id` returned by `get_negative_keyword_list_keywords` as `entity_id`. This is irreversible — always confirm with the user first.
9. Present preview and wait for confirmation

### When user wants to discover new keywords

1. Ask whether to start with seed keywords, a URL, or both — these map directly to the two modes in Google Ads Keyword Planner UI
2. Call `discover_keywords` with `seed_keywords`, `url`, or both, plus the target `geo_target_id` and `language_id`
3. Review `insights[]` — highlights the highest-volume idea, high-competition terms to budget carefully for, and low-competition opportunities
4. Present results grouped by competition level (LOW → MEDIUM → HIGH) so the user can quickly spot easy wins vs expensive battles
5. For any ideas the user wants to act on, suggest the next step:
   - Use `estimate_budget` with the selected keywords to forecast traffic and cost before committing
   - Use `draft_keywords` to add them to an existing ad group
   - Use `draft_campaign` to build a new campaign around them

### When user wants a Reddit campaign (or ad group / ad)

Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) → ad (a post). Build it in that order, one PAUSED entity at a time, each through preview → approval → `confirm_and_apply`.

1. `get_reddit_campaigns` to avoid duplicate names and to learn the account's currency; `list_reddit_funding_instruments` for a servable `funding_instrument_id` and the posting `profile_id`
2. **Pixel first.** `get_reddit_pixels`: every ad group needs a `conversion_pixel_id`, and a CONVERSIONS objective is pointless if the optimization event never fired. If the account has no pixel, stop and tell the user to create one in Reddit Events Manager
3. **Targeting.** Ask which countries (and languages, upper-case codes like `EN`) to target if not stated; find ids/names with `search_reddit_targeting` (`geolocations` with `country`, `communities` by query, `community_suggestions` for related subreddits, `interests`, `keywords`). Never draft an untargeted ad group; warn when a geo-targeted ad group has no language list. Narrow by device or carrier only when the user asks for it (an iOS app, a carrier partnership): `devices` look like `{"type": "MOBILE", "os": "IOS", "min_version": "16"}`, with make/model labels from kind `devices`; carrier ids come from kind `carriers` (filter with `country`). Every extra dimension shrinks the audience, so re-run the estimate after adding one
3a. **Estimate first.** `estimate_reddit_ad_group` with the same targeting and budget: if the targetable audience is tiny or the planned bid is under Reddit's suggested minimum, fix that before drafting
4. `draft_reddit_campaign` (objective, funding instrument; leave the budget off unless the user wants campaign budget optimization) → approve → apply
5. `draft_reddit_ad_group` with `daily_budget` ≤ `max_daily_budget`, `bid_strategy` (MAXIMIZE_VOLUME is the sane default; MANUAL_BIDDING/TARGET_CPX need `bid_value`), `bid_type` (CPC for traffic and conversions), `conversion_pixel_id`, `optimization_goal` matching the pixel event, targeting → approve → apply
6. `draft_reddit_ad`: verify `click_url` exists (route definition or live page), write a headline in the landing page's language (Reddit titles read like posts, not like search ads — no ALL CAPS, no exclamation stacking), pick a `call_to_action` from Reddit's list, decide `allow_comments` with the user (comments are public and need moderation) → approve → apply
7. Enable in reverse order once everything exists: ad (`enable_reddit_entity`, triggers policy review; expect `PENDING_APPROVAL` for up to 24h), then ad group, then campaign
8. After 24h check `get_reddit_ads` for `REJECTED` + `rejection_reason` before touching budgets

### When user asks to pause or enable something

1. Call the appropriate read tool to confirm the entity exists and get its current status
2. Call `pause_entity` or `enable_entity` (Google) or `pause_reddit_entity` / `enable_reddit_entity` (Reddit) with the entity type and ID
3. Present the preview with a clear warning about impact (e.g. "This will stop all ads in this campaign")
4. Wait for confirmation

### When user asks about landing page performance

1. Call `landing_page_analysis` — it combines ad final URLs with GA4 page data in one call
2. Review the `insights[]` for pages with traffic but zero conversions, high bounce rates, or orphaned URLs
3. If the codebase is accessible, read the flagged landing pages to identify UX or content issues
4. Present the results sorted by paid sessions, highlighting problem pages

### When user asks "is my tracking working" or "are conversions set up correctly"

1. Call `attribution_check` with `conversion_events` set to the expected events (e.g. `["sign_up", "purchase"]`)
2. The tool checks: do these events exist in GA4? Do they fire from paid traffic? Does Ads agree?
3. If the `insights[]` mention missing events or zero counts, search the codebase for tracking code and then call `validate_tracking` with the extracted event names for a structured comparison
4. If the `insights[]` mention GDPR consent gaps, explain that this is normal EU behavior, not broken tracking
5. If tracking code needs to be added, use `generate_tracking_code` to produce ready-to-paste gtag snippets with the right parameters

### When user asks "paid vs organic" or "which channel converts better"

1. Call `analyze_campaign_conversions` — it returns both paid campaign metrics and non-paid channel conversion rates
2. Compare `campaigns[].ga4_conversion_rate` (paid) vs `non_paid_channels[].conversion_rate` (organic/direct/referral)
3. If paid conversion rate is significantly lower, investigate landing page relevance and ad targeting before increasing spend

### When user asks about Performance Max or PMax campaigns

1. Call `get_pmax_performance` for campaign-level metrics with network breakdown and asset group ad strength
2. Review `insights[]` — weak ad strength and zero-conversion asset groups are the most actionable findings
3. If ad strength is POOR or AVERAGE, call `get_pmax_assets` to see each asset's type and serving status and which asset types are missing (per-asset LOW/GOOD/BEST labels don't exist for PMax; `get_detailed_asset_performance` shows which combinations Google serves most)
4. Call `get_detailed_asset_performance` to see which headline+description+image combinations Google selects most — this reveals what's actually working
5. If the user wants to improve PMax performance:
   - Replace LOW-performing assets with new ones
   - Ensure minimum asset diversity: at least 5 headlines, 5 descriptions, 5 marketing images, 1 landscape image, 1 logo, 1 YouTube video (recommended)
   - Check that final URLs point to relevant, working landing pages
6. Note: PMax campaigns are partially opaque. `segments.ad_network_type` gives SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most PMax traffic. Full per-channel transparency is not available via the API.

### When user asks about Google's recommendations or "what does Google suggest"

1. Call `get_recommendations` to retrieve all active (non-dismissed) recommendations
2. Review the `by_type` summary and `insights[]` — these flag budget-increase recommendations, whose projected gain comes from spending more
3. **NEVER blindly endorse Google's recommendations.** Cross-reference each recommendation against actual account data:
   - If Google says "raise budget" but the campaign has zero conversions → bad advice. Fix tracking/landing pages first.
   - If Google says "add keywords" but existing keywords have quality score < 5 → bad advice. Fix relevance first.
   - If Google says "switch to Maximize Conversions" and the campaign uses Manual CPC → likely good advice, but verify conversion tracking works.
   - If Google says "use Broad Match" and the campaign isn't on Smart Bidding → reject per safety rule 7.
4. Use `estimated_improvement` to prioritize: recommendations with >1 estimated additional conversion are worth investigating
5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting
6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue

### When user asks about audience performance or targeting

1. Call `get_audience_performance` for the relevant date range
2. If filtered to a specific campaign, also call `get_campaign_performance` for context on the campaign's overall performance
3. Compare audience segment metrics: which segments convert best? Which have high spend but zero conversions?
4. For PMax campaigns, note that audience targeting is automatic — audience performance data may not appear in the `ad_group_audience_view` report. Audience signals in PMax are configured at the asset group level and serve as hints, not hard targeting.
5. For Display/Search campaigns with explicit audience overlays, use the data to recommend bid adjustments or audience exclusions

**Audience type ↔ campaign type compatibility.** Not every audience type can be attached to every campaign type — check this BEFORE proposing audience targeting, and use it to explain empty `custom_audience` joins:

| Audience type | Search | Display | Video (YouTube) | Performance Max |
|---|---|---|---|---|
| Remarketing / your-data lists | ✓ (targeting or observation) | ✓ | ✓ | signal only |
| In-market segments | ✓ | ✓ | ✓ | signal only |
| Affinity segments | ✓ | ✓ | ✓ | signal only |
| Custom segments (custom intent/affinity) | ✗ NOT attachable | ✓ | ✓ | signal only |
| Combined segments | ✓ | ✗ | ✗ | ✗ |
| Detailed demographics | ✓ | ✓ | ✓ | signal only |

- **Custom segments cannot be attached to Search campaigns.** A GAQL join of `custom_audience` against Search campaigns will always come back empty — that is expected, not missing data. Don't propose custom segments for Search; suggest in-market/affinity or remarketing lists instead.
- **PMax never takes hard audience targeting.** Every audience there is an asset-group-level signal — a hint to the algorithm, not a filter.

### When user asks to exclude or include demographics ("exclude ages 25-34", "exclude women", "only show to parents")

1. Identify the target scope: usually an ad group, sometimes a campaign. If the user says "in campaign X", target the campaign; if they say "in ad group Y" or "on this ad group", target the ad group. If unclear, ask — demographic exclusions at ad-group level are the more common pattern.
2. Get the entity ID:
   - For an ad group: `run_gaql` with `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` (or filter by name)
   - For a campaign: `get_campaign_performance`
3. Call `get_demographic_targeting` first to see what's already excluded — avoid creating duplicate exclusions.
4. **Map the user's request to Google's fixed buckets:**
   - **Age**: 18-24, 25-34, 35-44, 45-54, 55-64, 65+. Google does NOT support arbitrary ranges. If the user says "23-35", explain that the closest buckets are 25-34 (covers 25-34 exactly) plus partial overlap with 18-24 (23-24) and 35-44 (35). Ask which buckets to use.
   - **Gender**: female, male, undetermined
   - **Parental status**: parent, not_a_parent, undetermined
   - **Income range**: PERCENTILES (not currency). top-10, 11-20, 21-30, 31-40, 41-50, lower-50. Only works in select countries (US, AU, JP, etc.). Do NOT translate dollar amounts — income range targeting is based on a user's household income percentile in their country.
5. Call `draft_demographic_targeting` with the appropriate dimension(s). Default is `negative=True` (exclusion). Use `negative=False` only when the user explicitly wants to NARROW targeting to specific demographics — warn them that this excludes everyone else (plus UNDETERMINED).
6. Present the preview to the user — show clearly which segments will no longer see ads (for exclusions) or will be the only ones who do (for positive targeting).
7. Wait for explicit user approval before calling `confirm_and_apply`.
8. **To remove an existing demographic exclusion**, call `get_demographic_targeting` to find the criterion's `remove_id`, then use `remove_entity` with `entity_type="ad_group_criterion"` or `entity_type="campaign_criterion"`.

**Common pitfalls:**
- "Audiences" and "demographics" mean different things in Google Ads. Audiences are user lists (remarketing, in-market, affinity) — use `get_audience_performance` and audience-targeting tools. Demographics are age/gender/parental/income — use `get_demographic_targeting` and `draft_demographic_targeting`. If the user says "exclude audiences" but describes ages, they mean demographics.
- Excluding many segments within a single dimension drastically reduces reach. The tool will warn when you exclude nearly all values in one dimension.
- **Excluding UNDETERMINED is almost never what the user wants.** Google cannot classify a large share of users (often 30%+, more under EU consent restrictions); excluding UNDETERMINED blocks all of them, far beyond the named segment. The tool warns — surface that warning prominently and confirm intent before applying.
- Campaign-level demographic criteria can only be EXCLUSIONS. Positive (narrowing) demographic targeting must go on an ad group — the tool rejects `campaign_id` + `negative=False`.
- Demographic exclusions are ad-serving filters, not bid modifiers. To raise or lower bids for a demographic, use bid adjustments (not currently exposed as a dedicated tool — use `run_gaql` and the Google Ads UI).

## Default Parameters

When the user doesn't specify:
- **Date range**: Default to last 30 days for Ads, last 7 days for GA4
- **Customer ID**: Use the account chosen in the AdLoop Cloud dashboard (no need to ask)
- **Property ID**: Use the property chosen in the AdLoop Cloud dashboard (no need to ask)
- **Format**: Use "table" for run_gaql results

## GAQL Quick Reference

See [references/gaql.md](references/gaql.md). Read it before writing a `run_gaql` query.

## Ad Copy Character Limits

Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits.

**Hard limits:**
- Headlines: **30 characters** max (including spaces)
- Descriptions: **90 characters** max (including spaces)
- Display path fields: **15 characters** each (path1, path2)
- Sitelink link text: **25 characters** max
- Sitelink descriptions: **35 characters** max (description1, description2)

**30 characters is very short.** This is the most common source of rejected ad drafts. Many languages produce words and phrases that easily exceed 30 characters — German compound words, French/Spanish phrases with articles, etc. English is among the most compact languages for ad copy.

**Rules for writing ad copy that fits:**
1. **Count characters for every headline BEFORE calling the draft tool.** Every space, hyphen, accent, and punctuation mark counts as 1 character. If a headline is over 30, rewrite it shorter — don't submit it and hope.
2. **Abbreviate long words.** If a domain term exceeds ~15 characters, abbreviate it or use a shorter synonym. Save long terminology for descriptions (90 char limit).
3. **Use short action verbs and numbers.** Numbers, symbols, and abbreviations save space.
4. **Put long phrases in descriptions, not headlines.** Headlines are for punchy hooks. Descriptions have 3x the space for detail.
5. **Write in the correct language.** Check the landing page to determine the language. If the landing page is multilingual or the language is ambiguous, ASK the user which language the ad should be in before writing any copy.
6. **Test each headline mentally:** if it's close to 30, it's probably over. Aim for 25 or fewer to leave margin.

## Geo & Language Targeting Reference

See [references/geo-language-targeting.md](references/geo-language-targeting.md). Read it for geo target and language IDs beyond the common ones listed above.

## Marketing Best Practices

When advising on Google Ads:

- **Bid strategy default**: Prefer MAXIMIZE_CONVERSIONS for new campaigns. It leverages Google's signals from day one, even without conversion history. TARGET_SPEND (Maximize Clicks) only makes sense as a deliberate data-collection phase — and even then, MAXIMIZE_CONVERSIONS is usually better because it starts optimizing for conversions immediately instead of just accumulating clicks.
- **Geo targeting is mandatory**: Every campaign must target specific countries/regions. Untargeted campaigns serve globally and waste budget. ALWAYS set geo_target_ids when creating campaigns.
- **Language targeting is mandatory**: Every campaign must target specific languages. Without it, ads show to all users in the geo region regardless of browser language. A German-language ad shown to an English speaker in Germany wastes budget. ALWAYS set language_ids when creating campaigns.
- **Match types**: Never recommend Broad Match without Smart Bidding (tCPA or tROAS) active on the campaign. Broad Match without Smart Bidding leads to budget waste.
- **CPA monitoring**: Flag any campaign where CPA exceeds 3x the target for review.
- **Budget sufficiency**: A campaign's daily budget should be at least 5x its target CPA to generate enough data for the algorithm.
- **Learning phase**: Don't edit campaigns that are in an active learning phase (Google Ads shows "Learning" or "Learning (limited)" status). Wait until the learning phase completes before making changes.
- **Negative keyword hygiene**: After reviewing search terms, always suggest adding irrelevant terms as negatives. Group them by theme.
- **RSA best practices**: Provide at least 8-10 unique headlines (out of max 15) and 3-4 descriptions (out of max 4). Make headlines diverse — don't repeat the same message. **Pin only when necessary** — pinning reduces Google's ability to rotate assets and typically lowers ad strength. Reach for the dict shape `{"text": "...", "pinned_field": "HEADLINE_1"}` only for concrete brand-safety/compliance reasons. For phone numbers, prefer a campaign-level call asset over a pinned headline. See "Ad Copy Character Limits" section below for language-specific guidance.
- **Quality Score**: If keywords have quality score < 5, prioritize improving ad relevance and landing page experience over bid increases.
- **Zero conversions**: When a campaign has spent significant budget with zero conversions, investigate (1) is GDPR consent reducing visible conversions? (2) is conversion tracking set up correctly in GA4? (3) is the landing page converting organic traffic? (4) are search terms relevant? Don't just increase budget.
- **Manual CPC + Broad Match**: This combination is the #1 cause of wasted budget. Broad Match without Smart Bidding matches any vaguely related query — a niche industry keyword on BROAD will match generic, irrelevant, and competitor terms. NEVER create this combination. If it already exists, recommend switching to PHRASE/EXACT match or moving the campaign to Smart Bidding BEFORE any other changes.
- **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each.
- **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each.
- **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken.
- **Performance Max asset diversity**: PMax campaigns need diverse assets to perform well. Minimum recommended: 5+ headlines (max 30 chars), 5+ long headlines (max 90 chars), 5+ descriptions (max 90 chars), 5+ marketing images (1200x628), 5+ square images (1200x1200), 1+ logo (1200x1200), 1+ landscape logo (1200x300). A YouTube video is strongly recommended. Ad strength below GOOD usually means missing asset types.
- **PMax transparency limitations**: The Google Ads API does not provide full channel-level breakdowns for PMax. `segments.ad_network_type` returns SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most traffic. Do not promise users exact Search vs Display vs YouTube splits. Be honest about what the data shows and what it doesn't.
- **Google recommendations are not neutral**: Google's auto-generated recommendations optimize for Google's revenue, not necessarily the advertiser's ROI. Budget increase and Broad Match recommendations should always be cross-referenced against actual conversion data. Bid strategy recommendations (switch to Smart Bidding) are usually sound. Keyword recommendations may be too broad. Never blindly apply recommendations — evaluate each one against the account's actual performance.
