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 the rec_active_triggers field (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:

TimeStepWhat it gives the triggers
03:00Engagement-state updateengagement_state refreshes — needed by dormant_to_clicker
03:30Customer calibrationbuying-rhythm curves (cadence_curves_customer) and pet age are computed — needed by cadence_window_reached and life_stage_transition_due
04:00Trigger detectionevery detector below runs for every customer, writes trigger_candidates
04:30Recommendation issuecandidates run through the 5-stage decision pipeline, become recommendations rows
05:00Smaily contact syncrecommendations + rec_active_triggers land on the contact fields
05:15Automation firinguses 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.

TriggerPet stores (pilot)BeautyFMCGFashion / Alcohol
purchase_in_categoryactiveactiveactiveactive
cadence_window_reachedactiveactiveactiveactive
dormant_to_clickeractiveactiveactiveactive
life_stage_transition_dueactive (see below)not definednot definednot defined
cart_abandonmentoff (code exists, pack status stub)not definednot definedoff (Fashion stub)
category_first_purchaseoff (pack status stub)activeactiveactive
stock_status_changeactiveactiveactiveactive
aov_below_free_shipping_thresholdoff (pack status stub)activeactiveactive
seasonal_window_openoff (pack status stub)activeactiveactive
purchase_lapsed_openernot definednot definednot definedactive (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):
    1. the customer is still in the dormant state, 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);
    2. the customer was freshly promoted (within the last 8 days) to won_back_recent by 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).
  • 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_clicker above): 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_months is 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:
    1. Registration birthdate — the plugin registration field the customer fills in themselves. Exact, but almost no store collects it.
    2. 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_days AND 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, OR recommendable = 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's edges.yaml already referenced it, but the trigger definition was missing from triggers.yaml entirely, 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_threshold must 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_days but not within the last recent_days) or "bought within the lookback" (any purchase from the window's category prefixes within lookback_days).
  • Examples: Beauty's spf_season (April-August, "bought before, not recently", 120d not-recent / 455d lookback); FMCG's cold_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:

TriggerPlanned meaning
purchase_volume_anomalythe latest order's quantity is >2× the customer's median (e.g. stocking up, a multi-pet household)
loyalty_milestonethe customer crossed an order-count or spend milestone (e.g. 5/10/20 orders, €100/250/500)
won_back_recentas 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)

  1. 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.
  2. Opted-out customer (opted_out=true) — skipped on every path.
  3. Held-out (holdout) customer (holdout_arm='holdout') — always skipped, before any other check (protects the incrementality measurement).
  4. Sync scope: the contact must have an email, be on the Smaily list (in_smaily=true) and not be unsubscribed (smaily_unsubscribed must not be true) — otherwise skipped.
  5. Test mode: if on, ONLY the test_emails addresses fire; everyone else is skipped.
  6. Cooldown: if the same customer has a pending or fired row for this trigger within cooldown_days, skipped.
  7. Daily cap: if today's fired count for this trigger has already reached daily_cap, skipped.
  8. 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

FieldDefaultAllowed range
cooldown_days71-365
daily_capempty (unlimited)1-100,000 or empty
test_modeon (true)boolean
language_modesinglesingle (one automation) or per_language (a language map + fallback)

The five automation triggers available today

TriggerFires from candidateSectorsRecommended cooldown
Replenishment due (replenish_due)cadence_window_reacheduniversal7 days (schema default)
Win-back (winback_rescue)dormant_to_clickeruniversal30 days (matches the detector's own cooldown)
Life-stage transition (life_stage)life_stage_transition_duepet stores only365 days
Post-purchase cross-sell (post_purchase)purchase_in_categoryuniversal14+ 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)universaltenant-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:

  1. 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_view events, where browse tracking is wired for that store).
  2. 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

Last updated: 2026-07-29