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:

FieldContent
rec_N_skuproduct code; empty = no slot (use this as the condition)
rec_N_nameproduct name in the customer's language
rec_N_descriptionshort description (max 200 chars)
rec_N_priceprice
rec_N_compare_pricecompare-at price for showing a discount (may be empty)
rec_N_image_urlproduct image
rec_N_link_urlfull product link with tracking parameters
rec_N_link_paththe same link WITHOUT the domain (see the link pattern below)
rec_N_headlineready-made headline phrase
rec_N_ctaready-made button copy
rec_N_reasonhuman-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.

In templates, use your store domain + link_path:

<a href="https://your-store.com/{{ rec_1_link_path }}">{{ rec_1_name }}</a>
  • link_path already contains the full path and tracking parameters (including smaily_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_path exists 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}}&#8364;</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}}&#8364;</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

FieldContent
rec_segmentsegment
rec_engagement_state / rec_engagement_trajectorystate and direction
rec_days_to_replenish / rec_replenish_sku / rec_predicted_next_replenish_atreplenishment
rec_discount_tier_suggestedsuggested discount tier (0/5/10/15) for this customer's sensitivity
rec_loyalty_brand_top / rec_loyalty_scorefavourite brand and loyalty
rec_preferred_send_windowthe customer's best send window
rec_active_triggerscurrently active reasons, comma-separated (see the value list below)
rec_subject_hook / rec_subject_hook_typeready-made subject-line hook and its category
rec_last_sync_at / rec_engine_versionwhen the data was refreshed
languageSmaily'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:

ValueMeaning
cadence_window_reacheda recurring product is predicted to run out soon
dormant_to_clickera long-quiet customer clicked an email again
life_stage_transition_duethe pet is crossing an age band (pet stores only)
category_first_purchasefirst-ever purchase in a category
purchase_in_categorya recent purchase to follow up with matching products
stock_status_changea relevant product's stock status changed
aov_below_free_shipping_thresholdthe basket is just below your free-shipping line
seasonal_window_opena 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_suggested is a whole number written as text (0 / 5 / 10 / 15). Build the template so that 0 shows no discount block at all: besides customers who buy fine without one, a small fixed share (~10%) of contacts is deliberately kept at 0, 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_state changes to lapsed; take the discount size from rec_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

  1. 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 codes WB5/WB10/WB15 — create those codes in your store if you keep it.)
  2. 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.
  3. 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.
  4. 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):

DataWhen it refreshes
Recommendation slots (rec_N_*), all rec_* analytics and the subject hookRecomputed 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 languageSame 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