← All playbooks
Playbook

Apple Ads optimizer

Optimizes the user's Apple Ads (Search Ads) campaigns through their connected Apple Ads account – reads the last 14 days of keyword and search term reports, raises bids on keywords that convert under the target CPA (capped +30% per run), lowers or pauses keywords that spend without installs, adds converting search terms as exact keywords and wasteful ones as negatives – every change a dry run with a diff the user approves.

~5–8 per campaign + 1 per applied change credits a run/apple-ads-optimizer [campaign] [target_cpa]

A weekly pass over Apple Ads campaigns with explicit rules: move budget toward keywords that install under the target cost per acquisition (CPA), away from keywords and search terms that spend without installs, and promote proven search terms to exact keywords. Every change goes through propose_action (dry run, free) and is applied only after the user approves it.

Install

The appgoat/skills repository is private until its public release, so the plugin and git commands below are coming soon. Today, run the playbooks as prompts of the AppGoat MCP server (Claude, Cursor, ChatGPT) or download the SKILL.md from this page.

Install the AppGoat plugin (all playbooks plus the AppGoat MCP server), then run /mcp to sign in.

/plugin marketplace add appgoat/skills
/plugin install appgoat-playbooks@appgoat

Run it by asking for it in plain words, or by name:

/apple-ads-optimizer [campaign] [target_cpa]

When to use

  • The user runs Apple Ads for their own app and connected the Apple Ads account in AppGoat (https://dash.appgoat.ai/connections, provider apple_ads, Apple Ads Platform API). An API user with access to several ad accounts connects one ad_account_id per connection; pick the connection whose account runs the app's campaigns.
  • Not for organic keyword research (use aso-keywords-metadata-rewrite) or Google Ads.

Inputs

  • campaign (optional): a campaign name or id. Default: every campaign with status ENABLED; ask which ones when there are more than five.
  • target_cpa (optional): the most the user wants to pay per install, in the campaign currency. Ask for it if missing; if they don't know, suggest the median CPA of the converting keywords from step 3 and get a yes before using it. Also ask for the daily budget they are comfortable with.

Window: the last 14 full days (end = yesterday, start = end − 13 days, UTC). Limits per run: at most 25 bid changes, 15 new keywords and 25 negatives per campaign.

Steps

Costs in AppGoat credits. Without the MCP, use the REST equivalents in the REST calls below.

  1. list_connections {provider: "apple_ads"} - 0. Keep status: active; mode: read_only can only dry-run (say so). None: send the user to https://dash.appgoat.ai/connections and stop.
  2. read_connection {id, what: "ads", view: "campaigns"} - 1. Campaign ids, status, app (adam id), countries, daily budget. Pick the campaigns.
  3. Per campaign:
    • read_connection {id, what: "ads", view: "ad_groups", campaign_id} - 1 (ad group ids, default bids).
    • read_connection {id, what: "ads", view: "report", level: "keywords", campaign_id, start, end} - 1: per keyword impressions, taps, installs, spend, avg CPT, avg CPA, keyword id, ad group id, bid.
    • read_connection {id, what: "ads", view: "report", level: "searchterms", campaign_id, start, end} - 1: the search terms that triggered the ads, with the keyword that matched them.
    • read_connection {id, what: "ads", view: "negatives", campaign_id} - 1 (existing negatives).
    • For ad groups you will add keywords to: read_connection {view: "keywords", campaign_id, ad_group_id} - 1 each.
  4. Optional, for search terms you cannot judge: keyword_research {terms: ≤ 25 terms, store: "apple", country: <campaign country>} - 1 per term. Popularity 5–100 shows whether a term has real search volume; include: ["top_apps"] shows which apps win it (relevance check).
  5. Apply the decision rules (no calls) and build three lists: bid changes, new exact keywords, negatives.
  6. Propose them as dry runs, one action per ad group and type, with playbook: "apple-ads-optimizer" - 0:
    • propose_action {type: "ads.keyword.bid", connection, params: {campaign_id, ad_group_id, keywords: [{id, bid}] or [{id, status: "PAUSED"}], max_increase: 0.3}} (status is ENABLED or PAUSED; ENABLED resumes a paused keyword)
    • propose_action {type: "ads.keyword.add", connection, params: {campaign_id, ad_group_id, keywords: [{text, match_type: "EXACT", bid}]}}
    • propose_action {type: "ads.negative.add", connection, params: {campaign_id, ad_group_id, keywords: [{text, match_type: "EXACT"}]}} The diffs show current → new bid per keyword; warnings flag bid jumps above max_increase and negatives that would block an enabled keyword.
  7. Show the user the summary table (see Output format) with each action id and the expected effect, and ask which to apply ("all", "only bids", "all except negatives in ad group X").
  8. apply_action {id} for each approved action - 1 per applied action. pending_approval → give the user the approve_url; "needs approval by a person" → hand over the link, don't retry; "account changed" (someone edited bids meanwhile) → show the new diff, re-run step 3 for that ad group rather than forcing. reject_action {id, reason} the declined ones - 0.
  9. Report what changed and when to look again (7 days).

Budget: ~5–8 credits per campaign to read + 1 per applied action (dry runs are free).

Decision rules

Let T = target CPA. Only act on rows with enough data: taps ≥ 10 or spend ≥ T.

  • Raise bid when installs ≥ 3 and CPA ≤ 0.8 × T: new bid = current × min(1.30, T / CPA × 0.9), rounded to 0.05. Never more than +30% per run; skip keywords already at the user's max CPT.
  • Hold when CPA is within 0.8–1.2 × T, or the data threshold is not met.
  • Lower bid when installs ≥ 1 and CPA > 1.2 × T: new bid = current × max(0.70, T / CPA), i.e. at most −30% per run.
  • Pause (status: "PAUSED") when installs = 0 and spend ≥ 2 × T. Lower by 30% instead when installs = 0 and spend is between T and 2 × T, or when the keyword is the app's brand name.
  • Add as EXACT keyword a search term with installs ≥ 2 and CPA ≤ T that is not already a keyword (any match type) in the ad group. Bid = its avg CPT × 1.1 (or the ad group default when the CPT is missing). Put it in the user's exact-match ad group when they have one, else the ad group where it converted. Skip "(low volume terms)" rows: Apple hides those terms.
  • Add as negative (EXACT, ad group level) a search term with taps ≥ 10 and installs = 0 and spend ≥ T, or one that is clearly irrelevant (another product, a different meaning, a job or piracy query) regardless of spend. Prefer EXACT negatives; BROAD only for clearly irrelevant words and only with the user's explicit agreement.
  • Never negate a term that converts (installs ≥ 1 in the window), that equals an enabled keyword in any ad group of the campaign, or the app's own brand. A guardrail warning about a blocked keyword means: drop that negative.
  • Budget: if the proposed raises would push daily spend past the user's budget (estimate: current daily spend × (1 + weighted bid increase)), scale the raises down; never change campaign budgets.
  • Changes compound: one run per week, and never re-raise a keyword raised in the last 7 days (check get_action history or ask).

Output format

# Apple Ads optimizer: <campaign> (<country>) - <start> → <end>, target CPA <T> <cur>

Spend <s> · installs <i> · CPA <c> · <k> keywords, <n> search terms with data

| Change | Ad group | Item | Now | Proposed | Why (14 d) | Action |
|---|---|---|---|---|---|---|
| Raise bid | Exact | habit tracker [EXACT] | 1.00 | 1.30 (+30%) | 14 installs, CPA 1.80 | act_… |
| Pause | Broad | free planner [BROAD] | 0.90 | paused | 0 installs, spend 11.20 | act_… |
| Add keyword | Exact | streak app [EXACT] | – | 0.85 | search term: 4 installs, CPA 2.10 | act_… |
| Negative | Broad | planner printable [EXACT] | – | negative | 22 taps, 0 installs | act_… |

Guardrail warnings: <none | list>.
Reply "apply all", "only bids", or name rows to skip.

Run report: what was applied, what waits for approval (links), and "Re-run in 7 days; the bid changes need about a week of data." Credits used: <sum>.

Automate it

Weekly proposals that a person approves in the dashboard. The run calls apply_action once per action, which submits it for approval (or applies it when the org has a Studio auto-apply rule for the type); nothing changes in Apple Ads until someone approves it at https://dash.appgoat.ai/approvals.

GitHub Action (.github/workflows/appgoat-apple-ads.yml, secrets ANTHROPIC_API_KEY, APPGOAT_API_KEY):

name: AppGoat Apple Ads optimizer
on:
  schedule:
    - cron: "0 6 * * 1"
  workflow_dispatch:
permissions:
  contents: read
jobs:
  ads:
    runs-on: ubuntu-latest
    steps:
      - name: Install AppGoat playbooks
        run: |
          git clone --depth 1 https://github.com/appgoat/skills /tmp/appgoat-skills
          mkdir -p .claude/skills
          cp -r /tmp/appgoat-skills/skills/* .claude/skills/
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: |
            Use the apple-ads-optimizer skill for all enabled campaigns with target CPA <amount> <currency>
            and daily budget <amount>. This is an unattended run: instead of asking in step 7, call apply_action
            once for every proposed action without guardrail warnings (this submits it for approval in the
            dashboard) and reject_action the others with the reason. Never call apply_action with force.
          claude_args: |
            --mcp-config '{"mcpServers":{"appgoat":{"command":"npx","args":["-y","@appgoat/mcp"],"env":{"APPGOAT_API_KEY":"${{ secrets.APPGOAT_API_KEY }}"}}}}'
            --allowedTools mcp__appgoat__list_connections,mcp__appgoat__read_connection,mcp__appgoat__keyword_research,mcp__appgoat__propose_action,mcp__appgoat__apply_action,mcp__appgoat__reject_action,mcp__appgoat__get_action

Claude Code routine:

/schedule mondays 08:00 - Run the apple-ads-optimizer playbook for my enabled Apple Ads campaigns, target CPA <amount>. Submit the proposed changes for approval (apply_action once each) and send me the summary table and the approvals link.

Without MCP: the REST calls

Without the AppGoat MCP server, call the API directly. Base URL https://api.appgoat.ai/v1, header x-api-key: $APPGOAT_API_KEY (create a key at https://dash.appgoat.ai). Responses are { "data": …, "meta": { "credits_used", "credits_remaining", … } }; errors are { "error": { "code", "message", "hint", "approve_url"? } } - read hint.

MCP toolREST call
list_connectionsGET /connections?provider=apple_ads (free)
read_connection view=campaignsGET /connections/{id}/ads?view=campaigns
read_connection view=ad_groupsGET /connections/{id}/ads?view=ad_groups&campaign_id=…
read_connection view=keywordsGET /connections/{id}/ads?view=keywords&campaign_id=…&ad_group_id=…
read_connection view=negativesGET /connections/{id}/ads?view=negatives&campaign_id=…
read_connection view=reportGET /connections/{id}/ads?view=report&level=keywords|searchterms&campaign_id=…&start=YYYY-MM-DD&end=YYYY-MM-DD
keyword_researchGET /keywords?qs=term1,term2&store=apple&country=us
propose_actionPOST /actions with "dry_run": true, "source": "playbook:apple-ads-optimizer" (free)
apply_actionPOST /actions/{id}/approve (1 credit when applied)
reject_actionPOST /actions/{id}/reject (free)

Report rows: { dims, impressions, taps, installs, spend, currency, avg_cpt, avg_cpa, ttr, conversion_rate }; dims holds Apple's row metadata (Apple Ads Platform API: keyword id, text and match type, ad group id and bid for keywords; the search term, its source and the matched keyword for search terms). Keyword status is ENABLED or PAUSED (ACTIVE is accepted as an alias). The connection's account shows the ad_account_id it acts on.

CON=con_… CAMPAIGN=123456
curl -s -G -H "x-api-key: $APPGOAT_API_KEY" "https://api.appgoat.ai/v1/connections/$CON/ads" \
  -d view=report -d level=searchterms -d campaign_id=$CAMPAIGN -d start=2026-10-08 -d end=2026-10-21 \
  | jq -r '.data.rows[] | "\(.dims.searchTerm // .dims.searchTermText)\t\(.taps)\t\(.installs)\t\(.spend)"'

# Bids for one ad group, as a dry run
curl -s -H "x-api-key: $APPGOAT_API_KEY" -H "content-type: application/json" https://api.appgoat.ai/v1/actions \
  -d '{"type":"ads.keyword.bid","connection":"'$CON'","dry_run":true,"source":"playbook:apple-ads-optimizer",
       "params":{"campaign_id":"'$CAMPAIGN'","ad_group_id":"789","max_increase":0.3,
                 "keywords":[{"id":"111","bid":1.3},{"id":"112","status":"PAUSED"}]}}' | jq '.data | {id, diff, guardrail}'

More playbooks

/app-audit-lite~25–40 credits a run

App audit (lite)

Runs a quick App Store / Google Play audit of one app with live AppGoat data – listing quality, ratings trend, recent complaints, keyword rankings, competitors and their recent changes – and ends with a prioritized fix list.

/find-app-ideas~150–250 credits a run

Find app ideas

Finds underserved App Store / Google Play niches with live AppGoat data – search demand (keyword popularity) against weak supply (low ratings, abandoned incumbents, few strong apps) – and returns a ranked shortlist of app ideas with evidence and user pain points.

/aso-keywords-metadata-rewrite~40–80 credits a run

ASO keywords → metadata rewrite

Researches App Store / Google Play keywords for one app with live AppGoat data (current ranks, competitor keyword gaps, popularity, difficulty, relevance) and rewrites the title, subtitle, keyword field and description within the store character limits.

/competitor-digest~15–55 credits a run

Competitor digest

Writes a weekly competitor digest for one App Store / Google Play app from an AppGoat competitor watch – what each competitor changed (title, subtitle, description, screenshots, icon), price and in-app purchase changes, new versions with release-note summaries, rating and chart moves, keyword gains and losses against the user's app, keyword gaps and review themes – and ends with 3 recommended actions linked to other playbooks. Sets up or reuses the competitor_set watch.

/reviews-to-github-issues~2–10 credits a run

Reviews → GitHub issues

Turns recent negative App Store / Google Play reviews into deduplicated GitHub issues – one issue per problem topic, with linked and quoted reviews, new reviews added as comments to the existing issue instead of duplicates. Uses AppGoat review data and the gh CLI (or the GitHub MCP server).

/reply-to-every-review~1–3 + 1 per posted reply credits a run

Reply to every review

Drafts a reply to every unanswered App Store and Google Play review of the user's own app, in the app's voice, through their connected App Store Connect / Play Console account – each reply is a dry run with a before/after diff and AppGoat guardrail scores (policy, tone, claims), and only the replies the user approves are posted.

/localization-push~35–60 per locale + 1 per applied change credits a run

Localization push

Localizes the user's own App Store / Google Play listing into new languages through their connected App Store Connect or Play Console account – picks target locales from markets with demand, researches the keywords people actually search in each country with AppGoat, writes a localized name, subtitle, keyword field and promotional text (Play title, short and full description) within the store limits, and pushes each locale as a dry run with a before/after diff the user approves.