agentleFS
kLOskkLOsk/adloop/.cursor/rules/adloop.mdc

adloop / rules

Cursor rule277 starsChanged 4 months agoMITView on GitHub

AdLoop MCP orchestration — Google Ads + Reddit Ads + GA4 + codebase intelligence

We split this adloop.mdc into the rules it gives an agent

134 rules. About 0 are common, 52 are distinctive, and 82 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈52

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 82

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Run it on ad final_urls, mobile first — that's where most paid traffic lands.

    line 121
  2. Very specific:

    Call get_reddit_performance (level campaign, compact=true for an overview).

    line 164
  3. Very specific:

    Read insights[]: zero-conversion spenders, rejected ads, empty windows

    line 166
  4. Very specific:

    If conversions are zero or implausibly low, call get_reddit_pixels BEFORE blaming targeting: a pixel that never fired the optimization …

    line 167
  5. Very specific:

    Call list_gsc_sites if no site is configured.

    line 173
  6. Very specific:

    Search the codebase for gtag('event', ...) and dataLayer.push({event: ...}) calls.

    line 181
  7. Very specific:

    If GTM account/container IDs are unknown, call list_gtm_accounts → list_gtm_containers (or take them from the GTM UI: Admin → Container …

    line 182
  8. Very specific:

    Call audit_event_coverage with the extracted expected_events — it returns a per-event status matrix (ok, tag_paused, …

    line 183
  9. Very specific:

    Call a draft_* tool → returns a preview with a plan_id

    line 254
  10. Very specific:

    Call confirm_and_apply(plan_id=..., dry_run=true) first to test

    line 256
  11. Very specific:

    Only call with dry_run=false after explicit user confirmation

    line 257
  12. Very specific:

    All operations (including dry runs) are logged to ~/.adloop/audit.log.

    line 274
  13. Very specific:

    Default to dry_run=true: When calling confirm_and_apply, always use dry_run=true unless the user explicitly says to apply for real.

    line 280
  14. Distinctive:

    One change at a time: Don't batch multiple write operations.

    line 286
  15. Very specific:

    Never guess entity IDs: Always retrieve IDs from a read tool first (get_campaign_performance for campaign IDs, get_ad_performance for ad …

    line 288
  16. Distinctive:

    Pre-write validation: check before you change: Before ANY write operation, verify the campaign/ad group context is sound:

    line 294
  17. Distinctive:

    Check bidding strategy (rule 7)

    line 295
  18. Distinctive:

    Check if conversion tracking is active (zero conversions + high spend = problem to fix first, not more ads to create)

    line 296
  19. Distinctive:

    Check quality scores (if all keywords have QS < 5, the problem is landing page/relevance, not keyword count)

    line 297
  20. Distinctive:

    If the account has systemic issues, WARN the user before making changes that won't help.

    line 298
  21. Distinctive:

    If Ads shows 10 clicks but GA4 shows 3 sessions → likely consent rejection, not broken tracking.

    line 310
  22. Distinctive:

    If GA4 shows 0 sessions from paid traffic → consent could explain it, but also check UTM parameters and GA4 filters.

    line 311
  23. Distinctive:

    Only flag tracking as broken when the discrepancy cannot be explained by consent (e.g., GA4 shows zero sessions for ALL traffic sources, or …

    line 312
  24. Very specific:

    Call get_campaign_performance for the relevant date range

    line 320
  25. Very specific:

    If results include Performance Max campaigns (campaign.advertising_channel_type = PERFORMANCE_MAX), also call get_pmax_performance for …

    line 321
  26. Very specific:

    If they mention conversions, CPA, or "is it worth it", call analyze_campaign_conversions instead — it gives Ads + GA4 data in one call with …

    line 322
  27. Very specific:

    If they mention specific keywords or search terms, also call get_keyword_performance or get_search_terms

    line 323
  28. Very specific:

    If the account has active Google recommendations, mention that get_recommendations can surface Google's suggestions for improvement

    line 327
  29. Very specific:

    Call attribution_check with relevant date range and conversion_events if the user mentions specific events (e.g.

    line 331
  30. Very specific:

    If the discrepancy needs page-level drill-down, call landing_page_analysis to see which pages get paid traffic but don't convert

    line 332
  31. Very specific:

    Call get_search_terms to see if search intent shifted

    line 333
  32. Very specific:

    Before concluding tracking is broken: Check the insights from attribution_check — it already factors in GDPR consent gaps.

    line 334
  33. Distinctive:

    If the user's codebase is accessible (Cursor native), search for recent changes to the affected pages

    line 335
  34. Very specific:

    Call get_campaign_performance to understand existing campaign structure and find the right campaign.id

    line 340
  35. Very specific:

    Use run_gaql to find ad group IDs: SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}

    line 345
  36. Very specific:

    Call get_tracking_events to verify conversion tracking exists

    line 346
  37. Distinctive:

    If codebase is accessible, read the landing page code to extract value propositions and determine the language.

    line 347
  38. Very specific:

    Call draft_responsive_search_ad with at least 8-10 diverse headlines and 3-4 descriptions.

    line 348
  39. Very specific:

    After the ad is created, suggest sitelinks if the campaign doesn't have any.

    line 356
  40. Very specific:

    Wait for explicit user approval before calling confirm_and_apply

    line 357
  41. Very specific:

    Call get_campaign_performance to identify the target campaign and its bidding strategy

    line 361
  42. Very specific:

    If the campaign uses MANUAL_CPC/MANUAL_CPM: ONLY use EXACT or PHRASE match.

    line 363
  43. Distinctive:

    If the campaign has zero conversions: WARN that adding keywords won't help until conversion tracking is working.

    line 364
  44. Distinctive:

    If all existing keywords have quality score < 5: WARN that the problem is ad relevance and landing pages, not keyword coverage.

    line 365
  45. Very specific:

    Call get_keyword_performance to see what keywords already exist — avoid duplicates

    line 366
  46. Very specific:

    Call get_search_terms to understand what's already triggering ads

    line 367
  47. Very specific:

    Call draft_keywords with appropriate match types (the tool will also warn about BROAD + non-Smart Bidding)

    line 368
  48. Distinctive:

    Wait for explicit user approval

    line 370
  49. Very specific:

    Call get_campaign_performance to understand the existing campaign structure — avoid duplicate campaign names

    line 374
  50. Very specific:

    If the user hasn't specified a budget, call estimate_budget with proposed keywords to get a data-driven budget recommendation

    line 375
  51. Distinctive:

    Geo targeting: ALWAYS ask the user which countries/regions to target if not specified.

    line 378
  52. Distinctive:

    Language targeting: ALWAYS ask the user which languages to target if not specified.

    line 379
  53. Very specific:

    Call draft_campaign with campaign name, daily budget, bidding strategy, geo_target_ids, language_ids, ad group name, and optional keywords

    line 382
  54. Distinctive:

    After campaign creation, remind the user to:

    line 385
  55. Very specific:

    Add ads via draft_responsive_search_ad (with display paths set)

    line 386
  56. Very specific:

    Add sitelinks via draft_sitelinks (at least 4 recommended)

    line 387
  57. Very specific:

    If the user needs multiple ad groups (e.g., different keyword themes), use draft_ad_group to add additional ad groups after the initial …

    line 388
  58. Very specific:

    Call get_campaign_performance to identify the target campaign and verify it exists

    line 395
  59. Very specific:

    Check the campaign's bidding strategy — if MANUAL_CPC, only use EXACT or PHRASE match keywords

    line 397
  60. Distinctive:

    Check if conversion tracking is active (zero conversions + high spend = problem to fix first)

    line 398
  61. Very specific:

    Check existing ad groups via run_gaql: SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id} — avoid duplicate …

    line 399
  62. Very specific:

    Call draft_ad_group with campaign_id, ad_group_name, and optional keywords

    line 400
  63. Very specific:

    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 …

    line 403
  64. Very specific:

    Call get_campaign_performance to identify the campaign and its current settings

    line 407
  65. Very specific:

    Use run_gaql to check current targeting:

    line 408
  66. Very specific:

    If changing to MAXIMIZE_CONVERSIONS: good default choice — confirm with the user

    line 412
  67. Very specific:

    If changing to MANUAL_CPC: warn about the trade-offs (no automation, requires constant monitoring)

    line 413
  68. Distinctive:

    If removing geo or language targets: warn that this broadens targeting and may waste budget

    line 414
  69. Distinctive:

    If the campaign is in a learning phase: warn that changes will restart the learning phase

    line 415
  70. Very specific:

    Call update_campaign with only the parameters that need to change

    line 416
  71. Distinctive:

    Ask the user for their target keywords (or suggest some based on the business context)

    line 422
  72. Distinctive:

    Ask for the target geography and language (or infer from the existing account)

    line 423
  73. Very specific:

    Call estimate_budget with the keywords, match types, and optional daily budget

    line 424
  74. Distinctive:

    If the user provided a daily budget, highlight whether it's sufficient to capture most available traffic

    line 426
  75. Very specific:

    Use the forecast to inform draft_campaign decisions — the estimated daily cost guides the budget parameter

    line 427
  76. Very specific:

    If the codebase is accessible, search for gtag('event' and dataLayer.push calls to extract event names.

    line 432
  77. Very specific:

    Call validate_tracking with the extracted event names — it compares codebase events against actual GA4 data and returns matched, missing, …

    line 433
  78. Very specific:

    If the user needs to add new tracking, use generate_tracking_code to produce the gtag snippet with recommended parameters

    line 435
  79. Very specific:

    If an event FIRES in GA4 but is not marked as a key event (conversion), propose draft_key_event — preview → confirm like every write.

    line 436
  80. Very specific:

    Call get_search_terms to see current search term data

    line 440
  81. Very specific:

    Call get_negative_keywords to see what's already blocked — avoid duplicates

    line 441
  82. Very specific:

    Call get_campaign_performance to get the right campaign.id

    line 443
  83. Distinctive:

    Ask whether to start with seed keywords, a URL, or both — these map directly to the two modes in Google Ads Keyword Planner UI

    line 456
  84. Very specific:

    Call discover_keywords with seed_keywords, url, or both, plus the target geo_target_id and language_id

    line 457
  85. Very specific:

    Use estimate_budget with the selected keywords to forecast traffic and cost before committing

    line 461
  86. Very specific:

    Use draft_keywords to add them to an existing ad group

    line 462
  87. Very specific:

    Use draft_campaign to build a new campaign around them

    line 463
  88. Very specific:

    After 24h check get_reddit_ads for REJECTED + rejection_reason before touching budgets

    line 477
  89. Distinctive:

    Call the appropriate read tool to confirm the entity exists and get its current status

    line 481
  90. Very specific:

    Call pause_entity or enable_entity (Google) or pause_reddit_entity / enable_reddit_entity (Reddit) with the entity type and ID

    line 482
  91. Very specific:

    Call landing_page_analysis — it combines ad final URLs with GA4 page data in one call

    line 488
  92. Distinctive:

    If the codebase is accessible, read the flagged landing pages to identify UX or content issues

    line 490
  93. Very specific:

    Call attribution_check with conversion_events set to the expected events (e.g.

    line 495
  94. Very specific:

    If the insights[] mention missing events or zero counts, search the codebase for tracking code and then call validate_tracking with the …

    line 497
  95. Very specific:

    If the insights[] mention GDPR consent gaps, explain that this is normal EU behavior, not broken tracking

    line 498
  96. Very specific:

    If tracking code needs to be added, use generate_tracking_code to produce ready-to-paste gtag snippets with the right parameters

    line 499
  97. Very specific:

    Call analyze_campaign_conversions — it returns both paid campaign metrics and non-paid channel conversion rates

    line 503
  98. Distinctive:

    If paid conversion rate is significantly lower, investigate landing page relevance and ad targeting before increasing spend

    line 505
  99. Very specific:

    Call get_pmax_performance for campaign-level metrics with network breakdown and asset group ad strength

    line 509
  100. Very specific:

    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 …

    line 511
  101. Very specific:

    Call get_detailed_asset_performance to see which headline+description+image combinations Google selects most — this reveals what's actually …

    line 512
  102. Distinctive:

    If the user wants to improve PMax performance:

    line 513
  103. Distinctive:

    Ensure minimum asset diversity: at least 5 headlines, 5 descriptions, 5 marketing images, 1 landscape image, 1 logo, 1 YouTube video …

    line 515
  104. Distinctive:

    Check that final URLs point to relevant, working landing pages

    line 516
  105. Very specific:

    Call get_recommendations to retrieve all active (non-dismissed) recommendations

    line 521
  106. Distinctive:

    If Google says "raise budget" but the campaign has zero conversions → bad advice.

    line 524
  107. Distinctive:

    If Google says "add keywords" but existing keywords have quality score < 5 → bad advice.

    line 525
  108. Distinctive:

    If Google says "switch to Maximize Conversions" and the campaign uses Manual CPC → likely good advice, but verify conversion tracking works.

    line 526
  109. Distinctive:

    If Google says "use Broad Match" and the campaign isn't on Smart Bidding → reject per safety rule 7.

    line 527
  110. Very specific:

    Use estimated_improvement to prioritize: recommendations with >1 estimated additional conversion are worth investigating

    line 528
  111. Very specific:

    If filtered to a specific campaign, also call get_campaign_performance for context on the campaign's overall performance

    line 535
  112. Very specific:

    Audience type ↔ campaign type compatibility: Not every audience type can be attached to every campaign type — check this BEFORE proposing …

    line 540
  113. Distinctive:

    PMax never takes hard audience targeting: Every audience there is an asset-group-level signal — a hint to the algorithm, not a filter.

    line 552
  114. Very specific:

    Call get_demographic_targeting first to see what's already excluded — avoid creating duplicate exclusions.

    line 560
  115. Very specific:

    Call draft_demographic_targeting with the appropriate dimension(s).

    line 566
  116. Distinctive:

    When the user doesn't specify:

    line 580
  117. Distinctive:

    Date range: Default to last 30 days for Ads, last 7 days for GA4

    line 581
  118. Distinctive:

    Customer ID: Use the default from config (no need to ask)

    line 582
  119. Distinctive:

    Property ID: Use the default from config (no need to ask)

    line 583
  120. Distinctive:

    Format: Use "table" for run_gaql results

    line 584
  121. Very specific:

    When selecting segments.date, results are broken down by day

    line 668
  122. Distinctive:

    Abbreviate long words: If a domain term exceeds ~15 characters, abbreviate it or use a shorter synonym.

    line 724
  123. Distinctive:

    Write in the correct language: Check the landing page to determine the language.

    line 727
  124. Distinctive:

    Test each headline mentally: if it's close to 30, it's probably over.

    line 728
  125. Distinctive:

    When advising on Google Ads:

    line 777
  126. Distinctive:

    Geo targeting is mandatory: Every campaign must target specific countries/regions.

    line 780
  127. Distinctive:

    Language targeting is mandatory: Every campaign must target specific languages.

    line 781
  128. Distinctive:

    Match types: Never recommend Broad Match without Smart Bidding (tCPA or tROAS) active on the campaign.

    line 782
  129. Distinctive:

    Learning phase: Don't edit campaigns that are in an active learning phase (Google Ads shows "Learning" or "Learning (limited)" status).

    line 785
  130. Distinctive:

    Negative keyword hygiene: After reviewing search terms, always suggest adding irrelevant terms as negatives.

    line 786
  131. Distinctive:

    Quality Score: If keywords have quality score < 5, prioritize improving ad relevance and landing page experience over bid increases.

    line 788
  132. Distinctive:

    Zero conversions: When a campaign has spent significant budget with zero conversions, investigate (1) is GDPR consent reducing visible …

    line 789
  133. Very specific:

    Display paths: Always set path1 and path2 on RSAs.

    line 791
  134. Distinctive:

    Clicks vs sessions gap: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent.

    line 793

≈ “Common” is an estimate: at least 1 in 5 of the 6,350 Cursor rule files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
adloop.mdc
---
description: AdLoop MCP orchestration — Google Ads + Reddit Ads + GA4 + codebase intelligence
globs:
alwaysApply: true
---

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

## 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 in the Google Cloud project — tell the user to enable it; it is not a permissions or consent problem.

**If health_check reports Google auth errors:** Tell the user to delete `~/.adloop/token.json` and re-run any tool to trigger re-authorization. If tokens keep expiring weekly, the GCP consent screen needs to be published from "Testing" to "In production". **Reddit auth errors** (`REDDIT_INVALID_GRANT`, `REDDIT_NOT_CONNECTED`) are fixed by re-running the Reddit step of `adloop init` (self-hosted) or reconnecting under Settings → Reddit Ads (AdLoop Cloud); 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 config) |
| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` |
| `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` |

### Google Ads Read Tools

| Tool | When to Use | Key Parameters |
|------|-------------|----------------|
| `list_accounts` | First-time discovery — find which Ads accounts exist | (none — uses MCC from config) |
| `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` |
| `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `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_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.
- `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, low-QS keywords, negative-keyword waste candidates, thin RSAs, single-ad ad groups) 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 | `gtm_account_id`, `gtm_container_id` (+ `tag_id`) |
| `list_gtm_triggers` / `get_gtm_trigger` | Inventory triggers / full detail incl. filter conditions | `gtm_account_id`, `gtm_container_id` (+ `trigger_id`) |
| `list_gtm_variables` | Inventory user-defined variables | `gtm_account_id`, `gtm_container_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 publish history / full metadata of one version | `gtm_account_id`, `gtm_container_id` (+ `version_id`) |

**GTM prerequisites:** the Tag Manager API must be enabled in the user's GCP project and their account needs at least Read access on the container. OAuth tokens granted before the GTM scope existed must be re-consented (delete `~/.adloop/token.json`, re-run any tool) — 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 | `site_url` (falls back to `gsc.site_url` config), `dimensions`, date range (ISO or "28daysAgo"), `dimension_filter_groups`, `search_type` |

**GSC prerequisites:** the Search Console API must be enabled in the user's GCP project and their Google account needs access to the property in Search Console. Tokens granted before the GSC scope existed must re-consent (`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.
- 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 — optional when `merchant.account_id` is configured) |

- **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 (own OAuth app, no developer token, no approval process). Every Reddit tool takes `ad_account_id` (falls back to `reddit.ad_account_id` in the config). 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. 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), `query`, `country`, `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, 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 config) |
| `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) | `daily_budget` vs cap, `bid_value` vs `max_bid_increase_pct`; targeting lists REPLACE the keys you pass (pass full lists), other keys stay; keywords are brand-safety-checked and geos validated with Reddit at draft time |
| `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) |
| `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.
- `require_dry_run: true` in config overrides `dry_run=false` — the user must change the config to allow real mutations.
- `two_phase_apply: true` in config (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 to `~/.adloop/audit.log`.

## 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, `require_dry_run` in config may override this.

3. **Respect budget caps.** The config has max_daily_budget set. 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
7. Compare against best practices (see Marketing Best Practices section)
8. 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. **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.
5. If the user's codebase is accessible (Cursor native), search for recent changes to the affected pages
6. 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 ≤ `max_daily_budget` in config, 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), propose `draft_key_event` — 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

### 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
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 default from config (no need to ask)
- **Property ID**: Use the default from config (no need to ask)
- **Format**: Use "table" for run_gaql results

## GAQL Quick Reference

GAQL (Google Ads Query Language) is SQL-like but with specific resource names and field paths.

### Basic Syntax

```sql
SELECT field1, field2, ...
FROM resource
WHERE condition
ORDER BY field [ASC|DESC]
LIMIT n
```

### Common Resources

| Resource | Use For |
|----------|---------|
| `campaign` | Campaign-level data |
| `ad_group` | Ad group-level data |
| `ad_group_ad` | Ad-level data (includes ad copy) |
| `keyword_view` | Keyword performance |
| `search_term_view` | Search terms report |
| `ad_group_criterion` | Keywords and targeting criteria |
| `campaign_budget` | Budget information |
| `bidding_strategy` | Bidding strategy details |
| `customer_client` | List accounts under an MCC (uses login_customer_id) |
| `asset_group` | PMax asset group data (ad strength, status) |
| `asset_group_asset` | PMax per-asset performance labels |
| `asset_group_top_combination_view` | PMax top asset combinations |
| `recommendation` | Google's auto-generated recommendations |
| `ad_group_audience_view` | Audience segment performance |

### Common Fields

**Campaign fields:**
- `campaign.id`, `campaign.name`, `campaign.status`
- `campaign.advertising_channel_type` (SEARCH, DISPLAY, SHOPPING, VIDEO)
- `campaign.bidding_strategy_type`

**Ad Group fields:**
- `ad_group.id`, `ad_group.name`, `ad_group.status`
- `ad_group.cpc_bid_micros` (bid in micros — divide by 1,000,000 for actual value)

**Ad fields:**
- `ad_group_ad.ad.responsive_search_ad.headlines`
- `ad_group_ad.ad.responsive_search_ad.descriptions`
- `ad_group_ad.ad.final_urls`
- `ad_group_ad.status`

**Keyword fields:**
- `ad_group_criterion.keyword.text`
- `ad_group_criterion.keyword.match_type` (EXACT, PHRASE, BROAD)
- `ad_group_criterion.quality_info.quality_score`

**Metrics (available on most resources):**
- `metrics.impressions`, `metrics.clicks`, `metrics.cost_micros`
- `metrics.conversions`, `metrics.conversions_value`
- `metrics.ctr`, `metrics.average_cpc`
- `metrics.search_impression_share`, `metrics.search_rank_lost_impression_share`

**Segments (for time-based breakdowns):**
- `segments.date` — daily breakdown
- `segments.device` — MOBILE, DESKTOP, TABLET
- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE

### Date Ranges

```sql
WHERE segments.date DURING LAST_7_DAYS
WHERE segments.date DURING LAST_30_DAYS
WHERE segments.date DURING THIS_MONTH
WHERE segments.date DURING LAST_MONTH
WHERE segments.date BETWEEN '2026-01-01' AND '2026-01-31'
```

### Important GAQL Rules

- You CANNOT use `SELECT *` — every field must be named explicitly
- **Fields used in ORDER BY must appear in SELECT.** `ORDER BY metrics.cost_micros` will fail unless `metrics.cost_micros` is in your SELECT clause. This is the most common GAQL error.
- Metrics can be used in WHERE clauses (e.g., `WHERE metrics.clicks > 5`). The real constraint is field compatibility — certain resource attribute fields cannot be selected alongside specific metrics or segments in the same query. When a query fails, check field compatibility for the resource.
- `cost_micros` values are in micros — divide by 1,000,000 for the actual currency amount. The dedicated read tools (get_campaign_performance, etc.) already compute `metrics.cost` and `metrics.cpa` for you. Only `run_gaql` returns raw micros.
- When selecting `segments.date`, results are broken down by day
- Status values are strings: `'ENABLED'`, `'PAUSED'`, `'REMOVED'`
- `search_term_view` always requires a date segment in WHERE

### Example Queries

**Top campaigns by spend:**
```sql
SELECT campaign.name, campaign.status, metrics.cost_micros, metrics.clicks, metrics.conversions
FROM campaign
WHERE segments.date DURING LAST_30_DAYS AND campaign.status = 'ENABLED'
ORDER BY metrics.cost_micros DESC
LIMIT 10
```

**Keywords with low quality score:**
```sql
SELECT ad_group_criterion.keyword.text, ad_group_criterion.quality_info.quality_score,
       metrics.impressions, metrics.clicks, metrics.cost_micros
FROM keyword_view
WHERE segments.date DURING LAST_30_DAYS
  AND ad_group_criterion.quality_info.quality_score < 5
ORDER BY metrics.cost_micros DESC
```

**Search terms that cost money but don't convert:**
```sql
SELECT search_term_view.search_term, metrics.clicks, metrics.cost_micros, metrics.conversions
FROM search_term_view
WHERE segments.date DURING LAST_30_DAYS AND metrics.clicks > 5 AND metrics.conversions = 0
ORDER BY metrics.cost_micros DESC
LIMIT 20
```

**Ad groups in a specific campaign:**
```sql
SELECT ad_group.id, ad_group.name, ad_group.status
FROM ad_group
WHERE campaign.id = 12345678
```

## 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

### Common Geo Target IDs (geoTargetConstants)

| ID | Country |
|----|---------|
| 2276 | Germany |
| 2040 | Austria |
| 2756 | Switzerland |
| 2840 | United States |
| 2826 | United Kingdom |
| 2250 | France |
| 2380 | Italy |
| 2724 | Spain |
| 2528 | Netherlands |
| 2056 | Belgium |

For cities/regions, use `run_gaql` with `geo_target_constant` resource or look up IDs in the Google Ads API Geo Target documentation.

### Common Language IDs (languageConstants)

Verified against the `language_constant` resource. Google accepts a wrong
language ID silently, so confirm anything not listed here with:
`SELECT language_constant.id, language_constant.code, language_constant.name FROM language_constant`

| ID | Code | Language |
|----|------|----------|
| 1000 | en | English |
| 1001 | de | German |
| 1002 | fr | French |
| 1003 | es | Spanish |
| 1004 | it | Italian |
| 1005 | ja | Japanese |
| 1009 | da | Danish |
| 1010 | nl | Dutch |
| 1011 | fi | Finnish |
| 1014 | pt | Portuguese |
| 1015 | sv | Swedish |
| 1017 | zh_CN | Chinese (simplified) |
| 1019 | ar | Arabic |
| 1021 | cs | Czech |
| 1030 | pl | Polish |
| 1031 | ru | Russian |
| 1037 | tr | Turkish |

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

Download now (ZIP)

  • Where files are headingNew Cursor rule files have more rules about planning and tracking tasks, and fewer about code style.

    Of the Cursor rule files we read, the 3,978 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.

  • In this repository2 other files it gives its agents.

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.