# Ads Manager

> Use when managing Apple Search Ads through the Adapty CLI — reading campaign, ad group, keyword or ad performance, changing bids or budgets, adding or pausing keywords, launching or pausing a campaign, creating a whole campaign structure in bulk from JSON or an Apple Ads template, harvesting search terms, or setting up rule-based ad automations.

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

---


# Apple Search Ads through the Adapty CLI

Reads and `automations run --dry-run` cost nothing. Every other `asa` command reaches Apple
within seconds and spends real money, and nothing it creates can be deleted or undone.

Open the reference a workflow names before running its commands:

- `references/asa-management.md` — campaigns, ad groups, ads, keywords, negative keywords,
  product pages, creatives, automations.
- `references/asa-metrics.md` — `metrics`, `metrics overview`, `search-terms list`,
  `competitors summary`.

## The CLI

**Resolve `$ADAPTY` once, before your first `asa` call, and use it for every command you run.** A
global `adapty` is frequently old. The `asa` topic ships in **0.4.0**, but `ad-groups create
--automated` and the five `--invoice-*` flags ship in **0.8.2**, and the `metrics` scope flags and the
`automations` action flags in **0.8.3**, so treat 0.8.3 as this skill's floor. An older install answers
with `unknown command` or an unknown-flag error, which reads like the command does not exist rather
than like a stale CLI:

```bash
adapty --version                                   # >= 0.8.3 ?  ADAPTY="adapty", done
npm i -g adapty@latest >/dev/null 2>&1 \
  && ADAPTY="adapty" \
  || ADAPTY="npx --yes adapty@latest"              # fallback: prefix not writable
```

**Install once; do not wrap every call in `npx`.** The wrapper costs ~1 s *per call* against 0.07 s
installed, and an optimization pass makes dozens of calls. Where the global prefix is not writable
the `npx` form still works, and there `--yes` is not optional: without it npx stops to ask
permission to install, and a headless run has nobody to answer.

**Writing a command out for the user? Expand it to a literal `adapty`.** Their shell has no
`$ADAPTY`. Same split as `--yes` below — what you run carries the variable, what they run carries
the command name.

**In `zsh`, the macOS default, a multi-word `$ADAPTY` is not split into words**, so the command
fails with `command not found: npx --yes adapty@latest`. Run `setopt shwordsplit` once in the same
shell, or write `npx --yes adapty@latest` out in full. That error is a shell problem, never evidence
that the CLI or the command is missing.

Declare an `asa` command unavailable only after `npx --yes adapty@latest` lacks it — never from a
version number you read somewhere.

## Account surface

- `$ADAPTY asa whoami` — company, how access was granted, Apple connection state. Run it first.
- `$ADAPTY asa connect [--no-wait]` — prints the Apple authorization link and waits; `--no-wait`
  returns immediately.
- `$ADAPTY asa orgs list` — ASA organizations. Each row carries two identifiers, not
  interchangeable: `internal_id`, the UUID `--org` takes on `campaigns create` and
  `--campaign-group` takes on the lists that accept scope filters, and `org_id`, Apple's numeric id,
  which both reject. `--org` itself exists only on `campaigns create`; no list accepts it. Each row's
  `payment_model` (`LOC`/`PAYG`) decides whether campaigns in that organization need Invoicing Options.
- `$ADAPTY asa apps list` — apps promoted in Apple Search Ads. Each row carries two identifiers,
  not interchangeable: Apple's numeric adam-id (`--adam-id`) and the ASA app UUID (`--app`).
- Those two and `automations list` take pagination only — no scope filters exist.
- Scope is the token's company. No `asa` command takes `--app` to select scope — `--app` is a
  list filter only.
- `402 ads_manager_subscription_required` — no Ads Manager subscription. `404` — not theirs, or
  does not exist.
- `--json` for machine-readable output on reads; a write under `--json` refuses and exits `2`.
  `--page` (default `1`), `--page-size` (default `100`, max `1000`) on every list — prefer one big
  page to a pagination loop.
- `ADAPTY_ASA_API_URL` overrides the ASA base URL, independently of `ADAPTY_API_URL`.

## The two templates

Fill every slot. A slot you cannot fill from a read is one you ask about, not one you drop.

```
$ADAPTY asa <topic> list --<filter-from-that-list-s-matrix-row> <id-from-a-previous-list> [--status <enum>]
$ADAPTY asa <topic> <create|update|add> [<id>] <field flags> --idempotency-key <key>
```

**The scope filter is a required slot, and the matrix is the only place it comes from** — read that
list's own row in `references/asa-management.md`, `## Scope filters`, and fill the slot from it. A
flag you saw on a create command, or on a different list, is not a filter for this one: `--org`
belongs to `campaigns create`, and the equivalent on the lists that take scope filters at all is
`--campaign-group`. Required on a
session's first list as much as its fifth — a filter changes the query the server ranks, while page
size only changes how much of the wrong answer you see.

**Two entities the user named separately are two lookups.** "The best-performing campaign" and "the
brand ad group" are not parent and child unless the user said so. Resolve each by its own name
(`ad-groups list --search <name>`), and never scope one lookup with an id produced by resolving the
other — that filter asserts a relationship the user did not state, and the write that follows lands
somewhere plausible and wrong without erroring. More than one match is where you ask, not pick.

**`--idempotency-key` is a required slot**, distinct per write in a chain and per batch within a
write. The CLI's auto key covers a network retry inside one invocation, not the person who
re-runs your create step after an ambiguous result — new invocation, fresh key, second campaign.
That, plus `422 cli_idempotency_key_reuse` and `409 cli_idempotency_in_progress`:
`references/asa-management.md`, `## Writes and idempotency`.

**`--yes` is not in the template, because whether it belongs depends on who runs the command.**

- **The user runs it** — you are writing commands out for them. Omit `--yes`. At their terminal the
  CLI prints the exact request body and waits; that prompt is their confirmation, and `--yes` is the
  one flag that deletes it. Nothing in your surrounding text puts it back, so no warning, STOP
  block, or stated assumption substitutes for leaving the flag off.
- **You run it** — in this session. Ask, get an explicit yes, then append `--yes` to the command you
  run.

`metrics`, `metrics overview`, `search-terms list` and `competitors summary` share **2 concurrent
queries per company**: `429 cli_analytics_busy` is a full pool, `429 cli_rate_limit_exceeded` a
full window, and
`429 cli_cooldown_active` the escalating **5m → 30m → 3h** lockout. See
`references/asa-metrics.md`, `## The analytics pool`.

## One question, one call

The server aggregates and the server ranks. Decide the single call that answers the question before
running anything; the metrics budget is **5 calls per minute** (`references/asa-metrics.md`).

- Totals: one `metrics overview` call.
- Best or worst N: one `metrics --metric <m> --order-by <metric> --page-size N` call, `--order asc`
  for worst.
- Trend or period comparison: one call — a per-period series already contains both periods.
- Counting entities: no metrics call at all. Any list with `--page-size 1` returns
  `meta.pagination.count`.
- **`--metric` is required on `metrics`**, and every metric named is computed over the whole entity
  set. Name the columns you will read. (`metrics overview` still takes it optionally.)
- **Scope a `metrics` call** with `--app`, `--campaign` or `--ad-group` — repeatable UUIDs, and the
  one thing that makes a call cheap, since cost follows the entities aggregated and not the page
  size. `subscribers`, `paid_subscribers`, `arppu` and `arpas` are **refused** unless `--campaign` or
  `--ad-group` scopes the call. `metrics overview` takes no scope flag; there you narrow by entity
  level and window, then match rows against ids from a scoped list.
- A window too wide for its bucket is cured by coarsening, never by splitting — and the two commands
  differ at day grain. `metrics`: **28 days** grouped by day, **90** with no period grouping, **180**
  by week, **365** by month and coarser. `metrics overview`: **90** at day, then the same. A year of
  data is one call at `--group-by month` (or `--period-unit month`), not four 90-day calls.
- A `metrics` page is capped at **5000 breakdown rows** (entities × countries × periods); over it the
  call fails `422 cli_response_too_large`. Coarsen, narrow, or scope — never loop smaller pages.

Never sum pages client-side, never call once per period, and never add a comparison the user did not
ask for — propose that in the answer instead.

## Never

- **Never settle a superlative yourself.** "Best-performing", "losing", "terrible ROAS" are
  business definitions, not query results. Name the metric and the window, get the user's yes on
  that definition, then write.
- **Never write without naming the change in chat first** — which campaign, which budget, how
  many keywords.
- **Never put more than 15 keywords in one `keywords add` or `negative-keywords add` call.** The
  cap is 100; the practice is 15.
- **Never loop on a `429`.** The CLI already waited the exact `Retry-After` and retried once, so a
  `429` that reaches you means the budget is genuinely gone. Cut the number of calls, or tell the
  user when to retry.
- **Never guess or probe a metric name.** The vocabulary is in `references/asa-metrics.md`. A wrong
  name fails listing every valid one, so a typo costs one call — spending a call to see what works
  is the failure. Cohort metrics rank by their expanded names: `--order-by gross_roas`, not
  `--order-by roas`.
- **Never invent an id, adam-id, budget or bid.** Read it from the matching list, or ask.
- **Never put `--yes` on a command the user will run.** It deletes the preview they would have read.
  `--yes` belongs only on a command you run yourself, after an explicit yes.
- **Never leave a Max Conversions campaign without its automated ad group**, and never launch into a
  `LOC` organization without the five `--invoice-*` flags. Both create cleanly and then never serve,
  so the failure looks like a working launch until someone reads `serving_status`.
- **Never submit a converted template without reading its `--preview` first**, and never resubmit a
  bulk operation because it timed out. `--timeout` ends the polling, not the operation; a resubmit
  creates the structure twice, and nothing in the `asa` topic can delete either copy.
- **Never write a teardown.** No delete exists in the `asa` topic and there is no undo;
  `--status PAUSED` is the only stop.

## Rationalizations

| What the agent told itself | What is actually true |
|---|---|
| "40 keywords is under the CLI's 100-per-call batch limit, so this is one call, not a loop." | Under the cap is not the same as safe. Nine rejected rows inside a 40-item response is a repair job; inside a 15-item call it is a re-run. |
| "I'd use ROAS over a trailing window as the default definition of "best-performing," and ask the user to confirm/override it before step 5 — but here is the command that answers it." | Handing over the command does not obtain the sign-off you just said had to come first. If the definition needs confirming, stop at the definition. |

## Red Flags — STOP

- A `list` with no scope filter — including the first one, and the one you are "only glancing at"
- A scope filter on `orgs list`, `apps list` or `automations list`
- A filter you did not read off that list's own row in the matrix — a create flag such as `--org`,
  or another list's filter, is not one
- `--yes` on a command you are handing to the user — it deletes the preview they were going to read
- A lookup scoped by an id that came from resolving a different entity the user named separately
- A create in a chain of dependent creates with no `--idempotency-key` pinned
- More than 15 keywords in one `keywords add` or `negative-keywords add` call — the cap does not
  govern a `bulk-create` structure, whose review is the preview instead
- A `bulk-create --from-file` submitted without reading the preview, or an `--org-id` filled with an
  `internal_id` UUID instead of the numeric `org_id`
- A write whose target you picked by your own definition of "best", "losing" or "terrible"
- `--order-by-day` with no matching `--by-days` window in the same call
- You stated a rule, and three commands later are making a silent exception to it

Observed in CLI-side sessions rather than in this skill's own baseline:

- Looping `--page 1..4` to build a total, instead of one `overview` call or one big page
- Spending a call to discover which metric names are valid
- Adding a period comparison the user did not ask for
- Retrying a `429` after a guessed `sleep` instead of the `Retry-After` value

**All of these mean: stop, read first, ask.**

## Workflows

**1. Orient.** `whoami` → `connect` if Apple is unlinked → `orgs list` → `apps list`.
Prerequisite for everything below. → `references/asa-management.md`,
`## Account and discovery`.

**2. Report performance.** Totals and any trend take the first shape; best or worst N takes the
second, with `--order asc` for worst. Dates are required on both, and `--metric` on `metrics` —
without them the command exits before it reaches Apple. Scope `metrics` with `--app`, `--campaign`
or `--ad-group` whenever you can name the ids; `subscribers`, `paid_subscribers`, `arppu` and
`arpas` are refused without `--campaign` or `--ad-group`. Counting needs no metrics call: any list
at `--page-size 1` carries `meta.pagination.count`. `--by-days` takes max **16 windows per call**, and `--order-by-day` may
only name one of those values. → `references/asa-metrics.md`, `## Cohort windows`.

```
$ADAPTY asa metrics overview --entity <level> --date-from <YYYY-MM-DD> --date-to <YYYY-MM-DD> [--period-unit <bucket>]
$ADAPTY asa metrics --entity <ad|ad-group|campaign|keyword> --date-from <YYYY-MM-DD> --date-to <YYYY-MM-DD> --metric <metric> [--campaign <uuid>] --order-by <metric> --page-size <n>
```

**3. Launch a campaign.** Read `orgs list` → `--org` and `apps list` → `--adam-id` first; then
each create consumes an id the previous printed. Neither the order nor any key is optional.
Mint `<run>` once per launch, so a second launch cannot collide. Create the campaign `PAUSED`,
verify the structure, then enable it with workflow 6 — nothing spends until you do. Set
`--match-type` yourself: it defaults to `BROAD`, which is the widest, most expensive targeting. →
`references/asa-management.md`, `## Writes and idempotency`.

```
$ADAPTY asa campaigns create --org <id> --adam-id <adam-id> --name <name> --country <country-code> --daily-budget <amount> --status PAUSED --idempotency-key <run>-camp
$ADAPTY asa ad-groups create --campaign <id> --name <name> --default-bid <amount> --idempotency-key <run>-ag
$ADAPTY asa creatives list --app <app-uuid>   # → --creative-id; no creatives create exists
$ADAPTY asa ads create --ad-group <id> --creative-id <id> --name <name> --idempotency-key <run>-ad
$ADAPTY asa keywords add --ad-group <id> --text <keyword> --match-type <EXACT|BROAD> --idempotency-key <run>-kw-1   # ≤15 per call, fresh key per batch
```

Two things in `orgs list` change the campaign create before you run it. A `payment_model` of `LOC`
makes the five `--invoice-*` flags required — all five in the one call, or the campaign is created and
never serves. And `--bidding-strategy MAX_CONVERSIONS` turns the first two writes into one unit: that
campaign serves only once it owns an ad group created with `--automated`, which takes no
`--default-bid` and no `--start-time`. Ask the user for the invoicing values; never invent them. →
`references/asa-management.md`, `### Max Conversions campaigns` and `### Line of credit organizations`.

**4. Harvest keywords.** Read `search-terms list --ad-group <id> --date-from <YYYY-MM-DD>
--date-to <YYYY-MM-DD>` (dates default to today, so pass them), then
promote converting terms with `keywords add --ad-group <id>` and block wasteful ones with
`negative-keywords add --ad-group <id>` — 15 per call, own key per call. →
`references/asa-metrics.md`, `## The analytics pool`, and `references/asa-management.md`.

**5. Optimization pass.** Read `metrics --entity keyword --date-from <YYYY-MM-DD> --date-to
<YYYY-MM-DD> --metric roas --metric spend --ad-group <id> --by-days 7 --by-days 90 --order-by
gross_roas --order-by-day 90` — `--order asc` asks that same call for the losers instead of the
winners — and `keywords list --ad-group <id> --status ACTIVE` for ids. Get the user's cutoff before
any write. Then `keywords update <ids> --bid <amount>` on winners,
`keywords update <ids> --status PAUSED` on losers, `campaigns update <id> --daily-budget <n>` to
shift spend — each with its own `--idempotency-key`. Confirm the budget separately from the
bids; separate decisions. → `references/asa-metrics.md`, `## Cohort windows`, and
`references/asa-management.md`.

**6. Pause or resume.** Campaigns, ad groups, ads:
`update <id> --status ENABLED|PAUSED --idempotency-key <key>`. Keywords:
`update <id> [<id>…] --status ACTIVE|PAUSED --idempotency-key <key>`. The keyword enum differs
from every other entity's, and there is no `DISABLED` anywhere in the surface. Ids come from a
scoped list first. → `references/asa-management.md`, `## Status`.

**7. Custom product page → ad.** `product-pages sync [--adam-id <adam-id>] --idempotency-key <key>`
— a write, queued rather than awaited. Then `product-pages list --app <app-uuid>` →
`creatives list --app <app-uuid>` →
`ads create --ad-group <id> --creative-id <id> --name <name> --idempotency-key <key>`. →
`references/asa-management.md`.

**8. Diagnose a dead ad or a campaign that never started.** `ads get <id>`, read
`serving_state_reasons`; if that does not explain it, walk up to `ad-groups get <id>` status, then
`campaigns get <id>` status and daily budget. A campaign carries `serving_status` and
`serving_state_reasons` too, and two reasons there have a known fix rather than a diagnosis:
`AUTOMATED_KEYWORDS_REQUIRED_AD_GROUP_MISSING` wants an `ad-groups create --automated`, and
`MISSING_BO_OR_INVOICING_FIELDS` wants the five `--invoice-*` flags on `campaigns update`. All reads.
→ `references/asa-management.md`.

**9. Rule automations.** `automations create --file rule.json --idempotency-key <key>` →
`automations run <id> --dry-run` → `automations runs <id>` to read what it would have done →
`automations update <id> --start`, only after the user has seen that outcome. Dry-run every rule
touching a bid or a budget. For an `add-as-keyword-to` rule, pass the action flags rather than
writing `actions[0].params` by hand — `--target-ad-group`, `--match-type` and `--cpt-bid-type` are
all required and the API has no defaults, and a hand-written block with one key out of place is
accepted with a `200` and then adds keywords to nothing. On `update`, an action flag re-reads the
rule and writes the whole action back, so it overwrites a dashboard edit made in between: say so
first. → `references/asa-management.md`, `### The add-as-keyword action flags`.

**10. Competitor check.** `competitors summary --app-ids <adam-id>,<adam-id>` — **1–5** Apple
App Store IDs. Last full month, every country; no period or country flags exist. Read-only, shares
the analytics pool, slow on a cold cache. → `references/asa-metrics.md`.

**11. Launch many campaigns at once.** One `bulk-create` instead of a loop over workflow 3, for a
whole structure — campaigns → ad groups → keywords/negative keywords/ads. From an Apple Ads template,
convert and read it first; the preview is the review, and there is no undo and no delete for what a
half-right structure creates. From JSON, `--file -` takes the structure on stdin.

```
$ADAPTY asa campaigns bulk-create --from-file <template.xlsx|keywords.csv> --org-id <numeric-org-id> --preview
$ADAPTY asa campaigns bulk-create --file <structure.json> --idempotency-key <run>-bulk
$ADAPTY asa campaigns bulk-status <operation-id>
```

`--org-id` is the numeric `org_id` from `orgs list`, not the `internal_id` UUID `campaigns create
--org` takes — the one command in the surface that wants Apple's number. The command polls to
`success`, `partial` or `failed`; a `partial` leaves the objects that succeeded in place and names the
rest with Apple's reason. `--timeout` ends the polling, never the operation, so read it with
`bulk-status` rather than resubmitting. → `references/asa-management.md`, `### Bulk operations`.

## Anything not covered here

1. This file and its two references are the source of truth for the `asa` surface.
2. `$ADAPTY asa <topic> <command> --help` — exact flag syntax for the installed version.
3. The CLI repo, https://github.com/adaptyteam/adapty-cli (default branch): `README.md`, then
   `skills/adapty-cli/references/cli-commands.md` — both can lag the installed CLI — then
   `src/commands/asa/**`, where a command's own `static flags` declaration outranks any table and
   settles a disagreement between prose and commands.

Do not guess a flag, a command, or a URL path. If none of the three confirms it, say so.

