Agent Skills: google-ads-experiments

Create and manage Google Ads A/B experiments via API. Use when user says 'A/B test', 'google ads experiment', 'split test campaign', 'эксперимент гугл', 'A/B кампании', or asks to test landing pages, ad copy, keywords, bids in Google Ads.

UncategorizedID: tekliner/improvado-agentic-frameworks-and-skills/google-ads-experiments

Install this agent skill to your local

pnpm dlx add-skill https://github.com/tekliner/improvado-agentic-frameworks-and-skills/tree/HEAD/skills/google-ads-experiments

Skill Files

Browse the full folder contents for google-ads-experiments.

Download Skill

Loading file tree…

skills/google-ads-experiments/SKILL.md

Skill Metadata

Name
google-ads-experiments
Description
"Create and manage Google Ads A/B experiments via API. Use when user says 'A/B test', 'google ads experiment', 'split test campaign', 'эксперимент гугл', 'A/B кампании', or asks to test landing pages, ad copy, keywords, bids in Google Ads."

Google Ads Experiments = A/B Testing via API

Core Principle: Create Google Ads experiments through API proxy, supporting any change type (landing pages, ad copy, keywords, bids) with mandatory validation before mutations.

Experiment Lifecycle (Occurrent - LR):

graph LR
    A[Gather params] --> B[Find campaign]
    B --> C[Get ad groups + ads]
    C --> D[Show current state]
    D --> E[Create experiment]
    E --> F[Create arms]
    F --> G[Schedule]
    G --> H[Modify trial]
    H --> I[Confirm + summary]

Ontological Rule: LR for experiment creation process

Primary source: Verified Google Ads API v20 access via Improvado Discovery proxy Session: 47a7a7ef-de8f-4125-898a-8aabc634086c by Konstantin Govorkov (2026-02-23) Updated: 908be4dd-f1c2-43ac-93c4-6a318e3975c4 — v1.2.0: new RSA ads approach, experiment deletion, async provisioning (2026-02-24)

1. API Access

All calls via mcp__improvado-ai-internal__discoveryRequestTool:

  • dataSource: google_ads_ql
  • connectorId: 1803 (must be string, NOT integer)
  • Customer ID: 9521562011
  • Base URL: https://googleads.googleapis.com/v20/customers/9521562011

CRITICAL — Discovery API request keys:

  • request.params → URL query parameters (use for GAQL queries via searchStream)
  • request.json → POST body payload (use for ALL mutations: experiments, arms, ads)
  • NEVER use request.params for mutation bodies — causes "Parameters can only be bound to primitive types" error

2. Parameters to Collect

If user provides context, use it. If not, ask for:

| Param | Required | Default | Description | |-------|----------|---------|-------------| | campaign | yes | — | Name or ID | | change_type | yes | — | landing_pages / ad_copy / keywords / bids / mixed | | changes | yes | — | Map of ad_group → new values | | split | no | 50 | Traffic % for treatment arm | | start_date | no | tomorrow | YYYY-MM-DD | | end_date | no | +30 days | YYYY-MM-DD | | goal_metric | no | CLICKS | CLICKS / CONVERSIONS / COST_PER_CONVERSION | | goal_direction | no | INCREASE | INCREASE / DECREASE / NO_CHANGE_OR_INCREASE | | name | no | auto-gen | Experiment name |

3. Execution Steps

Step 1: Find campaign

POST {base}/googleAds:searchStream
{"query": "SELECT campaign.id, campaign.name, campaign.status FROM campaign WHERE campaign.name = '{name}'"}

Step 2: Get ad groups

{"query": "SELECT ad_group.id, ad_group.name, ad_group.status FROM ad_group WHERE campaign.id = {id} AND ad_group.status = 'ENABLED'"}

Step 3: Get current ads (for LP/copy experiments)

{"query": "SELECT ad_group.id, ad_group.name, ad_group_ad.ad.id, ad_group_ad.ad.final_urls, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions FROM ad_group_ad WHERE campaign.id = {id} AND ad_group.status = 'ENABLED' AND ad_group_ad.status = 'ENABLED'"}

Step 4: Show diff table to user

Display current vs proposed changes. Example for LP test:

| Ad Group | Current LP | New LP |
|----------|-----------|--------|
| supermetrics | improvado.io/vs | improvado.io/vs/supermetrics |

WAIT FOR USER CONFIRMATION before proceeding.

Step 5: Create experiment (always validateOnly: true first)

POST {base}/experiments:mutate
{
  "operations": [{
    "create": {
      "name": "{name}",
      "description": "{description}",
      "suffix": "[Experiment]",
      "type": "SEARCH_CUSTOM",
      "status": "SETUP",
      "startDate": "{start}",
      "endDate": "{end}",
      "goals": [{"metric": "{goal_metric}", "direction": "{goal_direction}"}]
    }
  }],
  "validateOnly": true
}

If validation passes, repeat without validateOnly.

Step 6: Create experiment arms

CRITICAL: Both arms MUST have trafficSplit (NOT trafficSplitPercent). Without it, scheduleExperiment fails with fieldError: REQUIRED.

POST {base}/experimentArms:mutate
{
  "operations": [
    {
      "create": {
        "experiment": "customers/9521562011/experiments/{exp_id}",
        "campaigns": ["customers/9521562011/campaigns/{campaign_id}"],
        "control": true,
        "name": "Control",
        "trafficSplit": {100 - split}
      }
    },
    {
      "create": {
        "experiment": "customers/9521562011/experiments/{exp_id}",
        "control": false,
        "name": "Treatment",
        "trafficSplit": {split}
      }
    }
  ]
}

Field name mapping: Python SDK traffic_split → REST API trafficSplit (camelCase). ~~trafficSplitPercent~~ is INVALID and will be silently ignored, causing schedule to fail later.

Step 7: Schedule experiment (creates trial campaign)

POST {base}/experiments/{exp_id}:scheduleExperiment

Step 8: Apply changes to trial campaign

After scheduling, Google creates a trial campaign. Query it:

{"query": "SELECT campaign.id, campaign.name FROM campaign WHERE campaign.base_campaign = 'customers/9521562011/campaigns/{campaign_id}' AND campaign.status = 'ENABLED'"}

IMPORTANT — Async provisioning: Trial campaign ads are copied asynchronously. After scheduling, wait 30-60 seconds and verify all expected ads exist before modifying. First query may return partial results.

Then apply changes based on change_type:

Landing pages (NEW RSA ADS approach — RECOMMENDED):

RSA ads have IMMUTABLE final_urls — they cannot be updated via API UPDATE operation, and keyword-level URL overrides are invisible in Google Ads UI. Instead, use the new ads approach:

Step 8a: Get existing ads in trial campaign:

SELECT ad_group.id, ad_group.name, ad_group_ad.ad.id, ad_group_ad.ad.final_urls,
       ad_group_ad.ad.responsive_search_ad.headlines,
       ad_group_ad.ad.responsive_search_ad.descriptions,
       ad_group_ad.ad.responsive_search_ad.path1,
       ad_group_ad.ad.responsive_search_ad.path2
FROM ad_group_ad
WHERE campaign.id = {trial_campaign_id}
  AND ad_group.status = 'ENABLED'
  AND ad_group_ad.status = 'ENABLED'

Step 8b: For each target ad group, PAUSE old ads:

POST {base}/googleAds:mutate
{
  "mutateOperations": [{
    "adGroupAdOperation": {
      "update": {
        "resourceName": "customers/9521562011/adGroupAds/{trial_ag_id}~{ad_id}",
        "status": "PAUSED"
      },
      "updateMask": "status"
    }
  }]
}

Step 8c: CREATE new RSA ads with correct URLs:

POST {base}/googleAds:mutate
{
  "mutateOperations": [{
    "adGroupAdOperation": {
      "create": {
        "adGroup": "customers/9521562011/adGroups/{trial_ag_id}",
        "status": "ENABLED",
        "ad": {
          "finalUrls": ["https://{new_url}"],
          "responsiveSearchAd": {
            "headlines": [{"text": "...", "pinnedField": "HEADLINE_1"}, ...],
            "descriptions": [{"text": "...", "pinnedField": "DESCRIPTION_1"}, ...],
            "path1": "{path1}",
            "path2": "{path2}"
          }
        }
      }
    }
  }]
}

CRITICAL: When copying headlines/descriptions from existing ads, remove any "pinnedField": "UNSPECIFIED" entries — only include pinnedField when it has a real value (HEADLINE_1, HEADLINE_2, etc.).

Landing pages (keyword-level URL override — ALTERNATIVE):

Sets finalUrls at keyword level. Works technically but invisible in Google Ads UI Ads tab. Only use if team doesn't need to verify URLs in UI. See v1.1.0 SKILL.md for details.

Ad copy: Mutate RSA headlines/descriptions in trial Keywords: Add/remove/pause keywords in trial ad groups Bids: Change bid strategy or bid amounts in trial

Step 9: Show summary

Experiment: {name}
Campaign: {campaign_name} (ID: {id})
Type: {change_type}
Split: {split}% treatment / {100-split}% control
Period: {start} → {end}
Goal: {goal_metric} {goal_direction}
Changes: {count} ad groups modified
Trial campaign ID: {trial_id}

4. Safety Rules

  • ALWAYS validateOnly: true before real mutations
  • ALWAYS show diff table and get user confirmation
  • NEVER mutate without explicit approval
  • NEVER modify the original campaign — only the trial
  • If any step fails, stop and report error with full API response

5. Querying Experiment Results

To check running experiment performance:

{"query": "SELECT campaign.name, campaign.experiment_type, metrics.clicks, metrics.impressions, metrics.conversions, metrics.cost_micros FROM campaign WHERE campaign.base_campaign = 'customers/9521562011/campaigns/{campaign_id}' AND segments.date DURING LAST_7_DAYS"}

6. Managing Experiments

List experiments:

{"query": "SELECT campaign.id, campaign.name, campaign.experiment_type, campaign.status FROM campaign WHERE campaign.experiment_type != 'UNSPECIFIED'"}

End experiment early: Promote winner or end without applying

POST {base}/experiments/{exp_id}:promoteExperiment
POST {base}/experiments/{exp_id}:endExperiment

Delete experiment (if endExperiment returns "error code not in this version"):

POST {base}/experiments:mutate
{
  "operations": [{
    "remove": "customers/9521562011/experiments/{exp_id}"
  }]
}

IMPORTANT: Before creating a new experiment on the same campaign, delete or end any existing experiment with overlapping dates. Otherwise arms creation fails with OVERLAPPING_MEMBERS_AND_DATE_RANGE.