Skip to content
Closed
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ src/
output.ts # printResponse(), printList() helpers (auto-formats snake_case keys)
app-url.ts # dashboard base URL (ADAPTY_APP_URL): route building + rehosting API-issued links
asa-client.ts # factory: ApiClient against the ASA service (errorFormat 'asa')
asa-flags.ts # shared asa flags: scope filters, period, money, batch caps
asa-flags.ts # shared asa flags: scope filters, period, money, batch caps, add-as-keyword action
asa-confirm.ts # mutation preview + confirmation prompt (--yes; refuses when piped or --json)
asa-keyword-action.ts # add-as-keyword-to params: rebuild from flags per operate_with, reject
# the shapes the API would silently mis-resolve
asa-schemas.ts # response typings for asa entities
preview.ts # flow config normalization + render URL / gzip fragment building
```
Expand Down
134 changes: 132 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,142 @@ adapty asa product-pages sync [--adam-id 123456]

adapty asa automations list
adapty asa automations get AUTOMATION_ID
adapty asa automations create --file rule.json [--run-now]
adapty asa automations update AUTOMATION_ID [--stop] [--start] [--name "..."] [--file rule.json]
adapty asa automations create --file rule.json [--run-now] [--target-ad-group UUID ...]
adapty asa automations update AUTOMATION_ID [--stop] [--start] [--name "..."] [--file rule.json] [--target-ad-group UUID ...]
adapty asa automations run AUTOMATION_ID [--dry-run]
adapty asa automations runs AUTOMATION_ID
```

A rule file carries the whole rule: `name`, `status` (1 active, 0 stopped), `operate_with` (what the
rule iterates over), `apply_to` (where it looks), exactly one condition, exactly one action and a
`run_frequency`. The API stores one action and one condition per rule and rejects anything else.

| Field | Shape |
| --------------- | ------------------------------------------------------------------------------------------- |
| `operate_with` | `search-term`, `targeting-keyword`, `campaign`, `ad-group` |
| `apply_to[]` | `{"internal_id": UUID, "type": "campaign-group" \| "app" \| "campaign" \| "ad-group" \| "targeting-keywords"}` |
| `conditions[0]` | `{"operator": ..., "args": number, "operand": {...}}`, or `{"operator": "and" \| "or", "args": [condition, ...]}` |
| `operator` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` for a leaf; `and`, `or` to nest |
| `operand.field` | a metric name in dashboard nomenclature — the same vocabulary `asa metrics --metric` takes |
| `date_range_type` | `today`, `yesterday`, `last_1_d`, `last_3_d`, `last_7_d`, `last_14_d`, `last_28_d`, `last_30_d`, `last_60_d`, `last_90_d`, `custom` |
| `run_frequency` | `{"type": "daily", "hour": 8}`, `{"type": "hour", "value": 24, "start_time": 8}`, `{"type": "weekly", "weekdays": ["monday"], "hour": 8}`, `{"type": "monthly", "days": [1], "hour": 8}`, `{"type": "once", "date_time": "2026-10-01T09:00:00Z"}` — `hour` and `start_time` are UTC |

The `add-as-keyword-to` action promotes what the rule found into a keyword in one or more target ad
groups. Its `params` differ by `operate_with`, and the API picks the variant by shape without a
discriminator: a key that belongs to another variant makes it silently choose that variant and drop
the rest, which is how a rule ends up as "Add as keyword to 0 ad groups". `negate` and
`skip_enable_duplicate_keywords` exist only on a `search-term` rule, `pause_in_original_ad_group`
only on a `targeting-keyword` one, and `targets` holds `internal_ids` — never `ids`.

A full `search-term` rule — every term with 10+ taps over the last week becomes an exact keyword in
the target ad group at the term's own CPT, and is negated in the ad group it came from:

```json
{
"name": "Search term harvester",
"status": 1,
"operate_with": "search-term",
"apply_to": [{"internal_id": "CAMPAIGN_UUID", "type": "campaign"}],
"conditions": [
{
"operator": "gte",
"args": 10,
"operand": {
"field": "taps",
"field_type": "base_field",
"date_range_type": "last_7_d",
"date_range_size": 0,
"date_range_offset": 0,
"by_days": null
}
}
],
"actions": [
{
"type": "add-as-keyword-to",
"params": {
"targets": {"type": "ad-group", "internal_ids": ["AD_GROUP_UUID"]},
"cpt_bid": {"type": "search_term_current_cpt", "value": null},
"match_type": "EXACT",
"negate": {"enabled": true, "type": "ad-group"},
"skip_enable_duplicate_keywords": false
}
}
],
"run_frequency": {"type": "daily", "hour": 8}
}
```

The same action on a `targeting-keyword` rule — a broad keyword that has earned installs graduates
into the exact ad group at its current bid and is paused where it was:

```json
{
"name": "Graduate broad keywords to exact",
"status": 1,
"operate_with": "targeting-keyword",
"apply_to": [{"internal_id": "SOURCE_AD_GROUP_UUID", "type": "ad-group"}],
"conditions": [
{
"operator": "gte",
"args": 3,
"operand": {
"field": "total_installs",
"field_type": "base_field",
"date_range_type": "last_14_d",
"date_range_size": 0,
"date_range_offset": 0,
"by_days": null
}
}
],
"actions": [
{
"type": "add-as-keyword-to",
"params": {
"targets": {"type": "ad-group", "internal_ids": ["EXACT_AD_GROUP_UUID"]},
"cpt_bid": {"type": "keyword_current_bid", "value": null},
"match_type": "EXACT",
"pause_in_original_ad_group": true
}
}
],
"run_frequency": {"type": "daily", "hour": 8}
}
```

Rather than hand-writing that `params` block, pass the action flags — they fill in or override
`actions[0].params` in the file, and the CLI refuses a rule the API would have quietly accepted and
broken:

```sh
adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \
--match-type EXACT --cpt-bid-type search_term_current_cpt --negate ad-group
adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \
--match-type BROAD --cpt-bid-type set_to --cpt-bid 1.50 --no-negate --skip-enable-duplicates
adapty asa automations update AUTOMATION_ID --target-ad-group AD_GROUP_UUID \
--match-type EXACT --cpt-bid-type search_term_current_cpt
```

| Flag | Where it lands |
| -------------------------- | --------------------------------------------------------------------- |
| `--target-ad-group` | `targets.internal_ids`, repeatable; a rule without one does nothing |
| `--match-type` | `match_type`: `BROAD` or `EXACT` |
| `--cpt-bid-type` | `cpt_bid.type`: `ad_group_default_bid`, `set_to`, `search_term_current_cpt`, `keyword_current_bid` |
| `--cpt-bid` | `cpt_bid.value`: the bid itself with `set_to` (required), a percent markup on the entity's own bid with `search_term_current_cpt` / `keyword_current_bid`, rejected with `ad_group_default_bid` |
| `--negate` / `--no-negate` | `negate` (search-term rules): `ad-group` or `campaign`, or off |
| `--skip-enable-duplicates` | `skip_enable_duplicate_keywords` (search-term rules) |
| `--pause-original` | `pause_in_original_ad_group` (targeting-keyword rules) |

`--cpt-bid-type` and `--match-type` have no defaults, here or in the API: a bid and a match type are
a spend decision and a reach decision, so when `params` are built from scratch the CLI asks for them
instead of guessing. On `update`, an action flag turns the call into a read-modify-write — the rule
is read, `actions[0].params` is rebuilt and the whole `actions` list is written back, since the API
replaces it wholesale. That also repairs a rule whose stored `params` carry the wrong shape: only
the keys that fit are kept, the strays are dropped, and anything missing has to come from a flag. A
dashboard edit made between the read and the write is overwritten.


Metrics take an entity level, a period and an optional metric selection. Rows come back one per entity,
aggregated and sorted server-side, so a top-N or a breakdown is a single call — use `--order-by` with a small
`--page-size` for rankings, `metrics overview` for account totals and time series, and one big page (up to
Expand Down
65 changes: 63 additions & 2 deletions docs/agent/asa-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,72 @@ Invoicing Options. They map to `loc_invoice_details` in the request: advertiser
|---|---|---|
| `asa automations list` | pagination only, no scope filters | `status` in the response is `1` for active, `0` for stopped. |
| `asa automations get <id>` | positional UUID | Same `status` convention as `list`. |
| `asa automations create` | `--file rule.json` (or `--file -` for stdin) | `--run-now` queues the rule's first run immediately after creation. |
| `asa automations update <id>` | one or more of `--stop`, `--start`, `--name`, `--file` | If you pass `--file`, that file must not carry `internal_id` — the CLI treats a JSON body with `internal_id` in it as an error, since that field is server-assigned. |
| `asa automations create` | `--file rule.json` (or `--file -` for stdin); for an `add-as-keyword-to` action also `--target-ad-group`, `--match-type`, `--cpt-bid-type`, `--cpt-bid`, `--negate` / `--no-negate`, `--skip-enable-duplicates`, `--pause-original` | `--run-now` queues the rule's first run immediately after creation. The file must carry exactly one action and exactly one condition — see [Rule files](#rule-files). |
| `asa automations update <id>` | one or more of `--stop`, `--start`, `--name`, `--file`, or any action flag from `create` | If you pass `--file`, that file must not carry `internal_id` — the CLI treats a JSON body with `internal_id` in it as an error, since that field is server-assigned. An action flag makes this a read-modify-write: two calls, and the whole `actions` list is replaced — see [Rule files](#rule-files). |
| `asa automations run <id>` | `--dry-run` optional | Queued; the command prints a run id. `--dry-run` evaluates the rule and logs what it would do without touching Apple. |
| `asa automations runs <id>` | positional UUID | Past runs for this automation, dry runs included. |

### Rule files

A rule file is the whole rule. `name`, `status` (`1` active, `0` stopped), `operate_with`
(what the rule iterates over: `search-term`, `targeting-keyword`, `campaign`, `ad-group`),
`apply_to` (where it looks), exactly one `conditions` entry, exactly one `actions` entry
and a `run_frequency`. Anything else is rejected.

| Field | Shape |
|---|---|
| `apply_to[]` | `{"internal_id": UUID, "type": "campaign-group" \| "app" \| "campaign" \| "ad-group" \| "targeting-keywords"}` |
| `conditions[0]` | a leaf `{"operator": "gte", "args": 10, "operand": {...}}`, or `{"operator": "and" \| "or", "args": [leaf, leaf]}` to combine |
| `operator` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` on a leaf; `and`, `or` to nest |
| `operand` | `{"field": metric, "field_type": "base_field", "date_range_type": ..., "date_range_size": 0, "date_range_offset": 0, "by_days": null}` — `field` is a metric name from the same vocabulary `asa metrics --metric` takes, `by_days` only for a cohort metric |
| `date_range_type` | `today`, `yesterday`, `last_1_d`, `last_3_d`, `last_7_d`, `last_14_d`, `last_28_d`, `last_30_d`, `last_60_d`, `last_90_d`, `custom` |
| `run_frequency` | `{"type": "daily", "hour": 8}`, `{"type": "hour", "value": 24, "start_time": 8}`, `{"type": "weekly", "weekdays": ["monday"], "hour": 8}`, `{"type": "monthly", "days": [1], "hour": 8}`, `{"type": "once", "date_time": "2026-10-01T09:00:00Z"}`; hours are UTC |

**Never copy an action's `params` from another rule.** `params` is a union the API resolves
by shape, with no discriminator: a key that belongs to a different action makes it pick that
action's variant and silently drop everything else, and the call still answers `200`. An
`add-as-keyword-to` action given the `add-as-negative-keyword` shape (`{"target_type": ...,
"ids": [...]}`) becomes "Add as keyword to 0 ad groups" — the ad group is right there in
`ids` and the rule does nothing. `targets` on this action holds `internal_ids`, never `ids`.

Which `params` keys `add-as-keyword-to` takes depends on `operate_with`:

| `operate_with` | `params` |
|---|---|
| `search-term` | `targets`, `cpt_bid`, `match_type`, `negate`, `skip_enable_duplicate_keywords` |
| `targeting-keyword` | `targets`, `cpt_bid`, `match_type`, `pause_in_original_ad_group` |

So don't write that block by hand — pass the action flags and let the CLI build it. They
fill in or override `actions[0].params`, and the CLI exits `2` on the mistakes the API
would have accepted: a flag that does not belong to the rule's `operate_with`, an action
that is not `add-as-keyword-to`, or a rule left with no target ad groups.

```sh
adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \
--match-type EXACT --cpt-bid-type search_term_current_cpt --negate ad-group
adapty asa automations run AUTOMATION_UUID --dry-run
```

| Flag | Lands in | Values |
|---|---|---|
| `--target-ad-group` | `targets.internal_ids` | repeatable UUID; a rule with none does nothing |
| `--match-type` | `match_type` | `BROAD`, `EXACT` |
| `--cpt-bid-type` | `cpt_bid.type` | `ad_group_default_bid`, `set_to`, `search_term_current_cpt`, `keyword_current_bid` |
| `--cpt-bid` | `cpt_bid.value` | the bid itself with `set_to` (required); a percent markup on the entity's own bid with `search_term_current_cpt` / `keyword_current_bid`; rejected with `ad_group_default_bid` |
| `--negate` / `--no-negate` | `negate` | `ad-group`, `campaign`, or off — `search-term` rules only |
| `--skip-enable-duplicates` | `skip_enable_duplicate_keywords` | `search-term` rules only |
| `--pause-original` | `pause_in_original_ad_group` | `targeting-keyword` rules only |

`--cpt-bid-type` and `--match-type` have no default in the CLI or in the API — a bid and a
reach setting are the user's decision. When `params` are built from scratch the CLI asks for
them instead of guessing, so get them from the user rather than picking one.

On `update`, an action flag reads the rule, rebuilds `actions[0].params` and writes the whole
`actions` list back, because the API replaces `actions` wholesale rather than merging it.
That is also how a rule with the wrong `params` shape is repaired: the params are rebuilt
from scratch, only the keys that fit the expected shape are kept, and whatever is missing has
to come from a flag. A dashboard edit made between the read and the write is overwritten.

## Scope filters

Filters narrow the query itself, not the printed page: an unfiltered `asa keywords list`
Expand Down
Loading
Loading