# MerchDash automation language

This file teaches an AI assistant to write MerchDash automation rules. Hand it over along
with what the rule should do, and have the assistant send the script through the public
API. It is generated from the engine's own language definition, so it lists exactly
what the rule validator accepts. The human-readable version is `/docs/language`.

## Writing a rule through the API

A rule's script is the `code` field of `POST /api/v1/rules`. The key needs the
`automation` scope. The dependable loop:

1. **Check the script** with `POST /api/v1/rules/validate` (`{"code": "…"}`). It answers
   `{"valid": true}`, or `{"valid": false, "error": "…", "line": 4}`. Fix and repeat.
   Nothing is saved or run.
2. **Dry-run it** with `POST /api/v1/rules/preview` (`{"profileId": "…", "code": "…"}`), then
   poll `GET /api/v1/rules/preview/{jobId}` until `status` is `done`. You get how many
   changes it would make, a sample of them, and its log. Nothing is written.
3. **Save it** with `POST /api/v1/rules`. It starts in review mode, on a manual schedule,
   switched off.
4. **Run it** with `POST /api/v1/rules/{ruleId}/run`, read what it proposed, and apply or
   discard the review. `GET /api/v1/runs/{runId}/log` shows the lines the rule wrote with
   `log()` and MerchDash's own notes on what it skipped and why.

Rules that most often go wrong: every `FOR EACH` ends with `END`; bodies are indented
two spaces; fields are read through the loop variable (`keyword.clicks`, never
`clicks`); every computed bid or budget is bounded with `MIN()`/`MAX()`; and anything
that adjusts values carries a `DAYS_SINCE_CHANGE()` cool-down.

## Reference

```text
MerchDash automation language (object-v1) — complete authoritative reference.
Do not use syntax, variables, fields, relations, functions, or methods that are not listed below.

Core rules:
  - A rule contains one or more FOR EACH blocks. Every FOR EACH block must contain at least one step and end with END.
  - Indentation defines IF/WHEN bodies. Use two spaces. Bodies can nest; steps directly under FOR EACH always run.
  - An IF can be followed, at its own indentation, by "ELSE IF condition:" and "ELSE:"; only one branch of the chain runs. "Check A, and if that finds nothing check B, and if that finds nothing do C" is an IF/ELSE chain, or a counter: declare "LET found = 0" inside the loop body, "SET found = found + 1" inside a child loop (the value survives the loop), then "IF found = 0:" after it. A counter of 0 also means the loop had no rows at all (an ad group with no keywords, a window in which no search term delivered), so when "found = 0" leads to a pause, archive or negative, also count every row ("LET checked = 0", "SET checked = checked + 1" as the loop's first step) and write "IF checked > 0 AND found = 0:". Only a LET inside a loop body can be SET, never a top-level LET. A body works out its LETs and notes BEFORE anything else runs, so never copy a SET variable into a LET or write it in a note beside the loop — read it in an IF, SET or note placed after the loop.
  - Read every entity field through an object, such as keyword.clicks. Bare entity fields are invalid.
  - There is no INCREASE or DECREASE statement. Calculate the new value and pass it to setBid/setBudget.
  - A block range is optional and defaults to LAST 30 DAYS. Settled rolling windows exclude the newest 2 attribution days.
  - TODAY and N DAYS AGO are unlagged UTC windows and may contain partial data.
  - Inline metric windows use "expression IN range" or "expression OVER range". They bind looser than AND; use parentheses to control scope.
  - Keywords, built-in functions/methods, entities, fields, relations, and named literals are case-insensitive. Use lowercase user-defined variable names: LET names are normalized to lowercase, while AS loop-variable names must be referenced exactly as written.
  - A physical expression line may continue after an open parenthesis or a trailing/leading operator.
  - Keep safety limits in formulas, such as MAX($0.05, ...) and sensible bid/budget caps.
  - Never invent syntax. Always write the entire rule, never a fragment.
  - Accumulated reports answer "what did this keyword/ASIN do across ALL of its campaigns", which an ordinary FOR EACH cannot: a keyword row is one keyword in one ad group, and a product row is one ad in one campaign. Write "CREATE ACCUMULATED KEYWORD REPORT IN LAST 60 DAYS" (or ASIN) on its own line above the loop, then "FOR EACH accumulated_keyword:" / "FOR EACH accumulated_asin:". Use them whenever the user says across campaigns, overall, in total, everywhere, or wants to act on a keyword/ASIN as one thing. The rules, which the validator enforces: one report of each kind per rule (a second CREATE of the same kind is refused — put a second timeframe in a second rule); the loop variable is fixed, so no AS and no timeframe on the FOR EACH; at most 180 days and never LIFETIME; no relation hops (accumulated_keyword.campaign does not exist — the row spans many, so ask the counts: campaigns, adGroups, liveCampaigns, liveAdGroups) and no inline windows or METRIC()/LAST_CHANGE()/DAYS_SINCE_CHANGE() (the row is already summed over the report's one timeframe). Rows where nothing can still deliver are dropped automatically, so a pause rule never re-pauses what is already off; put "# INCLUDE PAUSED KEYWORDS" or "# INCLUDE PAUSED ASINS" above the CREATE line only when the user explicitly wants the fully paused ones back. When the user DOES ask for the active/live/running ones (or asks to "turn off the active ones"), write "# ACTIVE ONLY" above the CREATE line even though it is the default: it changes nothing, but it puts their intent in the script where they can see it was understood, and say in your reply that it is the default. Never claim a rule needs it to be active-only. Actions are the fan-out ones — pauseEverywhere(), setBidEverywhere(v), negateEverywhere([match]) — because each acts on every instance the row summed; pause()/setBid()/addNegative() do not exist on these rows. The report is temporary and never appears under Reports.
  - An accumulated ASIN row has CHILD LOOPS, and they are the answer whenever the user judges an ASIN across all its campaigns but wants to act on BIDS: "FOR EACH keyword IN accumulated_asin:" (also target, also adGroup) iterates the current rows of every ad group that ASIN delivered through in the report timeframe, each a whole row you can read and act on normally. Nothing is withheld: paused ad groups and ad groups advertising several ASINs are included, because narrowing is the rule's decision to make in the open. This exists because an ASIN has no bid of its own — there is no setBidEverywhere() for an ASIN — so "raise the bid 10% on ASINs with >10 orders and CPO under $2.50 in 60 days" is written as the report deciding WHICH ASINs, and the child loop doing the raising. Write that rule, do not refuse it and do not hand the user a log() of qualifying ASINs to paste into a second rule. Keywords and targets hang directly off the accumulated row, so put the bid loop directly under accumulated_asin rather than going through its ad groups. Because an ad group can advertise several ASINs, its keywords are not exclusively the winner's: when precision matters guard with COUNT(ASINS(keyword.adgroup)) = 1, and cap the blast radius with COUNT(KEYWORDS(keyword.adgroup)) <= 20 — mention the trade-off in your reply rather than silently picking. Keep the child loop's own conditions to state and bid; re-filtering it on keyword metrics is what makes these rules slow. Inside the child loop a bare placeholder names the CHILD row, so a note about the ASIN must name the outer row: write "{accumulated_asin.asin}" and "{accumulated_asin.cpo:money}", never "{asin}" or "{cpo}" — those read the keyword, which has neither and would render the literal braces.

Statements:
  - LET name = expression — Create a user-defined variable. Top-level LET variables are evaluated before and visible in every block; LET inside a body is visible in that body and nested bodies.
  - FOR EACH entity [AS name] [IN|OVER|USING range]: — Loop over an entity. The default variable is the entity name and the default range is LAST 30 DAYS.
  - FOR EACH product [AS name] IN <ad group or campaign> [OVER range]: Aliases: FOR EACH keyword IN <ad group or campaign>, FOR EACH target IN <ad group or campaign>, FOR EACH adGroup IN <campaign>, FOR EACH keyword IN accumulated_asin, FOR EACH target IN accumulated_asin, FOR EACH adGroup IN accumulated_asin. — The CHILD loop: written INSIDE a block, it iterates what is inside the row already in hand, "FOR EACH product IN searchTerm.adGroup:" walks the ASINs advertised in that search term's ad group, each a full row whose fields and metrics read exactly as they do in a top-level loop. This is how one rule crosses two dimensions: a campaign per search term AND per ASIN. What follows IN is the PARENT OBJECT, never a timeframe, the timeframe goes after OVER, and defaults to the outer block's. Ten pairings exist. Seven are the contents ASINS()/KEYWORDS()/TARGETS()/AD_GROUPS() list: products, keywords, targets and ad groups from a campaign, products, keywords and targets from an ad group. The other three hang off an accumulated ASIN row, "FOR EACH keyword IN accumulated_asin:", target, or adGroup, and iterate current rows in enabled, single-ASIN ad groups where that ASIN delivered in the report timeframe. Shared groups are skipped because their bids cannot be attributed to one ASIN safely. Child loops nest two levels deep under a top-level loop and no deeper, and each parent is read once per run however many outer rows point at it. The same loop can OPEN a rule, with a LET (or the lookup itself) as the parent: "LET camp = FIND_CAMPAIGN("Brand - Exact")" then "FOR EACH keyword IN camp OVER 8..35:" works on that one campaign and reads nothing else in the account. A parent that is NONE reads nothing and the run log says so. Search terms are not a child of anything, loop over them as a table and test searchTerm.campaign.name.
  - FOR EACH suggestedKeyword [AS name] IN <ad group or product>: Aliases: FOR EACH suggestedAsin IN <ad group or product>. — Amazon's own suggestions, as a child loop: "FOR EACH suggestedKeyword IN adGroup:" walks the keywords Amazon suggests for that ad group (or, written IN a product, for that ASIN), one row per keyword AND match type, with Amazon's suggested bid. "FOR EACH suggestedAsin IN adGroup:" walks the products Amazon suggests targeting for the ASINs the ad group advertises. They are asked of Amazon, not read from MerchDash's data, so they only exist inside another loop, each ad group or product is one request (at most 300 per run, so narrow the loop around it), and they are Sponsored Products only. Nothing is created by reading them, act on a row with adGroup.createKeyword(row.keywordText, row.matchType, row.bid) or adGroup.createTarget(PRODUCT_TARGET(row.asin)), guarded by row.alreadyTargeted.
  - CREATE ACCUMULATED KEYWORD REPORT IN range Aliases: CREATE ACCUMULATED ASIN REPORT IN range. — Build a temporary rollup for this run: each keyword (text + match type) or ASIN summed across EVERY campaign it delivered in. Iterate it with "FOR EACH accumulated_keyword:" / "FOR EACH accumulated_asin:", whose loop variable is fixed and takes no AS and no timeframe of its own. One report of each kind per rule, at most 180 days, and rows with nothing left running are dropped unless "# INCLUDE PAUSED KEYWORDS" / "# INCLUDE PAUSED ASINS" sits above the CREATE line, write "# ACTIVE ONLY" there to state that default explicitly. It exists only for the run, nothing appears under Reports. An ASIN row also has children: "FOR EACH keyword IN accumulated_asin:" (or target, or adGroup) iterates current rows in enabled, single-ASIN ad groups where it delivered. Paused and shared-product groups are skipped so the rule cannot attribute a shared bid to the wrong ASIN.
  - IF condition: Aliases: WHEN condition:. — Run the indented body when the condition is true. IF/WHEN bodies may nest.
  - ELSE IF condition: / ELSE: Aliases: ELSE:. — Written directly under an IF, at its indentation: ELSE IF tests another condition when the IF above did not match, and ELSE runs when none of them did. Only one branch of the chain runs.
  - SET name = expression — Change a variable that a LET in the same body, or in a body around it, declared, and have the change survive the loop it was made in. "LET found = 0", then "SET found = found + 1" inside a child loop, then "IF found = 0:" after it. Only LETs inside a loop body can be SET (top-level LETs are worked out once, before any loop), each name may be declared once, and the value must be a number, text or TRUE/FALSE. Bodies run their LETs and notes first and the rest in written order, so read a SET variable directly after the loop (an IF, a SET, or a note in an IF body), never in a LET or note written beside it: those are worked out before the loop has run.
  - END — Close a FOR EACH block.
  - # comment — Ignore the remainder of the physical line, except # inside a quoted string.
  - # INCLUDE ZERO IMPRESSIONS Aliases: # EXCLUDE ZERO IMPRESSIONS. — Force keyword/target loops below this line to include (or skip) entities with no impressions in the window. Without it MerchDash decides per loop: silent entities are included when the condition could match a zero-metric row and the actions are limited to setBid/note.
  - # ACTIVE ONLY Aliases: # ACTIVE ONLY KEYWORDS, # ACTIVE ONLY ASINS, # INCLUDE PAUSED KEYWORDS, # INCLUDE PAUSED ASINS. — Above a CREATE ACCUMULATED … REPORT line: whether the report keeps rows that can no longer deliver (every instance paused). Active only is the DEFAULT, "# ACTIVE ONLY" changes nothing and exists so the setting is visible in the script, the same thing the Accumulated tab's "Active only" tick does. "# INCLUDE PAUSED KEYWORDS" / "# INCLUDE PAUSED ASINS" keeps them. Without a noun it covers both reports; with one it covers that report only.
  - # INCLUDE PAUSED CAMPAIGNS Aliases: # EXCLUDE PAUSED CAMPAIGNS. — Force loops below this line to include (or skip) entities inside paused campaigns, overriding the "Skip paused and archived campaigns" setting in Profile → General. Archived campaigns are always skipped, whatever the setting and whatever the rule, Amazon rejects every write into one. Without it MerchDash decides per loop: a loop that can enable()/setState(), or that reads campaign state itself (including through a LET-bound campaign.state), keeps its paused campaigns automatically.

Variables and scope:
  - User-defined identifiers may be created with LET at the top level or inside a block body.
  - FOR EACH binds the default loop variables campaign, adGroup, keyword, target, searchTerm, product, or portfolio; AS may replace the name.
  - A LET value may be a scalar, list, calculation, function result, or related entity object.
  - Entity fields are never implicit variables: use object.field, such as keyword.clicks or campaign.budget.
  - Related objects can be bound to variables, such as LET campaign = keyword.campaign.

Entities and accepted FOR EACH names:
  - campaign (default variable: campaign). Aliases: campaigns.
  - adGroup (default variable: adGroup). Aliases: adgroups, ad group, ad groups.
  - keyword (default variable: keyword). Aliases: keywords.
  - target (default variable: target). Aliases: targets.
  - searchTerm (default variable: searchTerm). Aliases: searchterms, search term, search terms.
  - product (default variable: product). Aliases: products, productad, productads, product ad, product ads, ad, ads.
  - portfolio (default variable: portfolio). Aliases: portfolios.
  - accumulated_keyword (default variable: accumulated_keyword). Aliases: accumulatedkeyword, accumulated_keywords, accumulatedkeywords, accumulated keyword, accumulated keywords.
  - accumulated_asin (default variable: accumulated_asin). Aliases: accumulatedasin, accumulated_asins, accumulatedasins, accumulated asin, accumulated asins.

Literals:
  - number: 12, 0.85 — Finite decimal number
  - currency: $0.50, $25 — Dollar prefix; evaluates to a number
  - percentage: 30%, 12.5% — Evaluates to a decimal, e.g. 30% = 0.30
  - text: "ENABLED", 'exact' — Single- or double-quoted string with standard escaped characters
  - boolean: TRUE, YES, FALSE, NO — TRUE/YES and FALSE/NO are equivalent pairs
  - empty: NONE, NULL — Null value
  - list: ["EXACT", "PHRASE"] — Comma-separated expressions inside square brackets

Operators, comparisons, and inline windows:
  - AND, OR, NOT — Boolean logic; precedence is NOT, then AND, then OR
  - =, ==, IS — Case-insensitive equality when both values are text
  - !=, <>, IS NOT — Inequality aliases
  - <, <=, >, >= — Ordering comparisons
  - IN, NOT IN — List membership
  - CONTAINS, NOT CONTAINS, DOES NOT CONTAIN — Case-insensitive substring checks
  - CONTAINS ANY, NOT CONTAINS ANY, DOES NOT CONTAIN ANY — Case-insensitive checks against a list of substrings
  - STARTS WITH, ENDS WITH — Case-insensitive text prefix/suffix checks
  - +, -, *, /, % — Arithmetic; + concatenates when either operand is text, and % is modulo
  - unary - — Numeric negation
  - expression IN|OVER range — Evaluate metric reads inside the expression using an inline time window

Accepted range syntax (N and M are non-negative integers):
  - TODAY
  - YESTERDAY
  - N DAY AGO or N DAYS AGO
  - LAST N, LAST ND, LAST N DAY, or LAST N DAYS
  - ND, N DAY, or N DAYS
  - LIFETIME, ALL TIME, or ALL
  - FROM N DAYS AGO TO M DAYS AGO
  - BEFORE N DAYS AGO
  - FROM YYYY-MM-DD TO YYYY-MM-DD (FOR EACH block ranges)
  - METRIC() additionally accepts a numeric N or the string windows "lastN", "N..M", and "N.."

Metric fields:
  - impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov

All functions:
  - MIN(value, value, ...) — Smallest value
  - MAX(value, value, ...) — Largest value
  - ROUND(value[, decimal_places]) — Round a number; defaults to zero decimal places
  - FLOOR(value) — Round down to an integer
  - CEIL(value) — Round up to an integer
  - ABS(value) — Absolute numeric value
  - CLAMP(value, minimum, maximum) — Keep a number inside limits
  - LOWER(value) — Convert text to lowercase
  - UPPER(value) — Convert text to uppercase
  - LENGTH(value) — Length of text or a list; zero for an empty/null value
  - REPLACE(value, search, replacement) — Replace every matching text fragment
  - CONCAT(value, value, ...) — Join values as text; null values become empty text
  - IF(condition, value_if_true, value_if_false) — Choose a value; chain IF(c1, v1, c2, v2, ..., default) for multi-branch CASE logic
  - TODAY() — Current UTC date as YYYY-MM-DD, for example 2026-06-19
  - METRIC("metric_name", range) — Read a metric for the current loop entity in another window; accepts ranges such as "last7", "today", "1 day ago", "8..35", or "8.."
  - LAST_CHANGE("budget") — Timestamp of the latest applied change, or NONE. Field: "budget", "bid", "placement", "audience", "placement top of search", "placement product pages", "placement rest of search", "placement" is the newest of a campaign's three adjustments, and naming one narrows it to that placement. Add "this rule" as a second argument, LAST_CHANGE("budget", "this rule"), to count only this rule's own changes
  - DAYS_SINCE_CHANGE("bid") — Days since the latest applied change; Infinity when never changed. Field: "budget", "bid", "placement", "audience", "placement top of search", "placement product pages", "placement rest of search", the cool-down between adjustments. Counts every change MerchDash applied; add "this rule" as a second argument, DAYS_SINCE_CHANGE("bid", "this rule"), to count only the changes this rule made
  - AUDIENCE_BID("high interest") — A Sponsored Products campaign's current bid adjustment (%) for one audience, 0 when it has none. Name the audience as Campaigns → Audiences shows it (part of the name is enough when it is unambiguous) or by its id; a name that matches no audience of the account stops the rule rather than reading 0. NONE on anything but a Sponsored Products campaign
  - RULE("rule name") — Another rule of this account, by name, only as the second argument of DAYS_SINCE_CHANGE()/LAST_CHANGE(), to count just the changes that rule made: DAYS_SINCE_CHANGE("bid", RULE("Nightly bid down")). The name must be written out; a name no rule has, or two rules share, stops the run, use RULE_ID() then
  - RULE_ID("rule_mgx1abcd") — Another rule of this account, by the ID shown in its editor, only as the second argument of DAYS_SINCE_CHANGE()/LAST_CHANGE(). Keeps working when the rule is renamed
  - PORTFOLIO("portfolio name") — Find a portfolio ID by its exact case-insensitive name; errors when no portfolio exists
  - FIND_CAMPAIGN("campaign name") — The campaign with this name, or NONE when there is none. Names are matched case-insensitively with spacing normalised, archived campaigns are ignored, and two campaigns sharing the name is an error rather than a guess, use FIND_CAMPAIGN_ID() then. Read its fields (name, budget, state, metrics) or act on it like any campaign
  - FIND_CAMPAIGN_ID("583948274920174") — The campaign with this ID, or NONE. Never falls back to matching a name, a campaign really can be named "2024"
  - FIND_AD_GROUP(campaign, "ad group name") — The ad group with this name inside that campaign, or NONE. The campaign comes first because ad group names are only unique within one; a NONE campaign gives a NONE ad group
  - FIND_AD_GROUP_ID("1122334455") — The ad group with this ID, or NONE
  - FIND_PORTFOLIO("portfolio name") — The portfolio with this name, or NONE, the answering version of PORTFOLIO(), which throws instead. Finds portfolios holding no campaigns too
  - FIND_SB_AD("ad name") — The Sponsored Brands ad with this name, or NONE. An SB ad is the creative, headline, brand, landing page and up to three ASINs, and unlike every other ad Amazon REVIEWS it, so read ad.moderationStatus ("PENDING", "APPROVED", "REJECTED", or NONE when Amazon answers a status MerchDash has not seen) to tell a structure that serves from one that never will. Also ad.adFormat, ad.headline, ad.brandName, ad.asins, ad.servingStatus, and the campaign and ad group it sits in. Pass it to ad_group.addVideoAd(from: …) to put the same video in another ad group; the ad itself cannot be changed by a rule
  - FIND_SB_AD_ID("1122334455") — The Sponsored Brands ad with this ID, or NONE. The way to reach one of several ads sharing a name, Amazon does not make SB ad names unique
  - FIND_VIDEO("video name") — The video in the Sponsored Brands asset library with this name, or NONE. Read video.status, video.ready (TRUE once Amazon has finished processing it) and video.durationSeconds before an ad uses it. Two videos with one name is an error: rename one
  - ELIGIBLE("B0ABCD1234"[, "SP"|"SB"|"SD"]) — TRUE when Amazon says the ASIN may be advertised (eligible, or eligible with a warning), FALSE when it is ineligible. Asks Amazon once per distinct ASIN. A row Amazon cannot answer for is skipped and counted, never read as ineligible. The ad product defaults to the row's own, else Sponsored Products
  - ELIGIBILITY("B0ABCD1234"[, "SP"|"SB"|"SD"]) — Amazon's eligibility status for the ASIN as text: "ELIGIBLE", "ELIGIBLE_WITH_WARNING" or "INELIGIBLE". Same reads and skips as ELIGIBLE()
  - SUGGESTED_BID(keyword) — Amazon's suggested bid for a Sponsored Brands keyword or target (the row itself, e.g. SUGGESTED_BID(keyword)). One request to Amazon covers up to 100 keywords and targets of the campaign. A row outside Sponsored Brands, or one Amazon has no suggestion for, is skipped and counted
  - SUGGESTED_BID_LOW(keyword) — The low end of Amazon's suggested bid range for a Sponsored Brands keyword or target
  - SUGGESTED_BID_HIGH(keyword) — The high end of Amazon's suggested bid range for a Sponsored Brands keyword or target
  - HAS_KEYWORD(ad_group, "text"[, "exact"]) — Is this keyword already in the ad group? Omit the match type to ask about any of them
  - HAS_TARGET(ad_group, PRODUCT_TARGET("B0ABCD1234")) — Is this targeting clause already in the ad group?
  - HAS_ASIN(ad_group, "B0ABCD1234") — Is this ASIN advertised in the ad group (or anywhere in the campaign)?
  - PRODUCT_TARGET("B0ABCD1234") — Names a product targeting clause, for HAS_TARGET() and comparisons against TARGETS()
  - CATEGORY_TARGET("1234567") — Names a category targeting clause the same way PRODUCT_TARGET() names a product one
  - AD_GROUPS(campaign) — The names of every ad group in a campaign, as a list
  - ASINS(ad_group) — Every ASIN advertised in an ad group, or in a whole campaign, as a list
  - KEYWORDS(ad_group) — The keyword text of every keyword in an ad group, or in a whole campaign, as a list. Ask HAS_KEYWORD() when the match type matters
  - TARGETS(ad_group) — Every targeting clause in an ad group, or in a whole campaign, named the way PRODUCT_TARGET()/CATEGORY_TARGET() name one ("asin=B0ABCD1234", "close-match")
  - COUNT(list) — How many items a list has, for example COUNT(ASINS(ad_group))
  - JOIN(list[, ", "]) — Join a list into text; the separator defaults to a comma and a space
  - createPortfolio("portfolio name") — The portfolio with this name, creating it if there is none, never NONE, so it can be handed straight to campaign.moveToPortfolio(). Named like createKeyword() but called on nothing: a portfolio belongs to the account, not to an item. Get-or-create: a portfolio that is already there is not proposed again, so this is safe in a rule that runs nightly. A new portfolio is one change to review; applying it looks Amazon up first and adopts a portfolio somebody else made rather than building a second one. Reverting never removes a portfolio
  - createCampaign("campaign name", budget: $20, targeting: "manual"[, state: "paused"]) or createCampaign("campaign name", adType: "brands", budget: $20[, goal: "impression share"]) — The campaign with this name, creating it if there is none, never NONE, so it can be handed straight to campaign.createAdGroup(). Sponsored Products unless adType: "brands" asks for a Sponsored Brands one. budget: is required; a Sponsored Products campaign also needs targeting: ("manual" or "auto") and may take strategy:, while a Sponsored Brands one takes neither, it has no targeting type, and its bidding stays at Amazon's default. portfolio: and state: are optional. A Sponsored Brands campaign serves nothing until it has an ad, and Amazon refuses keywords in an SB ad group with no ad: put one in with ad_group.addVideoAd(…) before createKeyword(). Without it, keywords into an SB ad group the same run is building are skipped and reported, and a later run creates them once the ad exists. Get-or-create: a campaign that is already there is answered with as it stands and is not proposed again, so this is safe in a rule that runs nightly, and its budget is NOT overwritten, say setBudget() for that. A new campaign goes live in the run that builds it; say state: "paused" to build it switched off and enable it yourself later. Applying it looks Amazon up first and adopts a campaign somebody else made rather than building a second one. A Sponsored Brands campaign may take goal: "page visits" (Amazon's default, paid per click), "impression share" or "new customers" (both paid per 1,000 viewable impressions, so bids are per 1,000 impressions and the per-click maximum bid does not cap them), or "ad views"; the goal cannot be changed later. Reverting archives a Sponsored Brands campaign this rule actually created; a Sponsored Products one is not reverted automatically

All action/reason methods:
  - object.pause() Applies to: campaign, adGroup, keyword, target, product, portfolio. Pause a campaign, ad group, keyword, target, product, or portfolio
  - object.enable() Applies to: campaign, adGroup, keyword, target, product, portfolio. Enable a campaign, ad group, keyword, target, product, or portfolio
  - object.setBid(value) Applies to: keyword, target. Set a keyword or target bid (compute increases/decreases, e.g. keyword.bid * 0.9)
  - object.setBudget(value) Applies to: campaign. Set a campaign daily budget
  - portfolio.setBudget($500[, policy: "monthly"|"date range"][, startDate: "2026-10-01"][, endDate: "2026-10-31"]) Applies to: portfolio. Set a portfolio budget cap, the most every campaign in the portfolio may spend together. policy: "monthly" resets each calendar month; "date range" is one total from startDate: to endDate:, after which the portfolio's campaigns stop. Left out, the policy stays what it is (monthly when the portfolio has no cap yet), and so do its dates while the policy is unchanged. Dates are YYYY-MM-DD, TODAY() works; endDate: NONE removes the end date. Reverting restores the whole previous cap Parameters: amount — The cap, for example $500.
  - portfolio.removeBudgetCap() Applies to: portfolio. Take a portfolio's budget cap off, so its campaigns spend only against their own daily budgets. Reverting puts the previous cap back
  - portfolio.archive() Applies to: portfolio. Archive a portfolio. Permanent on Amazon, an archived portfolio cannot be enabled, changed or restored, so this change has no revert
  - object.setBiddingStrategy("FIXED BIDS") Applies to: campaign. Set a campaign bidding strategy: "DYNAMIC BIDS - UP AND DOWN", "DYNAMIC BIDS - DOWN ONLY", or "FIXED BIDS" Parameters: strategy — "DYNAMIC BIDS - UP AND DOWN", "DYNAMIC BIDS - DOWN ONLY", or "FIXED BIDS".
  - object.setPlacementBid("TOP OF SEARCH", percent) Applies to: campaign. Set a Sponsored Products placement bid adjustment (0–900%). Placement is "TOP OF SEARCH", "PRODUCT PAGES", or "REST OF SEARCH" (case-insensitive); percent is a whole-number percentage such as 50 Parameters: placement (one of "TOP OF SEARCH", "PRODUCT PAGES", "REST OF SEARCH") — "TOP OF SEARCH", "PRODUCT PAGES", or "REST OF SEARCH"; percent — Adjustment percentage, 0–900 (e.g. 50 for +50%).
  - object.setAudienceBid("high interest", percent) Applies to: campaign. Set a Sponsored Products audience bid adjustment (0–900%; 0 removes it), a campaign carries up to 10, each for one audience. Only that audience changes; the campaign's other audiences, placements and bidding strategy are kept. Name the audience as Campaigns → Audiences shows it, or by id Parameters: audience — The audience's name (or part of it) or id, e.g. "high interest"; percent — Adjustment percentage, 0–900 (e.g. 50 for +50%, 0 to remove).
  - object.setAutoTargetingBid("close match", 0.35) Applies to: campaign, adGroup. Set the bid on one of an auto campaign's four predefined targeting clauses, "close match", "loose match", "substitutes" or "complements", in every ad group of the campaign (or of this ad group). The clauses are read live from Amazon, so it reaches the ones that have never delivered and are in no report. Add when: "empty" to fill only the clauses that have no bid of their own and follow the ad group default Parameters: clause (one of "close match", "loose match", "substitutes", "complements") — "close match", "loose match", "substitutes" or "complements"; bid — The bid to write, e.g. 0.35 or $0.35.
  - object.setState("PAUSED") Applies to: campaign, adGroup, keyword, target, product, portfolio. Set enabled/paused state. On a portfolio it cannot archive, say portfolio.archive() for that
  - object.setName(value) Aliases: rename. Applies to: campaign, portfolio. Rename a campaign or a portfolio; rename(value) is an accepted alias
  - object.moveToPortfolio(portfolio) Aliases: setPortfolio. Applies to: campaign. Move a campaign into a portfolio. Takes the portfolio itself, FIND_PORTFOLIO("name"), createPortfolio("name") or PORTFOLIO("name"), or NONE to take the campaign out of the one it is in. A portfolio createPortfolio is still creating works too: the move is applied after it exists. setPortfolio(value) is an accepted alias
  - object.addNegative(text[, "exact"|"phrase"]) Applies to: campaign, adGroup, keyword, target, searchTerm, product. Add a negative keyword; omitted match type defaults to exact
  - object.removeNegative(text[, "exact"|"phrase"]) Applies to: campaign, adGroup, keyword, target, searchTerm, product. Remove a negative keyword, or for an ASIN a negative product target, that is live where the row delivered, the ad group, or the campaign for a campaign. Leave the match type out to remove either. Nothing to remove does nothing. Reverting re-creates it
  - object.createKeyword(text[, "exact"|"phrase"|"broad"[, bid]][, state: "paused"]) Applies to: adGroup, keyword, target, searchTerm, product. Create a positive keyword in the entity ad group, including an ad group the same rule is still creating; omitted match type defaults to exact and bid is optional. The keyword is live unless state: "paused" says otherwise. Works in Sponsored Products and Sponsored Brands ad groups (Sponsored Brands has its own floor, set by the marketplace and higher for video ads, US $0.10, or $0.25 for video, and a lower bid is raised to it); Sponsored Display has no keywords at all, so those are skipped and counted in the run summary Parameters: text — Keyword text to create; matchType — "exact", "phrase", or "broad"; defaults to "exact"; bid — Keyword bid, for example $0.75.
  - ad_group.createTarget(PRODUCT_TARGET("B0ABCD1234")[, bid][, state: "paused"]) Applies to: adGroup. Add a product or category targeting clause to an ad group, unless it is already there, including an ad group the same rule is still creating. The clause is live unless state: "paused" says otherwise. Build the clause with PRODUCT_TARGET("B0…") or CATEGORY_TARGET("12345"), a bare string is not a clause. Sponsored Products manual campaigns and Sponsored Brands ad groups: an auto campaign's four clauses are created by Amazon with the ad group and cannot be added to. In Sponsored Brands give a bid (SB ad groups have no default bid; the floor depends on the marketplace and is higher for video ads) and call ad_group.addVideoAd() first, Amazon takes targets only once the ad group has an ad. Every clause a rule ensures into one ad group is proposed as a single change; reverting it archives only the clauses Amazon actually created, never one that was already there. Parameters: clause (one of PRODUCT_TARGET("B0ABCD1234"), CATEGORY_TARGET("12345")) — PRODUCT_TARGET("B0ABCD1234") or CATEGORY_TARGET("12345"); bid — Starting bid, for example $0.60; omitted leaves the clause on the ad group default bid.
  - campaign.createAdGroup("ad group name", $0.75[, state: "paused"]) Applies to: campaign. The ad group with this name inside that campaign, creating it if there is none, never NONE, so it can be handed straight to ad_group.addAsin(). The campaign may be one this same rule is still creating. In a Sponsored Products campaign the bid is required: Amazon has no default bid. In a Sponsored Brands campaign leave it out, SB ad groups have no default bid, so put the bid on each createKeyword() or createTarget(), and fill the ad group with keywords or product targets (addAsin() is Sponsored Products only). Get-or-create, so it is safe in a rule that runs nightly; an ad group that is already there is answered with as it stands and its bid is not overwritten. A new ad group goes live in the run that builds it unless state: "paused" says otherwise. Reverting can only archive an ad group this rule actually created.
  - ad_group.addAsin("B0ABCD1234"[, state: "paused"]) Applies to: adGroup. Advertise an ASIN in an ad group, unless it is already advertised there, including an ad group the same rule is still creating. The product ad is live unless state: "paused" says otherwise. Sponsored Products only. Every ASIN a rule ensures into one ad group is proposed as a single change; reverting it archives only the product ads Amazon actually created, never one that was already there. Parameters: asin — The ASIN to advertise, for example "B0ABCD1234".
  - ad_group.addVideoAd(from: FIND_SB_AD("ad name") | asin: "B0ABCD1234" | video: "video name"[, asin:][, name: "…"][, state: "paused"]) Applies to: adGroup. Put a Sponsored Brands VIDEO ad in a Sponsored Brands ad group, including one the same rule is still creating, built from a video the account already has: from: takes the video and ASIN of that ad, asin: uses the video ad that advertises the ASIN (several different videos for one ASIN is an error naming them; say from: instead), video: picks the video by its name in the asset library (the file name it was uploaded under, refreshed by sync once a day, or the name given when it was uploaded through the MerchDash API, usable once Amazon has processed it) and takes the ASIN from the ads already using it, add asin: for a video no ad uses yet. The rule itself uploads nothing and the original ad is not changed; the new ad has its own metrics. name: defaults to the source ad's name. Needs # ALLOW CREATION. Amazon reviews every new SB ad before it serves. Keywords created into the same ad group AFTER this line wait for the ad, because Amazon accepts keywords in a Sponsored Brands ad group only once it has one. Get-or-create: an ad group already running that video for that ASIN is left alone. Reverting archives only an ad this rule actually created. Parameters: from — An existing Sponsored Brands video ad, from FIND_SB_AD("name") or FIND_SB_AD_ID("id"); asin — The ASIN whose video ad to use, for example "B0ABCD1234"; with video:, the ASIN the new ad advertises; video — The video's name in the Amazon Ads asset library, for example "cat-mom.mp4"; name — The new ad's name; defaults to the source ad's; state — "enabled" (default) or "paused".
  - ad_group.addSbAd(from: FIND_SB_AD("ad name") | format: "product collection"|"store spotlight"[, headline: "…"][, asins: "B0…, B0…"][, logo: "logo name"][, landingPage: "store page"][, pages: "Home, Tees, Mugs"][, brand: "…"][, name: "…"][, state: "paused"]) Applies to: adGroup. Put a Sponsored Brands product collection, store spotlight or brand video ad in a Sponsored Brands ad group, including one the same rule is still creating. Two ways. from: copies an ad the account already runs: its brand, logo, headline, products, video and landing page; headline: (at most 50 characters), asins: (a product collection's 1 to 5 products), logo: (an image in the account's Amazon Ads asset library, by its name there) and landingPage: (a page of the account's own Amazon Store) change the copy. A video ad is copied with addVideoAd() instead. format: builds a product collection or store spotlight ad from parts, for an account with no such ad to copy: headline: and logo: always; a product collection also needs asins: (it lands on a store page when landingPage: is given, otherwise on the product list); a store spotlight needs landingPage: and pages: (exactly 3 pages of the store, each shown with its first product). The brand name is brand:, or the one brand the account's existing Sponsored Brands ads carry. Unless already there: the same creative in the same ad group is not proposed twice. Amazon reviews every new ad, so it serves once approved; keywords and targets recorded into the same ad group after this line wait for it. Reverting archives the ad. Needs # ALLOW CREATION. Parameters: from — The Sponsored Brands ad to copy, from FIND_SB_AD("name") or FIND_SB_AD_ID("id"); or format:, not both; format — "product collection" or "store spotlight": builds the ad from the other settings instead of copying one; headline — The new ad's headline, at most 50 characters (needed with format:); asins — A product collection's products, 1 to 5 ASINs separated by commas; logo — The brand logo's name in the asset library (needed with format:); landingPage — A page of the account's Amazon Store, by name ("Home") or URL; pages — A store spotlight's three store pages, by name or URL, separated by commas; brand — The brand name shoppers see, at most 30 characters; defaults to the brand of the account's existing Sponsored Brands ads; name — The new ad's name; defaults to the source ad's with " - copy", or the headline and format when built; state — "enabled" (default) or "paused".
  - accumulated_keyword.pauseEverywhere() Applies to: accumulated_keyword, accumulated_asin. Pause every instance the accumulated row summed, each keyword instance, or each ad of the ASIN, across every campaign it delivered in during the report timeframe. Only instances that are still enabled are touched.
  - accumulated_keyword.setBidEverywhere(value) Applies to: accumulated_keyword. Set the bid on every instance of this keyword+match type, in every ad group it delivered in. One recorded change per instance, each revertible on its own. Parameters: value — New bid, for example $0.35 or MAX($0.02, accumulated_keyword.cpc * 0.8).
  - accumulated_keyword.negateEverywhere(["exact"|"phrase"]) Applies to: accumulated_keyword. Add this keyword as a negative in every ad group it delivered in (SP and SB; Sponsored Display has no keyword negatives). Omitted match type defaults to exact. Parameters: matchType — "exact" or "phrase"; defaults to "exact".
  - object.note(value) Applies to: campaign, adGroup, keyword, target, searchTerm, product, portfolio, accumulated_keyword, accumulated_asin. Set the reason for every action in the same body; may also be called without an object as note(value)
  - log(value) Applies to: . Write one line into this run's log, for seeing what a rule reads, not for changing anything. Unlike note(), it records even when the rule proposes no changes. Capped at 200 lines per run

Relations available through dot access:
  - campaign: portfolio, placement
  - adGroup: campaign
  - keyword: campaign, adGroup
  - target: campaign, adGroup
  - searchTerm: campaign, adGroup, keyword
  - product: campaign, adGroup
  - portfolio: none
  - accumulated_keyword: none
  - accumulated_asin: none
  - Relations can be chained or assigned, e.g. keyword.campaign.acos or LET campaign = keyword.campaign.

All fields by entity:
  - campaign: campaignId, name, campaignName, state, targetingType, adType, budget, startDate, endDate, portfolioId, portfolioName, budgetType, biddingStrategy, servingStatus, creationDate, updatedAt, lastUpdated, lastBudgetChangeAt, daysSinceBudgetChange, adGroupCount, lastPlacementChangeAt, daysSincePlacementChange, lastRuleBudgetChangeAt, daysSinceRuleBudgetChange, lastRulePlacementChangeAt, daysSinceRulePlacementChange, lastAudienceChangeAt, daysSinceAudienceChange, lastRuleAudienceChangeAt, daysSinceRuleAudienceChange, placementTopPct, placementProductPagePct, placementRestOfSearchPct, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov
  - adGroup: adGroupId, campaignId, name, adGroupName, state, adType, adFormat, defaultBid, servingStatus, creationDate, updatedAt, lastUpdated, campaignName, campaignState, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov
  - keyword: keywordId, adGroupId, campaignId, keywordText, matchType, adType, adFormat, state, bid, bidInherited, updatedAt, lastBidChangeAt, daysSinceBidChange, lastRuleBidChangeAt, daysSinceRuleBidChange, campaignName, campaignState, portfolioName, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov
  - target: targetId, targetingMode, targeting, expression, adType, adFormat, state, bid, bidInherited, updatedAt, lastBidChangeAt, daysSinceBidChange, lastRuleBidChangeAt, daysSinceRuleBidChange, campaignName, campaignState, portfolioName, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov
  - searchTerm: campaignId, adGroupId, keywordId, searchTerm, keyword, matchType, campaignName, campaignState, portfolioName, negated, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov
  - product: adId, asin, sku, campaignId, adGroupId, state, updatedAt, campaignName, campaignState, portfolioName, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov
  - portfolio: portfolioId, portfolioName, state, budget, budgetAmount, budgetCurrency, budgetPolicy, budgetStartDate, budgetEndDate, inBudget, updatedAt, campaigns
  - sb_ad: adId, adGroupId, campaignId, name, state, adFormat, headline, brandName, asins, servingStatus, moderationStatus, creativeStatus, creativeVersion, landingPageType, landingPageUrl, updatedAt, campaignName, campaignState, adGroupName, adGroupState
  - placement: top_of_search, rest_of_search, product_pages
  - accumulated_keyword: keywordText, matchType, campaigns, adGroups, keywords, liveKeywords, pausedKeywords, liveCampaigns, pausedCampaigns, liveAdGroups, pausedAdGroups, keywordState, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov, cpo
  - accumulated_asin: asin, sku, campaigns, ads, spAds, sbAds, liveAds, pausedAds, liveCampaigns, pausedCampaigns, asinState, impressions, clicks, cost, spend, sales, orders, units, acos, roas, ctr, cvr, cpc, aov, cpo
  - suggested_keyword: keywordText, matchType, bid, bidLow, bidHigh, rank, impressionRank, impressionShare, alreadyTargeted
  - suggested_asin: asin, themes, rank, alreadyTargeted

The complete set of values each of these fields can hold. A field NEVER holds anything outside its list, so comparing one to a value that is not there matches zero rows — the rule saves, runs green, and changes nothing. When a request needs a value that is not on any list, the field you are reaching for is the wrong one; find the field whose list contains it:
  - state: "ENABLED", "PAUSED", "ARCHIVED"
  - campaignState: "ENABLED", "PAUSED", "ARCHIVED"
  - keywordState: "ENABLED", "PAUSED"
  - asinState: "ENABLED", "PAUSED"
  - matchType: "EXACT", "PHRASE", "BROAD"
  - targetingType: "AUTO", "MANUAL"
  - targetingMode: "AUTO", "MANUAL"
  - targeting: "close match", "loose match", "substitutes", "complements", "product"
  - adType: "SP", "SB", "SD"
  - adFormat: "PRODUCT_COLLECTION", "STORE_SPOTLIGHT", "VIDEO", "BRAND_VIDEO", "MIXED"
  - biddingStrategy: "DYNAMIC BIDS - UP AND DOWN", "DYNAMIC BIDS - DOWN ONLY", "FIXED BIDS"
  - budgetPolicy: "NO_CAP", "MONTHLY_RECURRING", "DATE_RANGE"
  - bidInherited: TRUE, FALSE
  - negated: TRUE, FALSE
  - alreadyTargeted: TRUE, FALSE

Accepted field aliases and normalized spellings:
  - daily_budget -> budget
  - dailybudget -> budget
  - last_budget_change -> lastBudgetChangeAt
  - lastbudgetchange -> lastBudgetChangeAt
  - days_since_budget_change -> daysSinceBudgetChange
  - dayssincebudgetchange -> daysSinceBudgetChange
  - last_bid_change -> lastBidChangeAt
  - lastbidchange -> lastBidChangeAt
  - days_since_bid_change -> daysSinceBidChange
  - dayssincebidchange -> daysSinceBidChange
  - last_placement_change -> lastPlacementChangeAt
  - lastplacementchange -> lastPlacementChangeAt
  - lastplacementchangeat -> lastPlacementChangeAt
  - days_since_placement_change -> daysSincePlacementChange
  - dayssinceplacementchange -> daysSincePlacementChange
  - last_audience_change -> lastAudienceChangeAt
  - lastaudiencechange -> lastAudienceChangeAt
  - lastaudiencechangeat -> lastAudienceChangeAt
  - days_since_audience_change -> daysSinceAudienceChange
  - dayssinceaudiencechange -> daysSinceAudienceChange
  - last_rule_audience_change -> lastRuleAudienceChangeAt
  - lastruleaudiencechange -> lastRuleAudienceChangeAt
  - lastruleaudiencechangeat -> lastRuleAudienceChangeAt
  - days_since_rule_audience_change -> daysSinceRuleAudienceChange
  - dayssinceruleaudiencechange -> daysSinceRuleAudienceChange
  - last_rule_bid_change -> lastRuleBidChangeAt
  - lastrulebidchange -> lastRuleBidChangeAt
  - lastrulebidchangeat -> lastRuleBidChangeAt
  - days_since_rule_bid_change -> daysSinceRuleBidChange
  - dayssincerulebidchange -> daysSinceRuleBidChange
  - last_rule_budget_change -> lastRuleBudgetChangeAt
  - lastrulebudgetchange -> lastRuleBudgetChangeAt
  - lastrulebudgetchangeat -> lastRuleBudgetChangeAt
  - days_since_rule_budget_change -> daysSinceRuleBudgetChange
  - dayssincerulebudgetchange -> daysSinceRuleBudgetChange
  - last_rule_placement_change -> lastRulePlacementChangeAt
  - lastruleplacementchange -> lastRulePlacementChangeAt
  - lastruleplacementchangeat -> lastRulePlacementChangeAt
  - days_since_rule_placement_change -> daysSinceRulePlacementChange
  - dayssinceruleplacementchange -> daysSinceRulePlacementChange
  - search_term -> searchTerm
  - searchterm -> searchTerm
  - advertised_asin -> asin
  - campaign_name -> campaignName
  - campaignname -> campaignName
  - campaign_state -> campaignState
  - campaignstate -> campaignState
  - ad_group_name -> adGroupName
  - adgroupname -> adGroupName
  - ad_group_count -> adGroupCount
  - adgroupcount -> adGroupCount
  - ad_groups -> adGroupCount
  - adgroups -> adGroupCount
  - num_ad_groups -> adGroupCount
  - keyword_text -> keywordText
  - keywordtext -> keywordText
  - match_type -> matchType
  - matchtype -> matchType
  - portfolio_name -> portfolioName
  - portfolioname -> portfolioName
  - campaign_id -> campaignId
  - campaignid -> campaignId
  - ad_group_id -> adGroupId
  - adgroupid -> adGroupId
  - keyword_id -> keywordId
  - keywordid -> keywordId
  - target_id -> targetId
  - targetid -> targetId
  - portfolio_id -> portfolioId
  - portfolioid -> portfolioId
  - bidding_strategy -> biddingStrategy
  - biddingstrategy -> biddingStrategy
  - targetingtype -> targetingType
  - ad_type -> adType
  - adtype -> adType
  - placementtoppct -> placementTopPct
  - placement_top -> placementTopPct
  - top_of_search -> placementTopPct
  - tos -> placementTopPct
  - placementproductpagepct -> placementProductPagePct
  - placement_product_page -> placementProductPagePct
  - product_pages -> placementProductPagePct
  - product_page -> placementProductPagePct
  - detail_page -> placementProductPagePct
  - placementrestofsearchpct -> placementRestOfSearchPct
  - placement_rest_of_search -> placementRestOfSearchPct
  - rest_of_search -> placementRestOfSearchPct
  - targetingmode -> targetingMode
  - expressiontype -> targetingMode
  - targetingclause -> targeting
  - targeting_clause -> targeting
  - clause -> targeting
  - auto_clause -> targeting
  - defaultbid -> defaultBid
  - bidinherited -> bidInherited
  - bid_inherited -> bidInherited
  - conversion_rate -> cvr
  - click_through_rate -> ctr
  - average_order_value -> aov
  - purchases -> orders
  - purchase -> orders
  - live_keywords -> liveKeywords
  - livekeywords -> liveKeywords
  - paused_keywords -> pausedKeywords
  - pausedkeywords -> pausedKeywords
  - live_campaigns -> liveCampaigns
  - livecampaigns -> liveCampaigns
  - paused_campaigns -> pausedCampaigns
  - pausedcampaigns -> pausedCampaigns
  - live_ad_groups -> liveAdGroups
  - liveadgroups -> liveAdGroups
  - paused_ad_groups -> pausedAdGroups
  - pausedadgroups -> pausedAdGroups
  - live_ads -> liveAds
  - liveads -> liveAds
  - paused_ads -> pausedAds
  - pausedads -> pausedAds
  - sp_ads -> spAds
  - spads -> spAds
  - sb_ads -> sbAds
  - sbads -> sbAds
  - keyword_state -> keywordState
  - keywordstate -> keywordState
  - ad_format -> adFormat
  - adformat -> adFormat
  - format -> adFormat
  - brand_name -> brandName
  - brandname -> brandName
  - moderation_status -> moderationStatus
  - moderationstatus -> moderationStatus
  - creative_status -> creativeStatus
  - creativestatus -> creativeStatus
  - creative_version -> creativeVersion
  - creativeversion -> creativeVersion
  - landing_page_type -> landingPageType
  - landingpagetype -> landingPageType
  - landing_page_url -> landingPageUrl
  - landingpageurl -> landingPageUrl
  - ad_group_state -> adGroupState
  - adgroupstate -> adGroupState
  - asin_state -> asinState
  - asinstate -> asinState
  - bid_low -> bidLow
  - bidlow -> bidLow
  - bid_high -> bidHigh
  - bidhigh -> bidHigh
  - impression_rank -> impressionRank
  - impressionrank -> impressionRank
  - impression_share -> impressionShare
  - impressionshare -> impressionShare
  - already_targeted -> alreadyTargeted
  - alreadytargeted -> alreadyTargeted

note() behavior:
  - object.note(value) and note(value) set the reason for every action in the same body, whether the note appears before or after the action.
  - Placeholders: {field}, {field:money}, {field:percent}, {field:number}, {campaign_name}, {ad_group_name}, {portfolio_name}, any field of the item in either spelling (snake_case or camelCase), one relation hop, the same one a condition writes: {adGroup.cost:money}, {campaign.name}, a field the item does not carry stays as written, so a typo is visible, a figure that cannot be computed (ACOS with no sales) reads None, any visible scalar LET variable.
  - Nested bodies inherit the surrounding reason unless they set their own note.

Important function semantics:
  - TODAY() takes no arguments and returns the current UTC calendar date as YYYY-MM-DD text. Use it inside rule expressions, e.g. LET current_date = TODAY(). Do not confuse it with the TODAY time range used after IN/OVER.
  - METRIC() always reads the current FOR EACH entity, not a related object. Use an inline window on relatedObject.metric for related metrics.
  - LAST_CHANGE() and DAYS_SINCE_CHANGE() accept "budget", "bid", "audience" or a placement. They read the latest change MerchDash applied — from a rule or from a manual bulk edit on the Campaigns page — and answer Infinity/NONE for an entity it has never changed, which is why a plain "DAYS_SINCE_CHANGE("bid") > 7" also lets through everything it has never touched. A name outside this vocabulary is refused — when the rule is saved if you wrote it as a literal, and when the rule RUNS if it came from a LET variable or any other computed value — so never invent one ("state", "biddingStrategy", "name" and the like do not exist here). The argument must be one of those literal strings; a number, NONE or a list is refused too.
  - Placement cool-downs: DAYS_SINCE_CHANGE("placement") is the newest of a campaign's THREE placement adjustments; DAYS_SINCE_CHANGE("top of search") / ("product pages") / ("rest of search") narrow it to one, so a rule that nudges top of search is not held off by yesterday's product-pages change (the "placement " prefix and the setPlacementBid spellings — tos, detail page, PLACEMENT_TOP — are all accepted). The same thing reads as a campaign field: campaign.daysSincePlacementChange and campaign.lastPlacementChangeAt, aliases days_since_placement_change / last_placement_change, exactly like daysSinceBidChange and daysSinceBudgetChange. Put one on any rule that calls setPlacementBid — a placement adjustment needs days to show its effect, so re-adjusting on the next run judges the same data twice. Placement recency is campaign-level and Sponsored Products-only: on a keyword, target or ad group it reads Infinity/NONE.
  - Rule-scoped cool-downs: every form above counts changes from ANY rule, run or manual edit. A second argument "this rule" — DAYS_SINCE_CHANGE("bid", "this rule"), LAST_CHANGE("budget", "this rule"), DAYS_SINCE_CHANGE("top of search", "this rule") — counts only the changes the running rule itself applied. Field spellings: keyword/target.daysSinceRuleBidChange and lastRuleBidChangeAt, campaign.daysSinceRuleBudgetChange / lastRuleBudgetChangeAt, campaign.daysSinceRulePlacementChange / lastRulePlacementChangeAt. Use it only when the user asks for a cool-down that ignores other rules' changes; the default account-wide form is what stops two rules moving the same bid on the same day.
  - Another rule's changes: DAYS_SINCE_CHANGE("bid", RULE("Nightly bid down")) counts only the changes the rule NAMED that made (name matched case-insensitively), DAYS_SINCE_CHANGE("bid", RULE_ID("rule_mgx1abcd")) the same by the ID shown in that rule's editor. RULE()/RULE_ID() exist ONLY as that second argument, the name/ID must be a quoted literal (never a LET variable), and a name that matches no rule or two rules stops the run — so only use a rule name the user actually gave you. At most 10 per script. The second argument accepts nothing else besides "this rule".
  - "# NO COOL-DOWN TRACKING" on a line of its own at the top of a rule keeps every change that rule makes out of every cool-down (account-wide, "this rule" and RULE()); the changes are still reviewed, applied, shown in History and revertible. Suggest it for one-off or bulk rules (a mass bid cut) that must not hold the user's regular rules off. Such a rule must NOT use "this rule" or the daysSinceRule…/lastRule… fields — it is refused at save, because its own changes are never counted.
  - Maximum bids: every bid a rule writes is capped by the user's Profile → General maximum bids. "# NO MAX BID" on its own line ignores them for this rule; "# MAX BID 1.50" sets this rule's own cap, optionally scoped — "# MAX BID SP 1.50" (SP/SB/SD), "# MAX BID KEYWORD 2.00" (KEYWORD, PRODUCT, CLOSE MATCH, LOOSE MATCH, SUBSTITUTES, COMPLEMENTS), "# MAX BID SP CLOSE MATCH 0.80". A line replaces the Profile cap at its level and below, the narrower line wins, and unnamed levels keep the Profile cap. SD has no types and SB only KEYWORD/PRODUCT; the amount must be $0.02–$1,000. Only add these when the user asks to exceed or set a bid cap for the rule — prefer them over IF bid < X guards when the user means "never above X".
  - Settings (bid, budget, biddingStrategy, placement percentages, state, name, ...) can only be read at their CURRENT value. There is no way to read what a setting was N days ago — no historical*() methods, and no windowed-field syntax ("keyword.bid IN 30 DAYS AGO" does not read history, it just reads the current bid). Time windows apply to METRICS only. If a user asks for a settings-drift rule, say the language cannot look up past setting values and offer a metric-based alternative instead; never invent a method for it.
  - target.targeting is how a rule asks which kind of target a row is, and the ONLY field that answers it. It reads exactly one of "close match", "loose match", "substitutes", "complements", "product" — the four Sponsored Products auto-targeting clauses, or "product" for manual product/category targeting — and NONE when the row does not say (normal for Sponsored Brands and Display targets, which are not state-swept). Use it for any per-clause rule, e.g. a different maximum bid for substitutes than for close match. Compare the values exactly as written above: target.targeting = "close match". (target.targetingClause is an older spelling of the same field and still works; write target.targeting in new rules.)
  - target.targetingMode answers ONE question — is this an auto or a manual campaign's target — and holds only "AUTO" or "MANUAL". It NEVER names a clause. Every one of target.targetingMode = "SUBSTITUTES" / "COMPLEMENTS" / "CLOSE MATCH" / "LOOSE MATCH" (in any spelling, hyphenated, underscored or capitalized) is WRONG: Amazon keeps the clause name somewhere else entirely, so such a test matched zero rows while looking correct, and the editor now refuses to save it. Whenever a request names a clause — substitutes, complements, close match, loose match — reach for target.targeting, never targetingMode. Use targetingMode only when the user genuinely asks to split auto campaigns from manual ones, e.g. target.targetingMode = "AUTO".
  - keyword.adFormat, target.adFormat and adGroup.adFormat are the Sponsored Brands ad format of the row's AD GROUP — "STORE_SPOTLIGHT", "PRODUCT_COLLECTION", "VIDEO", "BRAND_VIDEO", or "MIXED" when its ads differ — and the way to limit a rule to one format, e.g. IF keyword.adFormat = "Store Spotlight" (the spelling with spaces compares equal). It is NONE for Sponsored Products and Sponsored Display rows and for an SB ad group whose ads have not been synced, so a format filter never needs an extra adType = "SB". A campaign has no adFormat field; filter its ad groups, keywords or targets instead.
  - campaign.adGroupCount is the number of ad groups in a campaign (a structural count, not a metric, so it ignores the time window). Read it on a campaign object, e.g. campaign.adGroupCount or keyword.campaign.adGroupCount. Aliases: ad_group_count, ad_groups, num_ad_groups.
  - PORTFOLIO() returns a portfolio ID suitable for campaign.moveToPortfolio(). It THROWS when the name does not exist, so never use it to test whether a portfolio is there — FIND_PORTFOLIO() is the one that answers.
  - Looking something up by name: FIND_CAMPAIGN("name"), FIND_AD_GROUP(campaign, "name"), FIND_PORTFOLIO("name") return the item or NONE, and FIND_CAMPAIGN_ID/FIND_AD_GROUP_ID do the same by id (never mix the two — a campaign can be NAMED "2024"). What comes back is a real entity: read its fields and metrics and call its actions exactly as if the loop were over it. FIND_AD_GROUP needs the campaign first because ad group names are only unique within one; passing a NONE campaign gives a NONE ad group. Guard every lookup with IS NOT NONE before acting on it — an action on NONE stops the run. A name matching two items also stops the run, naming both ids. Lookups are read once per run no matter how many rows the loop has, so writing one inside the loop is fine.
  - Sponsored Brands ads: FIND_SB_AD("name") / FIND_SB_AD_ID("id") return the SB ad or NONE. An SB ad is a CREATIVE, not a product ad — headline, brand name, landing page and up to three ASINs — and Amazon REVIEWS every one before it serves, which no other ad product does. So ad.moderationStatus ("PENDING", "APPROVED", "REJECTED", or NONE when Amazon answers a status MerchDash does not recognise) is the field that says whether a live-looking structure is actually running; ad.servingStatus is Amazon's raw wording, and ad.adFormat, ad.headline, ad.brandName, ad.asins, ad.campaignName and ad.adGroupName describe the rest. An existing SB ad cannot be edited, paused or archived by a rule, so never write an action ON one — use log(…) to report it; what a rule can do with it is pass it to ad_group.addVideoAd(from: …) (a video ad) or ad_group.addSbAd(from: …) (a product collection, store spotlight or brand video ad) to put a copy in another SB ad group. Without an ad to copy, ad_group.addSbAd(format: "product collection", headline: "…", asins: "B0…, B0…", logo: "logo name") or addSbAd(format: "store spotlight", headline: "…", logo: "logo name", landingPage: "Home", pages: "Home, Tees, Mugs") builds one from parts (brand: names the brand when the account's SB ads do not settle it). There is no FOR EACH sb_ad loop, so reach an ad by name or id; per-ad metrics are on the public API's GET /api/v1/sb-ads, not in rules.
  - Live reads from Amazon. ELIGIBLE("B0…"[, "SP"|"SB"|"SD"]) is TRUE when Amazon allows the ASIN to be advertised and FALSE when it is ineligible; ELIGIBILITY() gives the status text. SUGGESTED_BID(keyword), SUGGESTED_BID_LOW(keyword) and SUGGESTED_BID_HIGH(keyword) give Amazon's suggested bid and range for a Sponsored Brands keyword or target (the row itself is the argument). FIND_VIDEO("name") returns a library video (ready, status, durationSeconds) or NONE. Each ELIGIBLE/SUGGESTED_BID answer costs a request to Amazon, so keep the loop narrow. A row Amazon cannot answer for is SKIPPED, not read as false, so "IF NOT ELIGIBLE(productAd.asin): productAd.pause()" is safe during an outage. Never write keyword.setBid(SUGGESTED_BID(keyword)) on a Sponsored Products row: it is skipped.
  - What a rule CAN write in Sponsored Brands: keywords, product/category targets and negative keywords, in an ad group that already exists — FIND_AD_GROUP(FIND_CAMPAIGN("an SB campaign"), "its ad group").createKeyword("text", "exact", $0.50), plus bid and state changes on the clauses already in it. Sponsored Brands has its own bid floor, set by the marketplace and higher for video ads (US $0.10, or $0.25 for video; Europe €0.10 / €0.15), so a lower bid is raised to it and the change says so. A rule CAN also build a Sponsored Brands campaign and ad group: LET camp = createCampaign("SB — Harvest", adType: "brands", budget: $20) then LET ag = camp.createAdGroup("Core terms") — NO targeting: and NO strategy: on the campaign, and NO bid on the ad group (SB ad groups have no default bid; put the bid on each ag.createKeyword(…)). adType: must be written out as "brands" or "products". Fill an SB ad group with createKeyword() (keyword targeting) or createTarget(PRODUCT_TARGET("B0…") / CATEGORY_TARGET("id"), $0.60) (product targeting), always with a bid — addAsin() is Sponsored Products only and will not save there. The AD: a new SB campaign serves nothing until it has one, and Amazon refuses keywords in an SB ad group with no ad. ag.addVideoAd(from: FIND_SB_AD("ad name")), ag.addVideoAd(asin: "B0…") or ag.addVideoAd(video: "video file name") puts a VIDEO ad in the ad group, reusing a video (and ASIN) the account already has — exactly one of from:/asin:/video: (video: may add asin: for a video no ad uses yet), optional name: and state:, needs # ALLOW CREATION, and must come BEFORE createKeyword()/createTarget() on the same ad group so the keywords and targets wait for the ad. For the other formats, ag.addSbAd(from: FIND_SB_AD("ad name")) COPIES an existing product collection, store spotlight or brand video ad into the ad group, optionally with headline: "…" (≤ 50 characters), asins: "B0…, B0…, B0…" (a product collection's 1–5 products), logo: "logo name" (an image in the account's Amazon Ads asset library, by its name there) and landingPage: "store page name"; from: is required, it needs # ALLOW CREATION, and it too must come before the keywords. A Sponsored Brands campaign may take goal: "impression share" or "new customers" (paid per 1,000 viewable impressions, so keyword bids are per 1,000 impressions and the owner's per-click maximum bid does not cap them), "page visits" (the default, per click) or "ad views" — goal: is SB-only and cannot be changed later. Never invent createAd(). A rule cannot upload a video: the owner uploads one in Amazon Ads (found after the next daily sync) or through the MerchDash API (POST /api/v1/videos/uploads, found at once), and video: "its name" (with asin:) finds it once Amazon has processed it — before that the rule stops saying the video is not ready. Without addVideoAd, keywords and targets into an SB ad group the same run creates are skipped (reported in the run summary) and go in on a later run once the ad exists. Sponsored DISPLAY has no keywords at all (it targets products and audiences), so createKeyword() into an SD ad group is skipped and reported in the run summary.
  - Asking what is inside something: HAS_ASIN(adGroupOrCampaign, "B0…"), HAS_KEYWORD(adGroup, "text"[, "exact"]), HAS_TARGET(adGroup, PRODUCT_TARGET("B0…")) answer yes/no, and ASINS(), AD_GROUPS(), KEYWORDS(), TARGETS() return lists of text usable with IN, COUNT() and JOIN(). Use these to stop a harvesting rule re-proposing something that is already there. Name a targeting clause with PRODUCT_TARGET(asin) or CATEGORY_TARGET(id) rather than writing the expression by hand — Amazon spells clauses several ways and only the constructors compare against all of them.
  - log("…") writes one line into the run log, on its own line like note(…) and with the same placeholders. It is the only way a rule can report what it READ: note() sets the reason on the changes in its body and records nothing when there are none. Reach for it when the user asks to see, list, check or print something rather than change it. It is capped at 200 lines per run. There is no object.log(…) — it is never called on an item. Every run that logged shows a "Run log" panel with a Download CSV button — in Change History, on the review card, and on the Automation page's "Log only" notice — so a reporting rule can be read in a spreadsheet; say so when the user asks for a list or a report.
  - BEST PRACTICE, always: CHECK BEFORE YOU CREATE. A rule re-runs on a schedule, so a create with no existence test re-proposes the same thing on every run — a review queue full of duplicates, wasted Amazon writes, and rejections for entities that already exist. Every rule you write that creates something must be guarded:
    * Positive keywords: guard createKeyword() with "NOT HAS_KEYWORD(<the ad group it will land in>, <text>, <match>)". createKeyword() always creates in the ROW'S OWN ad group, so check that same ad group — for a search-term loop that is searchTerm.adGroup. Example: "IF searchTerm.orders >= 2 AND searchTerm.negated = FALSE AND NOT HAS_KEYWORD(searchTerm.adGroup, searchTerm.searchTerm, "exact"): searchTerm.createKeyword(searchTerm.searchTerm, "exact", $0.50)". Omit the match type in HAS_KEYWORD only when you mean "in any match type". The ONE case that needs no guard is an ad group the same run is creating (ag.createKeyword(…) after camp.createAdGroup(…)): it is empty by definition, so HAS_KEYWORD would only ever answer FALSE there.
    * Negative keywords: guard addNegative() with "searchTerm.negated = FALSE". That field is TRUE when a negative already covers the term in its ad group or campaign. There is no HAS_NEGATIVE() function — negated is the check, and it only exists on search-term rows. (The engine also drops an exact negation that already covers the term, but write the test anyway: it is visible in the rule, and it is what stops the rule proposing anything at all for terms already handled.) To exclude an ASIN from a whole Sponsored Products AUTO campaign, write searchTerm.campaign.addNegative(searchTerm.searchTerm): on an auto campaign an ASIN becomes a campaign-wide negative product target. The same call on a Sponsored Brands campaign (or an SB ad group, keyword or target) also writes a negative product target — campaign-wide means one in every ad group of the campaign. Anywhere else it stays a negative keyword.
    * Removing a negative: x.removeNegative(text[, "exact"|"phrase"]) archives the live negative keyword with that text in the scope addNegative(text) would write to (the row's ad group, or the campaign for a campaign), and for an ASIN-shaped text the negative product target too. Leave the match type out to remove either. It does nothing when there is none, so it needs no existence guard; searchTerm.negated = TRUE is the natural condition, but it is true when a negative at ANY scope covers the term, and removeNegative() only reaches the row's own scope. Reverting recreates it.
    * Product targets and ASINs: ad_group.createTarget(PRODUCT_TARGET("B0…")[, bid]) and ad_group.addAsin("B0…") already carry their own guard — both are get-or-create, so they propose nothing when the clause or the ASIN is already in that ad group. Do NOT wrap one in a HAS_TARGET/HAS_ASIN test; that only duplicates weaker logic. Use HAS_TARGET/HAS_ASIN when the rule needs the ANSWER for something else ("only bid up ad groups that already advertise this ASIN").
    * Portfolios: createPortfolio("name") carries its own guard — it is get-or-create, so it proposes nothing when the portfolio is already there. Do NOT wrap one in a FIND_PORTFOLIO test.
    * Campaigns and ad groups: createCampaign("name", budget: $20, targeting: "manual") and campaign.createAdGroup("name", $0.75) carry their own guard — both are get-or-create. Do NOT wrap one in a FIND_CAMPAIGN/FIND_AD_GROUP test. Use FIND_CAMPAIGN/FIND_AD_GROUP with "IS NOT NONE" when the rule must only act on something that ALREADY exists and must never build it — never PORTFOLIO(), which stops the run when the name is missing.
    When a user asks for a harvesting, negation or seeding rule, include the guard without being asked and mention in one short sentence that you added it. If a user explicitly asks for an unguarded create, write it but say what it will do on the second run.
  - Adding a product target or an ASIN to an ad group that ALREADY EXISTS: ad_group.createTarget(PRODUCT_TARGET("B0ABCD1234"), $0.60) and ad_group.addAsin("B0ABCD1234"). Both are called ON AN AD GROUP — a loop over ad groups, a hop such as keyword.adGroup, or FIND_AD_GROUP(campaign, "name") — never on a campaign or a search term. Both are get-or-create: safe to run every night, because what is already there is not proposed again. Build the clause with PRODUCT_TARGET(asin) or CATEGORY_TARGET(categoryId); a bare string is not a clause. addAsin is Sponsored Products only; createTarget works in Sponsored Products (a MANUAL campaign — an auto campaign's four clauses belong to Amazon) and Sponsored Brands ad groups. A Sponsored Brands ad group takes keywords OR product targets, never both — Amazon refuses the mix, so use two SB ad groups, each with its own addVideoAd(). Everything a rule adds to one ad group is proposed as a single change to review, and undoing it archives only what Amazon actually created.
  - Creating a portfolio: LET pf = createPortfolio("Scaling") is the portfolio with that name, created if there is none. It NEVER returns NONE, so it needs no IS NOT NONE guard and can be handed straight to campaign.moveToPortfolio(pf) — on the very run that creates it, because the move is written after the portfolio exists. Get-or-create, so it is safe in a rule that runs nightly: a portfolio that is already there is not proposed again. It is a FUNCTION, never a method — there is no campaign.createPortfolio(). Two portfolios sharing the name, or an archived one, stops the run rather than guessing. Note that a block using it reads only entities that delivered in the window (creating something is never "dormant-safe"), and that reverting the run never removes the portfolio.
  - moveToPortfolio takes the portfolio ITSELF — PORTFOLIO("name"), FIND_PORTFOLIO("name") or createPortfolio("name") — or NONE to take a campaign out of the one it is in. Do not pass anything else.
  - A portfolio's own settings ARE changeable: portfolio.setBudget(amount[, policy: "monthly"|"date range"][, startDate: "YYYY-MM-DD"][, endDate: "YYYY-MM-DD" or NONE]) sets its budget cap — including the budget start and end date — removeBudgetCap() takes the cap off, setName()/rename() renames it, pause()/enable()/setState() switch it, and archive() archives it for good (no revert). They work on FOR EACH portfolio rows and on FIND_PORTFOLIO("name"). A "date range" cap needs startDate:; leaving policy: out keeps the current one. To change ONE named portfolio, loop over portfolios, not campaigns — FOR EACH portfolio: / IF portfolio.portfolioName = "Scaling": / portfolio.setBudget(...) / END reads only the account's handful of portfolios.
  - RENAMED in v2.55.0, with NO alias: ensureTarget() is now createTarget(), and ensureAsin() is now addAsin(). The old spellings do not parse at all — never write them, and if a user shows you a rule containing one, tell them to change it.
  - Creating a campaign and an ad group: LET camp = createCampaign("Harvest - Exact", budget: $20, targeting: "manual") then LET ag = camp.createAdGroup("Core terms", $0.75). Both are get-or-create and never return NONE, so neither needs a guard, and both are safe in a rule that runs nightly. createCampaign takes ONE positional argument, the name; everything else is a NAMED argument — budget: and targeting: ("manual" or "auto") are REQUIRED, strategy:, portfolio: and state: are optional. createAdGroup is a METHOD on a campaign and takes two positional arguments, name and bid, plus the optional state:. That is the Sponsored Products shape (the default); for Sponsored Brands see adType: "brands" above.
    * A rule that uses either MUST have the comment "# ALLOW CREATION" on a line of its own; without it the rule will not save. Add it yourself whenever you write one, and say in one short sentence that you did. createPortfolio does NOT need it.
    * Everything created is LIVE — the structure serves as soon as the run is applied, with no second run and nothing to switch on. Say state: "paused" on any create verb (createCampaign, createAdGroup, addAsin, createKeyword, createTarget) to build that piece switched off instead; "enabled" and "paused" are the only two values. A rule still CANNOT enable, rename, set the budget of, or move a campaign it is creating in that same run — those are static errors, because the entity has no id until the run is applied. Anything settable at creation is a named argument; anything else is a later run's job, once FIND_CAMPAIGN() answers with the real campaign. Never write camp.enable() after createCampaign(camp) — it is refused, and it is also pointless now that the campaign is already live.
    * What you CAN do on the same run is build inside it: ag.addAsin("B0…"), ag.createKeyword("text", "exact", $0.80) and ag.createTarget(PRODUCT_TARGET("B0…"), $0.60) all work on an ad group the same run is creating. That is the point of the chain — a harvest rule builds the destination and fills it in one pass.
  - There is no ENSURE_CAMPAIGN, ENSURE_AD_GROUP or ENSURE_PORTFOLIO — never invent one, it will not save. Every creation verb is spelled create* in camelCase like createKeyword, even createCampaign and createPortfolio, which are bare functions rather than methods.
  - When a request is PARTLY impossible, do not stop at explaining. WRITE THE RULE FOR THE PART THAT WORKS, show it, and then say in one or two sentences which piece was left out and why. Offering a list of alternatives and waiting is the wrong answer: give them the script, then the caveat. Only ask a follow-up question when you genuinely cannot proceed without one — and even then, write the rule with a clearly marked placeholder so they can see the shape. A user asking to "create a portfolio, then a campaign per search term, each with its ad group, its ASIN and its keyword, at bid = CPC + $0.02" can have that whole chain in one search-term loop; the piece to flag is that a campaign named from a search term makes ONE campaign PER TERM, which is usually not what was meant.
  - THE CHILD LOOP — a FOR EACH written INSIDE a block, iterating what is inside the row already in hand. This is how ONE rule crosses two dimensions, and it is the answer to every "a campaign per search term AND per ASIN" request: "FOR EACH product IN searchTerm.adGroup:" walks the ASINs advertised in that search term's ad group, each one a full row whose fields, metrics and actions read exactly as they do in a top-level loop. What follows IN is the PARENT OBJECT — searchTerm.adGroup, keyword.campaign, a LET holding one, FIND_AD_GROUP(...) — and NEVER a timeframe; a timeframe goes after OVER ("FOR EACH product IN searchTerm.adGroup OVER LAST 30 DAYS:") and defaults to the outer block's. Close it with its own END. The pairings are the contents the collection functions list: product, keyword, target and adGroup from a campaign, product, keyword and target from an ad group. The same loop can OPEN a rule when the request is about ONE named campaign or ad group: "LET camp = FIND_CAMPAIGN("Brand - Exact")" then "FOR EACH keyword IN camp OVER 8..35:" reads only that campaign (8..35 is days 8 to 35 ago, the same as FROM 8 DAYS AGO TO 35 DAYS AGO) — use it instead of looping over every campaign and testing its name. searchTerm is a child of a campaign or an ad group too (FOR EACH searchTerm IN product.adGroup OVER LAST 30 DAYS): the report rows of that campaign for that window. Each distinct campaign is one read of the largest table there is and runs are capped, so narrow the loop around it (an IF on the product's ACOS) rather than asking it of every row; for the whole account loop over search terms as a top-level table. nest two levels deep under the top-level loop (campaign, then ad group, then keyword) and no deeper. Do NOT write "FOR EACH product IN ASINS(ag)": ASINS()/KEYWORDS()/TARGETS()/AD_GROUPS() answer with NAMES for IN/COUNT()/JOIN(), while the child loop reads the rows straight out of the parent, so the parent is what follows IN.
  - Seeding campaigns from proven search terms — single-keyword, "MD", harvest, SKAG, all the same request — is the shape to reach for, and the child loop is what makes it whole: FOR EACH searchTerm, IF the term qualifies, then FOR EACH product IN searchTerm.adGroup OVER <window>, IF that ASIN qualifies, build the chain per ASIN: LET pf = createPortfolio(...), LET camp = createCampaign(name, budget: ..., targeting: "manual", portfolio: pf), LET ag = camp.createAdGroup(name, bid), ag.addAsin(product.asin), ag.createKeyword(searchTerm.searchTerm, "exact", bid). Name the campaign with CONCAT so it can carry both dimensions — CONCAT("MD, ", product.asin, ", ", searchTerm.searchTerm). Every create is get-or-create, so the rule is safe nightly, and the whole structure is LIVE once the run is applied — say that in one sentence, and mention state: "paused" only if they ask to inspect it before it spends. Inside the child loop the row is the PRODUCT: to print something off the outer row in a note() or log(), write it dotted ("{searchTerm.searchTerm}", "{searchTerm.orders}") or bind it to a LET first; a bare "{searchTerm}" is the outer ROW, not a value.
  - AMAZON'S SUGGESTIONS are two more child loops, for "add the keywords Amazon suggests", "target the products Amazon suggests" and "use Amazon's suggested bid": inside a loop over ad groups (or products), "FOR EACH suggestedKeyword IN adGroup:" walks the keywords Amazon suggests for that ad group — one row per keyword AND match type, with keywordText, matchType, bid (Amazon's suggested bid for that match type), bidLow, bidHigh, rank (1 = best), impressionRank, impressionShare and alreadyTargeted (TRUE when the ad group already has that keyword in that match type). "FOR EACH suggestedAsin IN adGroup:" walks the products Amazon suggests targeting for the ASINs the ad group advertises, with asin, themes (a list), rank and alreadyTargeted. Written IN a product, suggestedKeyword gives the keywords for that one ASIN. Act on a row with the OUTER ad group: adGroup.createKeyword(suggestedKeyword.keywordText, suggestedKeyword.matchType, suggestedKeyword.bid) and adGroup.createTarget(PRODUCT_TARGET(suggestedAsin.asin)), always guarded with "NOT suggestedKeyword.alreadyTargeted" / "NOT suggestedAsin.alreadyTargeted" so a nightly rule proposes each once, and usually with a cap on rank. These are asked of Amazon, not read from the account's data, so: they exist ONLY as a nested loop (there is no top-level FOR EACH suggestedKeyword), each distinct ad group or product is one request to Amazon and a run may ask for a limited number — so put the child loop under an IF that narrows the outer loop to the ad groups worth asking about (orders, ACOS, a name) rather than looping the whole account; they are Sponsored Products only; and a suggestion has no metrics (no clicks, orders, acos). Creating keywords from them needs "# ALLOW CREATION" only if the rule also creates campaigns or ad groups — createKeyword and createTarget into an existing ad group do not. Cap the bid when the user wants a ceiling: MIN(suggestedKeyword.bid, $1.00).
  - keyword.bid / target.bid resolve an entity that has no bid of its own to the ad group's defaultBid — the bid Amazon actually uses at auction. bidInherited is TRUE on exactly those rows (FALSE when the entity has an explicit bid); add "AND object.bidInherited = false" to skip inherited rows. Applying setBid always writes an explicit bid, so the entity stops following the ad group default afterwards.
  - Amazon reports a keyword/target/ASIN only on days it delivered, so one with zero impressions in the window has no report row. FOR EACH keyword, target and product still reach those entities (every metric reads 0) when the condition could match a zero-metric row — "impressions < 1000" and rules with no metric test can, "clicks > 7" cannot. For setBid/note that is all it takes. For a DESTRUCTIVE action (pause, archive, enable, setState, addNegative, createKeyword) the condition must additionally TEST IMPRESSIONS in a way a zero row passes: "impressions = 0" or "impressions < 50" reaches entities that never ran, while "clicks = 0" or "orders = 0" describes one that ran and failed and deliberately does NOT. So when a user asks to pause/prune things that "never ran", "got no impressions", or "are dead", write the condition with impressions, not clicks — "IF product.impressions = 0: product.pause()". Writing it with clicks silently matches nothing among those entities. This is all automatic; never tell a user to add anything to get it. Archived entities are never included; campaigns/adGroups already included their zero-impression rows; search terms cannot (one nobody typed does not exist). To override, put the comment "# INCLUDE ZERO IMPRESSIONS" (or "# EXCLUDE ZERO IMPRESSIONS") on its own line above a FOR EACH; it applies to every block below it until another overrides it. Only add it when the user explicitly wants a destructive rule to act on entities with no impressions without an impressions test, or wants a large account to skip them for speed.
  - By default (Profile → General, "Skip paused and archived campaigns") a FOR EACH does not iterate entities that sit under a paused or archived campaign — a campaign paused mid-window still reported for the days before it, so its keywords/targets/ad groups/ASINs/search terms would otherwise still turn up. Two kinds of loop keep their PAUSED campaigns automatically: one that can run enable() or setState(), and one that reads campaign state itself (campaign.state, keyword.campaignState, target.campaign.state, or the hop bound to a variable first — "LET campaign = keyword.campaign" then "campaign.state"). ARCHIVED campaigns are skipped no matter what — even with the setting off and even for those two loops — because Amazon rejects every write into one. This is all automatic; never tell a user to add anything to get it, and never add a "campaign.state = 'ENABLED'" test just to achieve it. To override, put the comment "# INCLUDE PAUSED CAMPAIGNS" (or "# EXCLUDE PAUSED CAMPAIGNS") on its own line above a FOR EACH; it applies to every block below it until another overrides it. Only add it when the user explicitly wants a rule to act inside paused campaigns (e.g. preparing bids before a relaunch) or explicitly wants them skipped regardless of the account setting.
  - campaign.placement is a read-only namespace for the campaign's Sponsored Products placement bid adjustments: campaign.placement.top_of_search (alias tos), campaign.placement.rest_of_search, campaign.placement.product_pages — each a whole-number percentage 0–900. The flat spellings campaign.top_of_search / placementTopPct etc. read the same values. Change them with campaign.setPlacementBid(placement, percent), never by assignment. How long ago one changed is NOT in this namespace — ask DAYS_SINCE_CHANGE("top of search") or campaign.daysSincePlacementChange.
  - Audience bid adjustments (Sponsored Products only): a campaign can carry up to 10 bid adjustments, each for ONE audience — Amazon's own ("High interest…", "Purchased brand's product", "Clicked or added brand's product to cart", "High likelihood of purchase") and the advertiser's Amazon Marketing Cloud ones — each a whole-number percentage 0–900 on top of the placement adjustments. READ one with AUDIENCE_BID("high interest") — the campaign's current percentage for that audience, 0 when it has none, NONE on anything but a Sponsored Products campaign. CHANGE one with campaign.setAudienceBid("high interest", 50); a percentage of 0 REMOVES the adjustment, and only that audience changes — the campaign's other audiences, placements and bidding strategy are left as Amazon holds them. Name the audience the way Campaigns → Audiences shows it (part of the name is enough when it matches exactly one audience, case and punctuation ignored) or by its audience id. A name that matches nothing, or several audiences, stops the rule with a message naming the choices, so never invent an audience name — if the user has not said which audience, ask, or write the rule on one named audience they can edit. There is no campaign.audience namespace and no field per audience: AUDIENCE_BID("…") is the only read. Cool-down: DAYS_SINCE_CHANGE("audience") / campaign.daysSinceAudienceChange (aliases days_since_audience_change, last_audience_change / campaign.lastAudienceChangeAt) is the newest audience change of ANY audience on the campaign — put one on any rule that calls setAudienceBid, for the same reason as placements: an adjustment needs days of traffic before it shows. An audience boost is not a placement: setPlacementBid never touches audiences and setAudienceBid never touches placements. Per-audience performance (impressions, orders, ACOS by audience) is NOT available to rules or the API — only the percentages and when they changed.
  - IF() is the value-returning function. IF condition: is the block statement.

Complete example:
FOR EACH keyword IN LAST 30 DAYS:
  IF keyword.clicks > 30 AND keyword.orders = 0:
    keyword.pause()
    keyword.note("{clicks} clicks and 0 orders")
END
```

## Worked examples

Each of these validates as written. The thresholds are starting points: a $5 niche and a
$30 niche need different numbers.

### Pause bleeders

The classic first rule: keywords with plenty of clicks and nothing to show for them.

```text
FOR EACH keyword IN LAST 30 DAYS:
  IF keyword.clicks >= 25 AND keyword.orders = 0:
    keyword.pause()
    keyword.note("{clicks} clicks, {spend:money} spent, 0 orders")
END
```

### Pause a keyword that only bleeds when you add it up

$4 wasted in each of ten campaigns is invisible to the rule above, every instance looks harmless. An accumulated report sums the keyword across all of them first, then pauseEverywhere() switches off every instance at once.

```text
CREATE ACCUMULATED KEYWORD REPORT IN LAST 60 DAYS

FOR EACH accumulated_keyword:
  IF accumulated_keyword.spend >= $40 AND accumulated_keyword.orders = 0:
    accumulated_keyword.pauseEverywhere()
    accumulated_keyword.note("{spend:money} across {campaigns} campaigns, no orders")
END
```

### Stop an ASIN that quietly eats budget everywhere

The same idea one level up: an ASIN summed across every campaign it advertises in. Swap pauseEverywhere() for note() alone if you want a list to read before acting. The # ACTIVE ONLY line is the default written down, leave it out and the rule behaves identically, but with it there you can see at a glance that ASINs you have already switched off are left alone.

```text
# ACTIVE ONLY
CREATE ACCUMULATED ASIN REPORT IN LAST 30 DAYS

FOR EACH accumulated_asin:
  IF accumulated_asin.spend >= $25 AND accumulated_asin.orders = 0 AND accumulated_asin.live_ads > 1:
    accumulated_asin.pauseEverywhere()
    accumulated_asin.note("{spend:money} over {ads} ads, no orders")
END
```

### Bid up the keywords of an ASIN that is proven across every campaign

Judges each ASIN on its total across all campaigns, then raises bids on the keywords that actually sell it. The loop reaches every ad group the ASIN delivered through, so the two guards are what narrow it: COUNT(ASINS(...)) keeps the raise off ad groups advertising more than one product, where the winner's keywords are shared with its neighbours, and the keyword count keeps it off ad groups big enough to make one ASIN an expensive decision. Drop either one if you want the wider reach.

```text
CREATE ACCUMULATED ASIN REPORT IN LAST 60 DAYS

FOR EACH accumulated_asin:
  IF accumulated_asin.orders > 10 AND accumulated_asin.cpo < $2.50:
    FOR EACH keyword IN accumulated_asin:
      IF keyword.state = "ENABLED"
         AND COUNT(ASINS(keyword.adgroup)) = 1
         AND COUNT(KEYWORDS(keyword.adgroup)) <= 20:
        keyword.setBid(MIN($1.50, keyword.bid * 1.10))
        keyword.note("ASIN {accumulated_asin.asin} proven: {accumulated_asin.orders} orders at {accumulated_asin.cpo:money}")
    END
END
```

### Bid down high-ACOS keywords (with floor and cool-down)

Cuts bids 15% where ACOS runs hot, never below $0.05, and waits a week between cuts so changes can take effect before being judged.

```text
FOR EACH keyword IN LAST 30 DAYS:
  IF keyword.acos > 45% AND keyword.orders >= 1 AND DAYS_SINCE_CHANGE("bid") > 7:
    keyword.setBid(MAX($0.05, keyword.bid * 0.85))
    keyword.note("ACOS {acos:percent} over target")
END
```

### Trim one campaign's dead keywords, judged on settled days

A rule for a single campaign, named rather than searched for: nothing else in the account is read. 8..35 is days 8 to 35 ago, so the newest week, still being attributed, is left out of the judgement. If the name matches nothing, the run log says so instead of quietly doing nothing.

```text
LET camp = FIND_CAMPAIGN("Brand - Exact")

FOR EACH keyword IN camp OVER 8..35:
  IF keyword.clicks >= 10 AND keyword.orders = 0 AND DAYS_SINCE_CHANGE("bid") > 7:
    keyword.setBid(MAX($0.05, keyword.bid * 0.85))
    keyword.note("{clicks} clicks, no orders")
END
```

### Bid up proven winners (with ceiling)



```text
FOR EACH keyword IN LAST 30 DAYS:
  IF keyword.orders >= 3 AND keyword.acos < 20% AND DAYS_SINCE_CHANGE("bid") > 7:
    keyword.setBid(MIN($1.20, keyword.bid * 1.10))
    keyword.note("{orders} orders at {acos:percent} ACOS, scaling")
END
```

### Fill a new auto campaign's empty clause bids

Every auto ad group ships with four predefined clauses, and by default all four bid the ad group's default. This is Actions → Adjust Auto Targeting Bid on a schedule: it gives each clause its own bid the night the campaign appears. when: "empty" means it only fills clauses that have no bid of their own, so a bid you set by hand is never overwritten, and the clauses are read live from Amazon, so it reaches the three that have never delivered and are in no report.

```text
FOR EACH campaign:
  IF campaign.targetingType = "auto":
    campaign.setAutoTargetingBid("close match", $0.45, when: "empty")
    campaign.setAutoTargetingBid("loose match", $0.30, when: "empty")
    campaign.setAutoTargetingBid("substitutes", $0.30, when: "empty")
    campaign.setAutoTargetingBid("complements", $0.20, when: "empty")
    campaign.note("Auto clauses filled, they were on the ad group default")
END
```

### Scale budgets on performance

Raises budgets on campaigns that convert efficiently, trims campaigns that spend without selling.

```text
FOR EACH campaign IN LAST 30 DAYS:
  IF campaign.acos < 25% AND campaign.orders >= 5 AND DAYS_SINCE_CHANGE("budget") > 7:
    campaign.setBudget(MIN($50, campaign.budget * 1.25))
    campaign.note("{orders} orders at {acos:percent}, raising budget")

  IF campaign.spend > $30 AND campaign.sales = 0 AND DAYS_SINCE_CHANGE("budget") > 7:
    campaign.setBudget(MAX($1, campaign.budget * 0.50))
    campaign.note("{spend:money} spent, no sales, cutting budget")
END
```

### Harvest converting search terms

Promotes shopper queries that convert into exact keywords in their own ad group.

The targetingType test is not optional. createKeyword builds the keyword in the ad group its search term ran in, and Amazon allows only negative keywords inside an auto campaign, so terms harvested out of an auto campaign have to land somewhere else, which this rule cannot do. Terms from auto campaigns are skipped (the run log says how many), as is any term that is itself an ASIN, Amazon only accepts those as product targets. Moving an auto campaign's winners into a manual campaign is the one harvesting job a rule cannot do for you: pick the destination ad group in the Amazon console, and let a rule keep it tuned from there.

```text
FOR EACH searchTerm IN LAST 60 DAYS:
  IF searchTerm.orders >= 2 AND searchTerm.acos < 30% AND searchTerm.campaign.targetingType = "MANUAL":
    searchTerm.createKeyword(searchTerm.searchTerm, "exact", MAX($0.10, searchTerm.cpc * 1.10))
    searchTerm.note("Harvested: {orders} orders at {acos:percent}")
END
```

### Negate wasteful search terms

Blocks queries that click but never convert, the highest-leverage cleanup there is, because a negative stops the spend completely rather than trimming it.

Exact is the default, and usually the right one. A negative exact blocks only that precise query; a negative phrase blocks every query containing it, which is what you want for a genuinely toxic word (a competitor brand, "free", an irrelevant niche) and a quiet disaster otherwise. Placement follows the source: the negative goes into the ad group the term actually delivered from, so the same query stays free everywhere else. Terms already covered by an exact negative are skipped rather than re-sent, and every negative MerchDash adds is in Change history and revertible in one click.

```text
FOR EACH searchTerm IN LAST 60 DAYS:
  IF searchTerm.clicks >= 12 AND searchTerm.orders = 0 AND searchTerm.negated = FALSE:
    searchTerm.addNegative(searchTerm.searchTerm, "exact")
    searchTerm.note("{clicks} clicks, no orders, negated")
END
```

### Trend brake: this week vs. your baseline

Compares a keyword's recent ACOS to its own longer history and reacts only to genuine deterioration.

```text
FOR EACH keyword IN LAST 7 DAYS:
  LET recent_acos = keyword.acos
  LET baseline_acos = keyword.acos IN FROM 8 DAYS AGO TO 60 DAYS AGO

  IF keyword.clicks >= 10 AND recent_acos > baseline_acos * 1.5:
    keyword.setBid(MAX($0.05, keyword.bid * 0.85))
    keyword.note("ACOS {recent_acos} vs baseline {baseline_acos}")
END
```

### Placement tuning

Rewards campaigns that convert well with a top-of-search boost.

```text
FOR EACH campaign IN LAST 30 DAYS:
  IF campaign.acos < 20% AND campaign.orders >= 10 AND campaign.placement.top_of_search < 50:
    campaign.setPlacementBid("TOP OF SEARCH", 50)
    campaign.note("Efficient at {acos:percent}, boosting top-of-search")
END
```

### Portfolio housekeeping

Files paused, long-dead campaigns into an archive portfolio so working views stay clean.

```text
FOR EACH campaign IN LIFETIME:
  IF campaign.state = "PAUSED" AND (campaign.clicks IN LAST 90 DAYS) = 0 AND campaign.portfolioName != "Archive":
    campaign.moveToPortfolio(PORTFOLIO("Archive"))
    campaign.note("Paused and idle 90+ days")
END
```

### Move winners into a portfolio, and build it the first time

The housekeeping rule above needs the Archive portfolio to exist already, PORTFOLIO() stops the run when it does not. createPortfolio() is the version that makes it: the portfolio with that name, created when there is none, so the rule works on a fresh account and every night after it. It never answers NONE, so it needs no guard, and it proposes nothing once the portfolio is there. Approving the creation looks Amazon up first, so a portfolio you made in the console keeps its campaigns and history instead of being duplicated.

```text
FOR EACH campaign IN LAST 30 DAYS:
  IF campaign.orders >= 10 AND campaign.acos < 20%:
    LET scaling = createPortfolio("Scaling")
    campaign.moveToPortfolio(scaling)
    campaign.note("Winner: {orders} orders at {acos:percent}")
END
```

### Give one portfolio a budget window

Caps what a launch portfolio may spend between two dates. To act on one portfolio, loop over portfolios rather than campaigns: FOR EACH portfolio reads only your account's handful of portfolios, so the rule finishes in moments instead of walking every campaign to find the ones inside it. "date range" is one total for the whole window, and the portfolio's campaigns stop once the end date passes; "monthly" resets every calendar month instead. Leave policy: out to keep the cap type the portfolio already has, and write endDate: NONE to remove an end date. Reverting the change in History puts the previous cap back exactly.

```text
FOR EACH portfolio:
  IF portfolio.portfolioName = "Q4 Launch":
    portfolio.setBudget($500, policy: "date range", startDate: "2026-11-01", endDate: "2026-12-31")
    portfolio.note("Q4 window: $500 from Nov 1 to Dec 31")
END
```

### Raise a monthly portfolio cap that keeps running out

portfolio.inBudget is Amazon's own flag, and it turns false once a portfolio has spent its cap. This rule gives those portfolios 20% more room each time it runs, up to a ceiling you choose. Run it weekly rather than daily, so each raise has time to show before the next one. removeBudgetCap() takes the cap off entirely, if that is what you mean instead.

```text
FOR EACH portfolio:
  IF portfolio.budgetPolicy = "MONTHLY_RECURRING" AND portfolio.inBudget = FALSE AND portfolio.budgetAmount < 1000:
    LET new_cap = MIN($1000, ROUND(portfolio.budgetAmount * 1.2, 2))
    portfolio.setBudget(new_cap)
    portfolio.note("Ran out of its {budgetAmount:money} monthly cap, raised to {new_cap:money}")
END
```

### Placement tuning, one placement at a time

Raise top of search where the campaign converts, back it off where it does not, and leave each campaign alone for a week afterwards so the adjustment has time to show. DAYS_SINCE_CHANGE("top of search") is the cool-down for that one placement, a product-pages change made yesterday does not block it. See placement cool-downs.

Use campaign.daysSincePlacementChange instead when nothing about the campaign's placements should move if any of the three was touched recently.

```text
FOR EACH campaign IN LAST 30 DAYS:
  IF campaign.adType = "SP" AND campaign.orders >= 5 AND campaign.acos < 25% AND DAYS_SINCE_CHANGE("top of search") > 7:
    campaign.setPlacementBid("TOP OF SEARCH", MIN(100, campaign.placement.top_of_search + 10))
    campaign.note("{orders} orders at {acos:percent}, more top of search")

  IF campaign.adType = "SP" AND campaign.clicks >= 50 AND campaign.acos > 60% AND DAYS_SINCE_CHANGE("top of search") > 7:
    campaign.setPlacementBid("TOP OF SEARCH", MAX(0, campaign.placement.top_of_search - 10))
    campaign.note("ACOS {acos:percent} on {clicks} clicks, less top of search")
END
```

### Season ramp (pair with a season window)

Save with a season window (e.g. Nov 1 – Dec 20) and a second "ramp-down" rule after it.

```text
# Season: 2026-11-01 → 2026-12-20, schedule: weekly
FOR EACH campaign IN LAST 30 DAYS:
  IF campaign.acos < 35% AND campaign.orders >= 3 AND DAYS_SINCE_CHANGE("budget") > 6:
    campaign.setBudget(MIN($100, campaign.budget * 1.30))
    campaign.note("Q4 ramp: {orders} orders at {acos:percent}")
END
```
