Skip to content

Documentation

Write it out — the language

The whole Lookout as a short block of text — the same rule the builder edits, plus the things only text can say.

Every Lookout can be written out as a short block of text: the same rule the builder edits, in one shared language. Text is how you paste a rule somebody sent you, diff two rules, or say the things the builder has no boxes for.

What runs is always the rule itself, never the text. Parsing produces the rule, and the rule is what is saved.

on target
in all markets
when
  clicks(last30) >= 15 and ad_orders(last30) = 0
then pause
with min_clicks 15, cooldown 14, max_per_run 50, fresh_data

It reads top to bottom: on targets, in every market, when a month of clicks brought no orders, propose pausing — with these safety limits.

The clauses

A rule opens with on; after that the clauses can come in any order. There is no name clause — the name lives in the field beside the editor, so a rule pasted as text arrives unnamed and you name it there.

ClauseWhat it says
onWhat the rule acts on: campaign, ad_group, target, ad, search_term, negative, product, variant, gap. Required, and first.
letA named value or list, before when.
inThe marketplaces: in all markets or in US, DE. "All" means the markets your account actually has rows in, resolved when the rule runs. Optional.
runmanually, daily, weekly or on publication — floors, not clocks. The schedule normally lives in the rule's settings; if you write a run line anyway it wins, and the panel says so.
whenThe condition. Required and non-empty — a rule with no when is not a rule that does nothing, it is an unfinished one.
thenWhat it proposes: one action, or a ladderthen case(when … then …, else …). A second then is refused; the ladder goes inside the first one.
withThe guardrails.

Comments start with //. They are read and then let go: the editor's Format button re-prints the rule from what it means, so a comment does not survive a reformat. Neither does a run line — the printer does not emit one.

Conditions

A condition is a property, an operator and a value. Metrics carry their period in parentheses; settings and facts take none.

spend(last30) > 10                     // a metric, over a period
state = "ENABLED"                      // a setting — no period
campaign_name contains "auto"
acos(last30) between 40 and 80
search_term_shape in ("ASIN", "PHRASE")
spend(last7) < spend(last30) * 0.25    // compare with another metric
bid is empty                           // inherits the group default
days_since_bid_change >= 30
OperatorsNotes
> < >= <= = !=Numbers, money, percents, ratios.
between A and BAn inclusive range.
contains · not contains · starts with · ends withText. Case-insensitive.
contains any (…) · not contains any (…)Text against a list, or a named list from let. Case-insensitive.
in (…)Membership: state in ("ENABLED", "PAUSED"). Case-sensitive.
is empty · has valueThe only operators that can see a missing value.
Case matters in two places and not in the others. The contains family folds case; =, != and in (…) do not. campaign_name = "AUTO" will not match a campaign called auto, and state in ("enabled") matches nothing at all. Enum values are Amazon's own, uppercase.

The right side of a comparison may be another metric, optionally scaled. spend(last7) < spend(last30) * 0.25 is how "the spending stopped" is said without any slope column existing.

Grouping, and real nesting

and and or join conditions, brackets group, and groups nest. One level is joined by one connector: a and b or c is refused as reading two ways — bracket the tighter half.

clicks(last30) >= 15 and (acos(last30) > 120 or ad_orders(last30) = 0)

There is no boolean not; it survives only inside not contains. And one group asks about one thing: conditions about the row and conditions about its campaign cannot share a connector.

Looking up, and counting down

A condition can reach up to an ancestor with a qualifier — a target can ask about its ad group or campaign, a variant about its product:

on target
when
  acos(last30) > 80
  and campaign.spend(last30) > 50

Asking down is counting, and it takes a quantifier — any(), none(), every(), at_least(3, …), at_most(2, …) — over what the row contains:

on campaign
when
  spend(last30) > 20 and ad_sales(last30) = 0
  and every(target: clicks(last30) >= 3 and ad_orders(last30) = 0)
every() refuses an empty set. "Every target failed" is not true of a campaign that has no targets, so it compiles as has at least one, and none of them fails the test. none() and at_most() stay true on an empty set, because that is what those sentences mean in English.

Periods

A period is a preset: today, yesterday, last7, last14, last30, last60, last90, last180, thisMonth, lastMonth — plus lifetime where a grain genuinely keeps one. These are pre-aggregated columns, which is what makes a Lookout across thousands of rows affordable on every sync.

Two derived shapes are built from them:

  • previous — the period before a period. acos(previous last30) is days 31–60, computed by subtraction from the stored 60-day column. Only previous last7, previous last30 and previous last90 exist, because each needs a stored period twice as long.
  • without — a period with its recent end cut off. spend(last30 without last7) is days 8–30 — the shape that separates a target that stopped from one that was always hopeless. The cut-out must be a shorter trailing period inside the outer one.

Values

  • Percents are the displayed number. acos(last30) > 20 means 20%, exactly as the dashboard shows it. A ratio is a multiplier: a ROAS of 3, never 300%.
  • Money is a bare number in the ads account's own currency. No currency signs — the extension holds no exchange rates. min_spend 5 is five dollars in the US and five euros in Germany, which is what a person means.
  • Text is matched case-insensitively by contains and its family; enum values are quoted and uppercase: "ENABLED", "PAUSED", "EXACT".
  • Missing is not zero. is empty and has value exist for values that can genuinely be missing: acos at zero volume, a bid that inherits the group's, a price still in draft. On columns stored as measured zero — clicks, spend, unitsis empty is refused outright, because there it would not be a filter, it would be a lie. And an ordinary comparison against a missing value is simply not true, so a rule about "unchanged for 30 days" holds back the rows it cannot see rather than firing hardest on the things it knows least about.

Named values and lists

let binds a name to an expression or a list, above the when that uses it. A binding stands wherever a metric stands and takes no period of its own — the periods live inside its definition.

on target
let lost = spend(last30) - ad_sales(last30) * 0.35
in all markets
when
  clicks(last30) >= 10 and lost > 5
then pause
with min_clicks 10, cooldown 30, max_per_run 25, fresh_data

That rule answers "did it take more money than the royalty its sales were worth" — arithmetic no column holds. The 0.35 is a royalty share you would set to your own.

A list binding feeds in and contains any: let brands = ("nike", "adidas") and then search_term not contains any brands — forty competitor terms written once instead of three drifting copies.

Verbs

then takes one verb — or several, in order, which is a ladder. The verb resolves against the grain: pause on a target rule pauses targets, on a campaign rule campaigns. A verb that does not apply to the grain is unsayable, and the error lists what this grain can do.

Written asOnWhat it proposes
pause · enable · archivetarget, ad_group, adA state change. Campaigns take pause and enable only.
set bid to 0.75 · lower bid by 20% · raise bid by 10%target, ad_groupA bid move. The % is optional.
set budget to 5 · lower budget by 20%campaignA daily-budget move.
negate exact · negate phrase · negate productin ad_group / in campaignsearch_termA negative keyword, or a negative product for a query that is an ASIN. Scope defaults to the ad group.
promote exact at 0.55 · promote phrase at default * 1.25search_termGraduate a converting query into a keyword — priced outright, or from the destination group's default bid. at default alone means the default itself.
target product [expanded] at defaultsearch_termThe positive twin for ASIN-shaped queries. The price is optional here.
un_negatenegativeReverse an exclusion.
split it at 3.00 [bidding 0.45]adMove a product out of a shared campaign into its own, at the daily budget you name.
advertise itproductAdd the product to where its design is already advertised. An advertising action, not a catalogue one.
tag "review me"productA local tag, 40 characters at most. Amazon never sees it — this is how a Lookout asks you to look.
report onlyevery grainFind and show, change nothing. Skips every hold, which makes it the honest way to observe.
set price to 21.99 · publish it · offer colour "BLACK" · stop offering fit "WOMENS"product, gapThe catalogue verbs. They parse and preview, and decline to queue.
Why the catalogue verbs are held. Every advertising proposal can be walked back — pause and enable, lower and raise. A price edit is a content review that takes the listing off sale until it clears, and a publish's only undo is deletion, which throws away the reviews and the rank. Until the write path earns the same safety, a rule can find these and cannot queue them. You can still make the same changes by hand. publish it additionally demands with max_per_run before it even parses. There is no delete verb at all, deliberately: a Lookout that wants a product gone tags it, and a person does the deleting.

Computed values

set bid to and its siblings take a full expression — arithmetic over metrics, a case() ladder, a let name:

then set bid to case(
  when acos(last30) > 60 then bid * 0.7,
  when acos(last30) > 40 then bid * 0.85,
  else bid
)

The else is required — the one opinion in the syntax. A row matching no arm must be told what happens to it; else bid means "leave it alone", and it lands in the run as exactly that.

Several verbs, in order

The same word, one position over. case() in the value decides how much; case() in the then decides which verb:

on target
in all markets

when
  clicks(last30) >= 10

then case(
  when acos(last30) > 150 then pause,
  when acos(last30) > 80 then lower bid by 20%,
  else nothing)

with min_clicks 10, max_per_run 25, cooldown 30, max_change 25%

Each line is a rung. They are ordered, the first that matches wins, and exactly one rung answers any row — so the two verbs can never both be proposed for the same target.

Written as two lookouts instead, the gentle one has to say acos(last30) <= 150 as well, because otherwise it fires on the targets the stern one just paused. That second threshold is the first one written backwards, and nothing keeps the pair in step: edit 150 in one place and forget the other, and a target at exactly 150 is claimed by neither — silently, for as long as the pair exists.

Four things to know:

  • else is required here too. else nothing leaves the rows no rung claimed alone, and they are reported as neither matched nor held back — this lookout has nothing to say about them. else with a verb gives the ladder a final rung that claims everything the others did not.
  • Two rungs may not share a verb. Two rungs that both lower a bid are one verb with two amounts, and that is set bid to case(…) — the ladder in the value, above.
  • The guardrails belong to the lookout, not the rung. max_per_run 25 is twenty-five rows in total, shared out in ladder order, so the sternest rung is served first. The cooldown is per rung and per row, because the ledger remembers which verb it proposed.
  • The builder cannot draw one. It has one action row. A lookout with a ladder opens in the text editor, and the toggle to the builder is not offered for it — the form would show the first rung as the whole thing.

Guardrails

WordWhat it holds back
min_clicks 15 · min_orders 3 · min_spend 5Volume floors — enough evidence to judge. Also what makes = 0 conditions sound: zero-volume rows are stored as measured zeros, so a floor is what separates "ran and failed" from "never ran".
cooldown 14Days before the same row can be proposed again.
max_per_run 50A cap on proposals per run. Mandatory for publish it, and what lets a rule matching more than 5,000 rows run at all.
max_change 20%A ceiling on how far one move may go, as a percentage of the row's current value. A row whose current value is unknown is held back rather than waved through.
fresh_dataOnly judge rows whose data has settled. On the advertising grains this refuses the whole run when the summaries are behind; elsewhere it holds back rows fetched more than a week ago.

What the language refuses, and why

You wroteThe editor says
a and b or cOne level mixes and and or, which reads two ways — bracket the tighter half.
spend(43)Not a period. It lists the presets, the three previous forms and without.
be_acos(last30) on a target ruleBreak-even is a product's economics, so it lives on the ad grain — the one place an ad names a product. A shared campaign judged against one member's royalty would be judged against economics that belong to somebody else.
is empty on spendSpend cannot be missing; it is stored as measured zero.
spend(lifetime)Ads history is not kept per entity for life. The period parses, the data does not exist, so it refuses rather than silently matching nothing.
then pause then a second thenA rule has one then. Alternatives by severity go inside it: then case(when … then …, else …).
two rungs that both lower the bidOne verb with two amounts, which is set bid to case(…) — a case in the VALUE, not two rungs.
a ladder with no elseEvery case ends with one. else nothing leaves the rows no rung claimed alone; that is a decision, and the language makes you write it.
a rule with no whenNot a rule that does nothing — an unfinished one.
let after whenBindings come before when: a name has to exist before the line that uses it.
let spend = …Already a metric. A binding cannot rename one.
then deleteNot a verb this language has, at any grain.

Every refusal carries a line and a column, and names the nearest thing that works. A rule that parses, runs and matches nothing would be worse than one that stops you.

What an assistant can write of it

A connected assistant composes lookouts from this same language, and from a smaller part of it. It has every metric and fact, every period including the previous forms, comparison against a number or against another metric, arithmetic through let, the guards, and one verb.

What it does not have is the half that takes words you chose: contains, starts with, in (…) and their kin, and lists. A lookout's rule is stored with your data and read back to the assistant in later conversations, so a phrase somebody else picked would come back wearing your product's voice. Those stay yours to write, here.

Two smaller ones follow from how it is read rather than from safety. It cannot name a binding — they are v1 and v2, and the definition is printed above the rule wherever the rule is shown, so the name never has to carry meaning. And it cannot name the lookout: that string sits beside the Approve button, and the engine writes it from the rule itself.

Format, round trips, and sharing

Format re-prints your text in the language's one canonical rendering — the same rule, tidied. Text that does not parse is returned unchanged rather than half-rewritten.

The whole format rests on one tested invariant: printing a rule, parsing it back and printing again lands on the same bytes. The text you send somebody is the rule, not a description of it.