Smaily templates and fields
The engine writes personalised fields onto every contact each night. Your Smaily templates and automations use them like any merge field — there is no new sending platform.
Recommendation slots (1-9)
Every slot N (1..9) carries the same fields:
| Field | Content |
|---|---|
rec_N_sku | product code; empty = no slot (use this as the condition) |
rec_N_name | product name in the customer's language |
rec_N_description | short description (max 200 chars) |
rec_N_price | price |
rec_N_compare_price | compare-at price for showing a discount (may be empty) |
rec_N_image_url | product image |
rec_N_link_url | full product link with tracking parameters |
rec_N_link_path | the same link WITHOUT the domain (see the link pattern below) |
rec_N_headline | ready-made headline phrase |
rec_N_cta | ready-made button copy |
rec_N_reason | human-readable reason the product was picked |
An empty slot is written empty (not left stale), so a template can always trust the condition:
{% if rec_4_sku and rec_4_sku != "" %} ... slot 4 content ... {% endif %}
Use exactly this nil-safe form: a missing field is nil in Liquid (not
an empty string) — that is what a comparison-group contact looks like after
the engine removes their fields. A bare == "" comparison OR a bare
{% if rec_4_sku %} alone would each leave one case uncovered and produce
empty product cards.
The link pattern (important!)
In templates, use your store domain + link_path:
<a href="https://your-store.com/{{ rec_1_link_path }}">{{ rec_1_name }}</a>
link_pathalready contains the full path and tracking parameters (includingsmaily_rec, which the store plugin captures at purchase). Do not insert your own catalog prefix (e.g./product/) — the path is already in there and a double prefix produces a 404.- Why not just
link_url? That works too if your template allows a full URL;link_pathexists so the link reliably lives on your domain and attribution works.
Ready-made product card block (example)
A working, email-client-safe (table-based) product card from a real pilot.
Paste it inside an HTML element in the Smaily template: 1 block = 1
product, 3 blocks per row; the layout/columns come from the template
itself. Copy it for slots 2..9, replacing every rec_1_ → rec_2_ etc.
Replace your-store.com with your domain and #be3e77 with your brand
colour.
Note: the guard is in the nil-safe form
{% if rec_1_sku and rec_1_sku != "" %} … {% endif %} — it covers both a
missing field (nil; e.g. a comparison-group contact) and an empty string.
{% if rec_1_sku and rec_1_sku != "" %}
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; border:none; width:100%; max-width:200px; margin:0 auto; font-family:HelveticaNeue, Arial, Helvetica, Geneva, sans-serif">
<tbody>
<!-- Image -->
<tr>
<td align="center" style="border-collapse:collapse; border:none; vertical-align:top; padding-top:10px; padding-bottom:10px" valign="top">
<a target="_blank" href="https://your-store.com/{{rec_1_link_path}}" style="border:none; margin:0; padding:0; display:inline-block">
<img src="{{rec_1_image_url}}" alt="{{rec_1_name}}" width="173" style="border:none; margin:0; padding:0; width:100%; max-width:173px; height:auto; display:block"/>
</a>
</td>
</tr>
<!-- Product name -->
<tr>
<td align="center" style="border-collapse:collapse; border:none; vertical-align:top; padding:0 6px 2px 6px" valign="top">
<a target="_blank" href="https://your-store.com/{{rec_1_link_path}}" style="text-decoration:none; color:#333333; font-family:HelveticaNeue, Arial, Helvetica, Geneva, sans-serif; font-size:14px; line-height:18px; font-weight:bold">
{{rec_1_name}}
</a>
</td>
</tr>
<!-- Price + compare-at price (only when present) -->
<tr>
<td align="center" style="border-collapse:collapse; border:none; vertical-align:top; padding:0 6px 8px 6px; font-family:HelveticaNeue, Arial, Helvetica, Geneva, sans-serif; font-size:15px; line-height:20px; color:#be3e77; font-weight:bold" valign="top">
<span style="color:#be3e77; font-size:16px; font-weight:bold">{{rec_1_price}}€</span>{% if rec_1_compare_price and rec_1_compare_price != "" %} <span style="color:#999999; font-weight:normal; font-size:13px; text-decoration:line-through">{{rec_1_compare_price}}€</span>{% endif %}
</td>
</tr>
<!-- CTA button -->
<tr>
<td align="center" style="border-collapse:collapse; border:none; vertical-align:top; padding-top:2px; padding-bottom:10px" valign="top">
<table border="0" cellpadding="0" cellspacing="0" align="center" style="border-collapse:collapse; border:none; margin-left:auto; margin-right:auto">
<tbody>
<tr>
<td align="center" bgcolor="#be3e77" style="border-collapse:collapse; border:none; vertical-align:top; line-height:normal; mso-line-height-rule:exactly; padding:10px 20px; background-color:#be3e77; border-radius:3px; color:#fff" valign="top">
<a target="_blank" href="https://your-store.com/{{rec_1_link_path}}" style="line-height:normal; display:inline-block; text-align:center; text-decoration:none; color:#ffffff; font-family:HelveticaNeue, Arial, Helvetica, Geneva, sans-serif; font-size:16px; font-weight:normal">
View product
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
{% endif %}
Analytics fields on the contact
| Field | Content |
|---|---|
rec_segment | segment |
rec_engagement_state / rec_engagement_trajectory | state and direction |
rec_days_to_replenish / rec_replenish_sku / rec_predicted_next_replenish_at | replenishment |
rec_discount_tier_suggested | suggested discount tier (0/5/10/15) for this customer's sensitivity |
rec_loyalty_brand_top / rec_loyalty_score | favourite brand and loyalty |
rec_preferred_send_window | the customer's best send window |
rec_active_triggers | currently active reasons, comma-separated (see the value list below) |
rec_subject_hook / rec_subject_hook_type | ready-made subject-line hook and its category |
rec_last_sync_at / rec_engine_version | when the data was refreshed |
language | Smaily's own native field — lets you language-segment your static copy; written only when the customer's language is known, and never blanked |
What rec_active_triggers can contain
rec_active_triggers is a comma-joined list of the reasons the engine
currently sees for this customer — you can branch or segment on it in Smaily.
The possible values:
| Value | Meaning |
|---|---|
cadence_window_reached | a recurring product is predicted to run out soon |
dormant_to_clicker | a long-quiet customer clicked an email again |
life_stage_transition_due | the pet is crossing an age band (pet stores only) |
category_first_purchase | first-ever purchase in a category |
purchase_in_category | a recent purchase to follow up with matching products |
stock_status_change | a relevant product's stock status changed |
aov_below_free_shipping_threshold | the basket is just below your free-shipping line |
seasonal_window_open | a seasonal window has opened |
Which values can appear depends on your store's sector — for example the life-stage trigger only applies to pet stores.
Cohort flags (rec_seg_<slug>)
Separately from the nightly sync, the console's "Create Smaily segment"
action (available on any filtered Audience view) writes a cohort flag onto
the chosen contacts: a field named rec_seg_<segment-slug> set to "yes".
It is a partial push — only the contacts in that cohort get the flag, and
re-running the same-named segment clears it from contacts that have left the
group. Build your Smaily segments and automations on that flag. Note this is
written on demand, the moment you click the action — not by the nightly
sync.
Value formats (for template conditions)
- Prices (
rec_N_price,rec_N_compare_price) are bare numbers with two decimals and no currency symbol (e.g.12.90) — add the€yourself in the template. rec_discount_tier_suggestedis a whole number written as text (0/5/10/15). Build the template so that0shows no discount block at all: besides customers who buy fine without one, a small fixed share (~10%) of contacts is deliberately kept at0, so the engine can see what happens without a discount and learn who actually needs one.- Timestamps (
rec_predicted_next_replenish_at,rec_last_sync_at) are in ISO format.
Subject-line hook
rec_subject_hook is a short (≤30 characters), ready-to-use subject line
personalised per customer — for example "Time to restock: Acana",
"New from Orijen" or "Personal picks for you". The engine picks exactly one
per customer from a small ladder (replenishment due → win-back → favourite
brand → weekly pick → generic).
The field is never empty: a customer with no strong signal (or a comparison-group contact) gets a clean generic phrase, never a blank subject. So for a treatment send you can set the whole subject row to just the merge tag:
{{ rec_subject_hook }}
rec_subject_hook_type carries the language-neutral category
(replenish / winback / brand / top_pick / generic) — use it to
segment or to branch, not the localised text.
Caveats:
- For any send that may include the comparison group (holdout), the hook
is intentionally blank for those contacts — use a static subject for
such sends and reserve
{{ rec_subject_hook }}for treatment-only sends. - Turning subject hooks on for live sends is a deliberate, operator-gated step (subject lines affect deliverability); the field ships first, and the first live use is confirmed separately.
Automation recipes
- Replenishment: trigger when
rec_days_to_replenish <= 5— see the replenishment page. - Win-back: trigger when
rec_engagement_statechanges tolapsed; take the discount size fromrec_discount_tier_suggested. - Branching content by state:
{% if rec_engagement_state == "lapsed" %}
<p>We haven't seen you in a while…</p>
{% else %}
<p>{{ rec_replenish_sku }} is about to run out:</p>
{% endif %}
Engine-run automations
The recipes above are automations you build on field conditions. There is now a simpler path: the engine watches your store data and enrols the contact into your Smaily automation at exactly the right moment for the right customer — for example when a recurring product is predicted to run out (replenishment) or when a lapsed customer clicks an email again (win-back). Your store and the plugin do not send anything; the engine enrols the contact and Smaily sends the email you built.
The setup lives in the store plugin's settings, in the "Engine-run recommendation automations" section (WooCommerce today; Shopify is next). The list of available triggers depends on your store's sector — for example the life-stage transition trigger only appears for pet stores — and every trigger comes with its description and recipe right in the plugin UI.
How to set it up
- In Smaily: build the automation. Create an automation with the
"form submitted" trigger — that is the mechanism the engine uses to
enrol a contact — and keep it active. Ready-made email templates are
already on your Smaily account as draft campaigns (subject prefix
"ENG DRAFT — …"); use one as the basis of the workflow email. The
email uses the same
rec_fields and recommendation slots described above. (The win-back template shows a discount block with codesWB5/WB10/WB15— create those codes in your store if you keep it.) - In the plugin: connect the trigger. Open the automations section in the plugin settings, switch the trigger on and pick or enter the Smaily workflow it should run. On a multilingual store, set a workflow for each language plus a fallback.
- Test first. Test mode is ON by default — add your test addresses. In test mode only those addresses receive the email; with no test address nothing is sent at all. Check the email in a real inbox: images, links, empty slots.
- Activate for real. When the test email looks right, turn test mode off and activate — that is a separate, deliberate confirmation step.
How it behaves
- Off until you switch it on (fail-closed): the engine never sends on a trigger you have not activated.
- Cooldown per customer: the same customer does not get the same automation again within its cooldown window (for example replenishment defaults to 7 days).
- The trigger list, descriptions and recipes come straight from the engine into the plugin UI — a new trigger appears there without a plugin update.
The field-based path still works
Everything above this section — Smaily segments and automations built on the
rec_ fields — remains fully supported and keeps working. Engine-run
automations are the simpler, recommended path (the engine picks the moment
for you); the field-based path stays available as the flexible fallback, for
example for a store that does not run the plugin.
How often fields update
Different data refreshes on different clocks. All times below are the server's schedule (Europe/Tallinn):
| Data | When it refreshes |
|---|---|
Recommendation slots (rec_N_*), all rec_* analytics and the subject hook | Recomputed by the nightly chain (engagement → calibration → trigger detection → recommendations) and pushed to Smaily around 05:00. Changed customers are also recomputed a few times during the day (about 09:10, 13:10 and 17:10), and a catch-up sync runs roughly every 30 minutes to carry those daytime changes across. |
Smaily native language | Same nightly contact sync — and only when the customer's language is known. |
Cohort flags (rec_seg_<slug>) | On demand only — the moment you use the console "Create Smaily segment" action. |
| Opens & clicks (they feed engagement and attribution) | Pulled from Smaily every 15 minutes, with a full backfill each night around 02:00. |
| Campaign statistics (Insights) | Refreshed hourly; a campaign's numbers keep settling for about 35 days (late opens). |
| Daily metrics (Home / Insights history) | Aggregated at 06:00; today and yesterday are shown live in the console in the meantime. |
Need certainty right before a send? Use "Sync now" (next section).
When a contact unsubscribes
Once Smaily reports a contact as unsubscribed, the engine stops enriching
them. They receive one final sync that empties all rec_ fields — so no
stale recommendation is left sitting on an opted-out contact — and after that
the engine never writes to them again. Nothing else about them is deleted:
their purchase history keeps feeding the model, and if they re-subscribe they
re-enter the sync on the next nightly run and get a full set of fields back.
The same applies to a profiling objection (GDPR Art 21): such a contact may
stay on your list, but the engine computes nothing for them — their rec_
fields are emptied once and never written again. A contact who is both
unsubscribed and objecting is emptied once, not twice.
Before sending: "Sync now"
The nightly sync runs in the early morning. If you change segments during the day or want certainty the fields are fresh, ask the operator to press Sync now (or do it yourself if you have access) right before sending a campaign — especially when the campaign targets a freshly created segment.
Technical background
Field construction: lib/smaily/contact-sync.ts (slots + analytics; empty
slot = null write) and lib/smaily/sync-tenant.ts (language localisation,
link_path = URL without scheme/domain/leading slash). The link's
smaily_rec parameter is the attribution
signal. Spec §8.5. Engine-run automations: trigger catalog
lib/automations/catalog.ts; email content spec
docs/AUTOMATION_EMAILS_SPEC.md.
Last updated: 2026-07-12