Trigger manual
The engine has two separate trigger systems that are easy to mix up:
- Engine trigger detectors — nightly detectors that feed the
recommendation pipeline. They never send anything themselves; their
output turns into
rec_recommendations and therec_active_triggersfield (see Smaily templates). - Engine-run automations — a smaller subset of triggers you can turn on in the plugin/admin so the engine itself enrols a contact into your Smaily automation (Smaily templates → Engine-run automations gives the how-to; this page gives the exact numbers).
This page is the source for the numbers: every threshold, window and
cooldown here is verified straight from the code
(lib/engine/triggers/, lib/automations/, the sector packs'
triggers.yaml), not from spec intent. Where the engine promises more than
it delivers today, that gap is called out explicitly.
How triggers fit the nightly chain
All times follow the Europe/Tallinn server schedule:
| Time | Step | What it gives the triggers |
|---|---|---|
| 03:00 | Engagement-state update | engagement_state refreshes — needed by dormant_to_clicker |
| 03:30 | Customer calibration | buying-rhythm curves (cadence_curves_customer) and pet age are computed — needed by cadence_window_reached and life_stage_transition_due |
| 04:00 | Trigger detection | every detector below runs for every customer, writes trigger_candidates |
| 04:30 | Recommendation issue | candidates run through the 5-stage decision pipeline, become recommendations rows |
| 05:00 | Smaily contact sync | recommendations + rec_active_triggers land on the contact fields |
| 05:15 | Automation firing | uses ONLY candidates detected in the last 24h (see below) |
Engine trigger detectors (behind the recommendations)
These ten detectors exist in code (lib/engine/triggers/index.ts).
Whether one actually produces candidates for a given sector pack is
decided by that pack's triggers.yaml status field — the same detector
code serves every sector, but each pack switches on only the triggers it
wants.
| Trigger | Pet stores (pilot) | Beauty | FMCG | Fashion / Alcohol |
|---|---|---|---|---|
purchase_in_category | active | active | active | active |
cadence_window_reached | active | active | active | active |
dormant_to_clicker | active | active | active | active |
life_stage_transition_due | active (see below) | not defined | not defined | not defined |
cart_abandonment | off (code exists, pack status stub) | not defined | not defined | off (Fashion stub) |
category_first_purchase | off (pack status stub) | active | active | active |
stock_status_change | active | active | active | active |
aov_below_free_shipping_threshold | off (pack status stub) | active | active | active |
seasonal_window_open | off (pack status stub) | active | active | active |
purchase_lapsed_opener | not defined | not defined | not defined | active (Fashion only) |
The Fashion and Alcohol packs are populated but no tenant runs on them
yet, so their triggers detect nothing in practice. Travel and Consumer
Electronics remain stub packs (manifest + empty edges.yaml, CLAUDE.md
§1) with no triggers.yaml file at all.
Post-purchase cross-sell candidate — purchase_in_category
Fires cross-sell/replenish/discovery recommendations off a category
purchase. Does not require a repeat purchase — fires from the very
first purchase in that category (unlike cadence_window_reached, which
needs at least 3 repeat purchases).
- Fires when: the customer has a completed/processing order with an
item in category C within the last
window_days. One candidate per (customer, category); anchor product = the most recently purchased SKU in that category (ties broken by the largest line total). - Window: 30 days (Pet, FMCG) / 45 days (Beauty).
- Cooldown: 14 days (Pet, FMCG) / 21 days (Beauty, Fashion, Alcohol) — the same customer+category won't fire again before that.
Replenishment window — cadence_window_reached
Fires when a recurring product is predicted to run low, based on the customer's OWN buying rhythm.
- Fires when:
now >= predicted_next_purchase − 15% × median_interval(buffer factor 0.85) AND the customer has at least 3 confirmed repeat purchases of the same product/product family (n_observations >= 3) AND cadence confidence ≥ 0.5. - Cooldown: 14 days (Pet, Beauty, Alcohol) / 7 days (FMCG) / 30 days (Fashion) — product-family aware: if any other variant of the same family was recommended recently, this product won't fire again either (a variant switch does not duplicate the email).
- Detection cooldown: 5 days per customer+product, counted from the previous candidate of this trigger. The replenishment window stays open for weeks, so without it the same product would be detected again every night; a different product coming due is unaffected and still fires the same night.
- The underlying cadence data (median interval, consumption-rate factor, confidence) is computed in the 03:30 calibration run, before 04:00 trigger detection.
Cart abandonment — cart_abandonment (off)
The detector's code exists and works (a 24-72h window after a cart-link
click, 7-day cooldown), but the Pet pack marks it stub — it produces
zero candidates in any sector today. Reason: no edges_universal rule
carries trigger_type='cart_abandonment' (an unauthored edge would mean
"recommend the whole catalog"); offering "1 safe companion" needs cart
CONTENTS, which requires browse-tracking Variant B (backlog, see
CLAUDE.md §14.2).
Dormant-to-clicker — dormant_to_clicker
Fires when a long-quiet customer shows a fresh sign of life (an open or a click) — the win-back moment.
- Fires when (two cases, OR'd):
- the customer is still in the
dormantstate, has been there ≥60 days, AND clicked or opened an email in the last 7 days (covers an open-driven wake-up, since the nightly engagement cron only flips state on a CLICK, not an open); - the customer was freshly promoted (within the last 8 days) to
won_back_recentby the 03:00 cron AND clicked or opened an email in the last 7 days (this is today's live path — the cron has already "rescued" the customer before trigger detection gets to them).
- the customer is still in the
- Cooldown: 30 days in all five populated sector packs, counted from the previous candidate of this trigger.
- Two attempts, then stop: an "attempt" is one candidate of this trigger that the nightly issuance actually used, counted from the customer's last purchase onwards. After two the customer stops being a candidate until they buy again — a purchase is what answers a win-back, and it also resets the count. A fresh CLICK does not reset it: this trigger fires on a click in the first place, so a click-reset would mean the limit never applies to anyone. A customer who has hit the limit still receives their nine recommendations as usual; they just stop getting another "come back" pitch.
- Anchor product for the recommendation: the customer's most-repeated purchased product (a proven favourite, not a novelty).
Lapsed but still opening — purchase_lapsed_opener (fashion only)
The other side of dormant_to_clicker: that one is about email silence
and needs a click to end it. This one is about a customer who stopped
buying while still opening the emails — in fashion that is the
typical lapsed customer, and requiring a click closed the win-back door
on nearly all of them.
- Fires when: no purchase for 180 days AND an email open in the last 30 days AND the customer has fewer than two unanswered attempts behind them (below).
- Anchor: exactly ONE category — the customer's most-kept product and the category it sits in. The win-back stays inside that category.
- Two attempts, then stop (the same counter as
dormant_to_clickerabove): an "attempt" is one candidate of this trigger that the nightly issuance actually used, counted from the customer's last purchase onwards. After two the customer stops being a candidate. There is no separate "was it answered" test: the answer to a win-back is a purchase, which both ends the 180-day lapse and resets the count. - Cooldown: 60 days.
- Only the Fashion pack declares it; every other pack leaves it undefined, so the detector returns nothing for them.
Life-stage transition — life_stage_transition_due (pet stores only)
Fires when a pet's age crosses a life-stage boundary: kitten/puppy → adult at 12 months; adult → senior (dog 7 years / 84 months, cat 11 years / 132 months).
- Fires when:
customers.inferred_attributes.pet_age_monthsis known AND falls in [threshold, threshold + 1) months (a 30-day lookback window; the nightly cron always catches the crossing) AND the customer does NOT have a young-stage (kitten/puppy) purchase and an adult/senior purchase for the same species within 6 months of each other — a hard conflict gate that suppresses the trigger for likely multi-pet or gift-purchase households, where the age-derived transition would send the wrong-stage email. - Cooldown: 365 days, counted from the previous candidate of this trigger (a life-stage transition happens once per pet). It is also what keeps the month-wide age window from producing a candidate every night.
- Where the age comes from, in order of confidence:
- Registration birthdate — the plugin registration field the customer fills in themselves. Exact, but almost no store collects it.
- Purchase-anchored aging timeline (PRO-1569) — the customer's FIRST purchase of a kitten/puppy product starts a clock, and the pet is treated as newborn at that moment. The age it produces is deliberately a lower bound (the pet was already some weeks or months old when that food was bought), so the transition lands late rather than early — the safe direction for a "time to switch food" email. Only a young-stage purchase may start a clock: an adult-food-only buyer gets no age at all, because assuming "12 months old" for them would fake a transition on their very first order. A later adult/senior purchase restarts the clock at that purchase, so the next boundary is measured from the observed switch-over. A birthdate always wins where one exists.
- Honest status: with the timeline in place the trigger fires for real customers, but only for the minority whose purchases carry a life-stage tag — a store whose product names never say "kitten" or "puppy" still produces nothing here.
Category-family first purchase — category_first_purchase (off for Pet)
Fires on the customer's FIRST-EVER purchase in an entire category family (e.g. their first skincare product) — an onboarding moment.
- Fires when: the family's first-ever purchase happened within
window_daysAND the customer has no earlier purchase in that family. - Window: 45 days (Beauty) / 30 days (FMCG).
- Cooldown: 60 days (Beauty) / 45 days (FMCG).
- Pet pack status:
stub— this trigger does not fire for Pet tenants.
Stock status change — stock_status_change
Fires when a product the customer reliably rebuys (cadence
n_observations >= 3) goes out of stock, disappears from the catalog, or
becomes non-recommendable — prompts a managed substitute recommendation.
- Fires when: the customer's cadence SKU is either missing from the
catalog,
in_stock = false, ORrecommendable = false. - Cooldown: 30 days (Beauty) / 21 days (FMCG) / 30 days (Pet).
- Pet pack status:
active(PRO-1479, 2026-07-20) — the detector already existed and Pet'sedges.yamlalready referenced it, but the trigger definition was missing fromtriggers.yamlentirely, so it silently never fired. Now active — same universal detector as Beauty/FMCG.
Basket just under free shipping — aov_below_free_shipping_threshold (off for Pet)
Fires when the customer's typical basket sits just below the store's free-shipping line — a small add-on could close the gap.
- Requires:
tenant_settings.free_shipping_thresholdmust be configured; with no threshold set, this trigger fires for nobody by default. - Fires when: the customer's average of recent orders (over
window_days) falls in [threshold − max_gap, threshold). - Window / max gap: 60 days / €25 (Beauty); 60 days / €20 (FMCG).
- Cooldown: 30 days (Beauty) / 21 days (FMCG).
- Pet pack status:
stub— does not fire for Pet tenants.
Seasonal window — seasonal_window_open (off for Pet)
Fires when a calendar-defined seasonal window is open (e.g. SPF season, cold & flu season, gift season) AND the customer matches the window's eligibility rule.
- Fires when: the current month is in the window's month list AND the
customer meets the eligibility rule — either "bought before, not
recently" (a purchase within
lookback_daysbut not within the lastrecent_days) or "bought within the lookback" (any purchase from the window's category prefixes withinlookback_days). - Examples: Beauty's
spf_season(April-August, "bought before, not recently", 120d not-recent / 455d lookback); FMCG'scold_flu_season(October-February); both sectors'gift_season(November-December). - Cooldown: 75 days (Beauty) / 60 days (FMCG) — one seasonal series per season.
- Pet pack status:
stub— does not fire for Pet tenants.
Planned, no code yet
Three triggers are described in the Pet pack's triggers.yaml as
placeholder concepts with no working detector (no file for them under
lib/engine/triggers/, not present in the ACTIVE_DETECTORS map) — don't
expect these to produce candidates today:
| Trigger | Planned meaning |
|---|---|
purchase_volume_anomaly | the latest order's quantity is >2× the customer's median (e.g. stocking up, a multi-pet household) |
loyalty_milestone | the customer crossed an order-count or spend milestone (e.g. 5/10/20 orders, €100/250/500) |
won_back_recent | as a standalone trigger; the STATE itself exists and is USED by dormant_to_clicker's case B, but there is no separate candidate detector for it |
Engine-run automations (sending the email)
Automation triggers are a smaller subset (currently 5) of the detectors
above that can be bound to a Smaily automation workflow — the engine
enrols the contact, the workflow sends the email. The model:
settings in the plugin/admin, execution in the engine, content in Smaily
(authoritative code: lib/automations/catalog.ts +
lib/automations/fire.ts).
One of the five, back in stock, is not fed by any of the nine nightly detectors above at all — see its own row in the table below for how it's actually detected.
Candidate freshness requirement
The nightly automation-firing run (05:15) uses ONLY candidates detected in
the last 24 hours (trigger_candidates.detected_at) — a candidate from
yesterday or earlier that missed its automation window is dropped from
the automation path (it still reaches the recommendation path; this
freshness window applies only to sending emails). Back in stock is the
one exception: a dedicated cron running every 15 minutes
(fire-back-in-stock) calls the same firing logic with a 30-minute
freshness window instead, so that trigger's detection→send latency is
~15-30 minutes rather than up to a day.
The enrolment sequence (every candidate goes through these, in order)
- Fail-closed: if the tenant has no config OR
enabled=false, the whole tenant is skipped (not logged per customer, for volume reasons) — a trigger you haven't enabled NEVER fires. - Opted-out customer (
opted_out=true) — skipped on every path. - Held-out (holdout) customer (
holdout_arm='holdout') — always skipped, before any other check (protects the incrementality measurement). - Sync scope: the contact must have an email, be on the Smaily list
(
in_smaily=true) and not be unsubscribed (smaily_unsubscribedmust not be true) — otherwise skipped. - Test mode: if on, ONLY the
test_emailsaddresses fire; everyone else is skipped. - Cooldown: if the same customer has a
pendingorfiredrow for this trigger withincooldown_days, skipped. - Daily cap: if today's
firedcount for this trigger has already reacheddaily_cap, skipped. - Workflow ACTIVE check: if the matching automation id is not in Smaily's ACTIVE list, skipped (an INACTIVE workflow still returns 101 OK from Smaily, but never sends the email — a silent trap this step avoids).
At-most-once: a pending row is written to automation_fires BEFORE
the Smaily call; if the call crashes, the pending row stays and still
counts toward the cooldown — a retry never double-sends. A failed
status does NOT block the next night (better a day late than never).
Also: a tenant-level freeze (contact_sync_enabled=false, e.g. during a
migration freeze) excludes the whole tenant from automations — the same
flag that halts the contact sync.
Configuration limits
| Field | Default | Allowed range |
|---|---|---|
cooldown_days | 7 | 1-365 |
daily_cap | empty (unlimited) | 1-100,000 or empty |
test_mode | on (true) | boolean |
language_mode | single | single (one automation) or per_language (a language map + fallback) |
The five automation triggers available today
| Trigger | Fires from candidate | Sectors | Recommended cooldown |
|---|---|---|---|
Replenishment due (replenish_due) | cadence_window_reached | universal | 7 days (schema default) |
Win-back (winback_rescue) | dormant_to_clicker | universal | 30 days (matches the detector's own cooldown) |
Life-stage transition (life_stage) | life_stage_transition_due | pet stores only | 365 days |
Post-purchase cross-sell (post_purchase) | purchase_in_category | universal | 14+ days AND a daily cap (avoids an email after every single order) |
Back in stock (back_in_stock) | back_in_stock — NOT one of the nine nightly detectors; detected inside catalog ingest itself (see below) | universal | tenant-configured (applies per CUSTOMER, not per SKU — see note below) |
Four of these are essentially universal (the mechanism is
sector-independent; content comes from the sector pack); the life-stage
trigger is sector-restricted in code (scope: ['pet'] in
lib/automations/catalog.ts), because its thresholds come from the Pet
pack's triggers.yaml — another sector pack would need to define its own
thresholds or leave the trigger unused.
Each trigger's description, recipe and activation steps (test mode → going live) are in Smaily templates → Engine-run automations. If you have operator access, detailed fire/skip monitoring and debugging live in the operator runbook "Activating and monitoring automations".
Back in stock — how it's actually detected (PRO-1376)
Unlike the other four, back in stock has no nightly detector. It's
event-based: every catalog ingest (the plugin's regular sync AND an admin
CSV re-upload) diffs each SKU's stock status against what was stored
before. A false → true flip only counts once it has stayed out of stock
for at least 30 minutes (a debounce that filters out same-day
drain-then-restock flicker and stale-read noise during a full re-sync) —
qualifying signals accumulate in a small internal table.
A dedicated cron (fire-back-in-stock, every 15 minutes) then:
- Builds the interest pool for that restocked SKU — v1 uses two
engine-derivable signals, no storefront waitlist yet: past purchasers of
the exact SKU (180-day lookback) and customers who browsed that SKU
while it was unavailable (30-day lookback on
product_viewevents, where browse tracking is wired for that store). - Fires through the exact same enrolment sequence described above — holdout, sync scope, test mode, cooldown, daily cap, workflow ACTIVE — with no exceptions.
One real difference from the other four: the per-customer cooldown
above is not SKU-aware (there's no way to scope it by product without a
schema change), so a customer notified about one restocked product will
not be notified about a different restocked product until their
configured cooldown_days has elapsed. A separate, SKU-scoped check at
candidate-creation time (30-day window) additionally stops the same
product re-notifying the same customer if it flaps in and out of stock
repeatedly over time.
Like the other four, this ships disabled by default — no tenant has back_in_stock enabled today; nothing fires until a merchant opts in through the same settings mechanism as any other trigger.
The same signal also feeds the recommendations (PRO-1566): since pet
pack version 1.3.0 a restock candidate additionally gives that customer's
nightly top-9 a slot for the exact product that came back (the mirror image
of the out-of-stock substitute rule — the wanted product, not a compromise).
This half needs no opt-in and sends nothing by itself: it only changes which
products land in the customer's rec_1..9_* fields overnight. The two
consumers do not interfere — the automation reads a 30-minute freshness
window on the candidate, the nightly issuance stamps the candidate processed,
and neither step affects the other.
Where to read more
- Smaily templates and fields — how to put
rec_active_triggersand automations to work in practice. - FAQ and troubleshooting — if a trigger's behaviour looks wrong.
Last updated: 2026-07-29