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:
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-waitreturns immediately.$ADAPTY asa orgs list— ASA organizations. Each row carries two identifiers, not interchangeable:internal_id, the UUID--orgtakes oncampaigns createand--campaign-grouptakes on the lists that accept scope filters, andorg_id, Apple's numeric id, which both reject.--orgitself exists only oncampaigns create; no list accepts it. Each row'spayment_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 listtake pagination only — no scope filters exist. - Scope is the token's company. No
asacommand takes--appto select scope —--appis a list filter only. 402 ads_manager_subscription_required— no Ads Manager subscription.404— not theirs, or does not exist.--jsonfor machine-readable output on reads; a write under--jsonrefuses and exits2.--page(default1),--page-size(default100, max1000) on every list — prefer one big page to a pagination loop.ADAPTY_ASA_API_URLoverrides the ASA base URL, independently ofADAPTY_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--yesis 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
--yesto 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 overviewcall. - Best or worst N: one
metrics --metric <m> --order-by <metric> --page-size Ncall,--order ascfor 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 1returnsmeta.pagination.count. --metricis required onmetrics, and every metric named is computed over the whole entity set. Name the columns you will read. (metrics overviewstill takes it optionally.)- Scope a
metricscall with--app,--campaignor--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,arppuandarpasare refused unless--campaignor--ad-groupscopes the call.metrics overviewtakes 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
metricspage is capped at 5000 breakdown rows (entities × countries × periods); over it the call fails422 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 addornegative-keywords addcall. The cap is 100; the practice is 15. - Never loop on a
429. The CLI already waited the exactRetry-Afterand retried once, so a429that 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
--yeson a command the user will run. It deletes the preview they would have read.--yesbelongs 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
LOCorganization without the five--invoice-*flags. Both create cleanly and then never serve, so the failure looks like a working launch until someone readsserving_status. - Never submit a converted template without reading its
--previewfirst, and never resubmit a bulk operation because it timed out.--timeoutends the polling, not the operation; a resubmit creates the structure twice, and nothing in theasatopic can delete either copy. - Never write a teardown. No delete exists in the
asatopic and there is no undo;--status PAUSEDis 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
listwith no scope filter — including the first one, and the one you are "only glancing at" - A scope filter on
orgs list,apps listorautomations 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 --yeson 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-keypinned - More than 15 keywords in one
keywords addornegative-keywords addcall — the cap does not govern abulk-createstructure, whose review is the preview instead - A
bulk-create --from-filesubmitted without reading the preview, or an--org-idfilled with aninternal_idUUID instead of the numericorg_id - A write whose target you picked by your own definition of "best", "losing" or "terrible"
--order-by-daywith no matching--by-dayswindow 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..4to build a total, instead of oneoverviewcall 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
429after a guessedsleepinstead of theRetry-Aftervalue
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
- This file and its two references are the source of truth for the
asasurface. $ADAPTY asa <topic> <command> --help— exact flag syntax for the installed version.- The CLI repo, https://github.com/adaptyteam/adapty-cli (default branch):
README.md, thenskills/adapty-cli/references/cli-commands.md— both can lag the installed CLI — thensrc/commands/asa/**, where a command's ownstatic flagsdeclaration 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.