Engagement journeys
engagementBring people back with reminder ladders that obey a weekly limit, quiet hours and a stop after ignored messages, with strict rules for children and no dark patterns.
engagementFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
engagement brings people back to a habit app with reminder ladders that obey a weekly limit, quiet hours and a stop after ignored messages, and it makes dark patterns hard to write.
The limits are declared once in main.yaml and enforced on every engagement message at run time, whichever channel notify picks. Study CX, an app for children aged 6 to 16, is the reference case. It runs inside your app on notify and durable workflows, and it is free. The calls come from mainsdk/notify, and app.engagement is built on first use from the notify.engagement section.
At a glance
notify:
engagement:
max-per-week: 4
quiet-hours: "21:00-08:00"
stop-after-ignored: 5
audience: { children: strict }
guardian: { controls: [pause, frequency, quiet-hours, view] }
categories: [reminders]
active: user.active
ignored-after: 48h
send-time: { default: "17:00" }
copy: { ai: true, limit: $2/month }
proof:
finished-today: { kind: count, event: lesson.finished, window: day }
links:
offer: https://app.example.com/offer
measure: { return-within: 48h, unsubscribe-within: 7d }import { journey, ladder } from "mainsdk/notify";
export const comeBack = journey("come-back", { version: 1, start: "user.inactive", category: "reminders" },
async ({ user }) => ladder(user, [
{ after: "1d", template: "reminder.gentle" },
{ after: "3d", template: "reminder.gentle-2" },
{ after: "7d", template: "reminder.last" },
], { sendAt: "usual" }));Calls
journey(name, { version?, start, category }, fn)ladder(user, rungs, { sendAt? })engagement.send(user, { template, data?, journey?, rung? })engagement.sendTime(user)engagement.status(user)engagement.results(flag, range)engagement.copy(template, { locale, variant? })engagement.guardian(child)engagement.optOut(user, { channel, category?, via? })engagement.stopByHandle(handle, { channel, category?, via? })engagement.keyword(user, text, { channel? })engagement.preferences(user).set({ maxPerWeek? })engagement.apply(pushed)engagement.register(journey)engagement.start(journey, user)engagement.attach()engagement.erase(userId)createEngagement(deps)parseEngagement(raw)
Guarantees
Whatever the driver.
- A weekly limit, quiet hours and a stop after ignored messages on every engagement message, tighten-only when pushed
- Every message carries a one-step stop: one-click unsubscribe on email, a stop action on push, a stop keyword on WhatsApp and SMS
- Children get no streak-loss, guilt or comparison copy, and a guardian can pause or limit reminders
- Social proof comes only from declared analytics queries, with small groups hidden
- Links in messages are one share per journey, rung and variant, never per person
Drivers
1 drivers. Select one to see its line in main.yaml.
engagement:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-ENGAGE-0010 | Checkmainsdk check, before deploy | Confirmshaming in unsubscribe or opt-out copy |
MAIN-ENGAGE-0011 | Checkmainsdk check, before deploy | Social proof not backed by data: a literal count or name ("1,240 people finished today") instead of a {{proof.*}} placeholder |
MAIN-ENGAGE-0012 | Checkmainsdk check, before deploy | Engagement messages are sent (journey, ladder, engagement.send or an engagement category) with no notify.engagement.max-per-week |
MAIN-ENGAGE-0013 | Checkmainsdk check, before deploy | A children's audience is possible with no notify.engagement.guardian settings |
MAIN-ENGAGE-0014 | Checkmainsdk check, before deploy | Copy that can reach a children's audience has streak-loss or guilt pressure, or comparative social proof |
MAIN-ENGAGE-0001 | RuntimeError responses and logs | Engagement message skipped: the person reached the weekly limit |
MAIN-ENGAGE-0002 | RuntimeError responses and logs | Engagement messages stopped for this person: stop-after-ignored messages in a row were ignored |
MAIN-ENGAGE-0003 | RuntimeError responses and logs | AI copy was refused by the copy rules, was over the tenant's copy.limit, or failed; the template text was used |
MAIN-ENGAGE-0004 | RuntimeError responses and logs | Guardian control refused: the caller isn't the child's guardian, or the change loosens a limit (403) |
MAIN-ENGAGE-0005 | RuntimeError responses and logs | Not sent to a children's audience: the copy has streak-loss or guilt pressure, or comparative social proof |
MAIN-ENGAGE-0006 | RuntimeError responses and logs | urgent: true on an engagement message refused; engagement never skips quiet hours (400) |
MAIN-ENGAGE-0007 | RuntimeError responses and logs | A social proof placeholder had no declared query, or its result was hidden or empty; the text without proof was used |
MAIN-ENGAGE-0008 | RuntimeError responses and logs | A guardrail value in main.yaml is invalid (start fails), or a pushed value loosens one (refused, last good kept) |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| engagement contract and main.yaml keys | Phase 1 | 0.4 | engagement#contract |
| Journeys and ladders as durable workflows | Phase 1 | 0.4 | engagement#journeys |
| Weekly limit, quiet hours and stop after ignored | Phase 1 | 0.4 | engagement#guardrails |
| Send time from the person's own activity | Phase 1 | 0.4 | engagement#send-time |
| Variants through flags with return and unsubscribe rates | Phase 1 | 0.4 | engagement#variants |
| Copy from ai within a tenant limit | Phase 1 | 0.4 | engagement#ai-copy |
| Strict rules and guardian controls for children | Phase 1 | 0.4 | engagement#children |
| Social proof from declared queries | Phase 1 | 0.4 | engagement#proof |
| Engagement checks | Phase 1 | 0.4 | engagement#checks |
| Manifest and privacy label line | Phase 1 | 0.4 | engagement#label |
Quick start
notify:
channels: [push, whatsapp, email]
engagement:
max-per-week: 4 # required, 1 to 21
quiet-hours: "21:00-08:00"
stop-after-ignored: 5
categories: [reminders]
active: user.active # the event that means the person came backimport { journey, ladder } from "mainsdk/notify";
export const comeBack = journey("come-back", { version: 1, start: "user.inactive", category: "reminders" },
async ({ user }) => ladder(user, [
{ after: "1d", template: "reminder.gentle" },
{ after: "3d", template: { flag: "reminder-copy", variants: ["reminder.a", "reminder.b"] } },
{ after: "7d", template: "reminder.last" },
], { sendAt: "usual" }));Publish user.inactive for a person and the journey starts one run for them. Publish user.active and the run ends with outcome: "returned"; nothing more is sent.
Configuration
notify:
quiet: { from: "22:00", to: "08:00" }
engagement:
max-per-week: 4 # engagement messages per person per rolling 7 days
quiet-hours: "21:00-08:00" # the stricter of this, notify.quiet and the person's own wins
stop-after-ignored: 5
audience: { children: strict } # strict is the only value
guardian: { controls: [pause, frequency, quiet-hours, view] }
categories: [reminders] # notify categories that count as engagement messages
active: user.active
ignored-after: 48h
send-time: { default: "17:00" }
copy: { ai: true, limit: $2/month } # per tenant; past it, the template text
proof:
finished-today: { kind: count, event: lesson.finished, window: day }
links:
offer: https://app.example.com/offer # a {{link.offer}} placeholder
measure: { return-within: 48h, unsubscribe-within: 7d }Every key has a row in the main.yaml reference. A bad value stops the app at start with MAIN-ENGAGE-0008 naming the key.
What you get
Journeys and ladders
journey(name, { version?, start, category }, fn) // a durable workflow, one run per person and journey
ladder(user, rungs, { sendAt? }) // { outcome: "returned" | "stopped" | "finished" }
engagement.register(journey) // bind a journey to this app's instance
engagement.start(journey, user) // start a run now; one at a time per person
engagement.attach() // listen for `active` and each journey's start eventladder is built only from waitFor, step and sleep, so it resumes after a crash without sending twice. It waits for the person's return before every rung. app.engagement calls attach() for you.
Sending and status
engagement.send(user, { template, data?, journey?, rung? }) // status: sent | deferred | skipped | stopped, with a code
engagement.sendTime(user) // { at, wait, basis: "usual" | "default" }
engagement.status(user) // { sentThisWeek, ignoredInARow, stopped, nextAllowedAt }
engagement.results(flag, { from, to }) // per variant: sent, returnRate, unsubscribeRate, winner
engagement.copy(template, { locale, variant? }) // { text, via: "ai" | "template" }
engagement.preferences(user).set({ maxPerWeek? }) // a person may lower their own limit, never raise itBefore each send the SDK checks, in process and from the person's own counters, in this order: the stop signal, opt-outs, the guardian's pause, the weekly limit (MAIN-ENGAGE-0001), the strict rules for children (MAIN-ENGAGE-0005) and quiet hours (the message is deferred to the next allowed minute). urgent: true is refused with MAIN-ENGAGE-0006: engagement never skips quiet hours.
A message counts as ignored when the person sends no active event within ignored-after. After stop-after-ignored ignored messages in a row, the person gets nothing (MAIN-ENGAGE-0002) and their live runs end, until they come back on their own. Only your own first-party activity is used: no open pixels and no rewritten links.
Guardians
engagement.guardian(child).get()
engagement.guardian(child).set({ paused?, maxPerWeek?, quietHours? }) // only lowers or widensOnly a subject that access relates to the child as guardian_of may call this; anyone else gets MAIN-ENGAGE-0004, and so does a change that loosens a limit. Each change is audited.
One-step stop on every channel
An engagement message carries a stop for its category. Stopping takes effect before the next send, asks no "are you sure", and counts as an unsubscribe for the variant that was sent.
| Channel | The stop | How it reaches engagement |
|---|---|---|
RFC 8058 one-click List-Unsubscribe header; the message goes as marketing mail | app.engagement listens to the email notices, so one POST to the link stops the category | |
| Push | A stop action in the push payload, naming the person only by a handle | Call engagement.stopByHandle(handle, { channel: "push" }) from the action's handler |
| WhatsApp and SMS | The STOP keyword | Pass inbound text to engagement.keyword(user, text, { channel }); a stop word stops, anything else returns null |
import { app } from "mainsdk";
// The device sends back the handle it was given in the push payload's `stop` field.
export async function onStopAction(handle: string) {
return app().engagement.stopByHandle(handle, { channel: "push" }); // { category, optedOut: true, confirm: false }
}engagement.optOut(user, { channel, category?, via? }) does the same for a person you already know. Email needs email.unsubscribe-url in main.yaml, because the one-click link is minted by email. The handle is a keyed hash, kept with the person's counters and dropped by engagement.erase(userId).
Links in messages
A link in a message is a {{link.<name>}} placeholder, declared under notify.engagement.links. It is minted through share (ADR-0056) once per journey, rung and variant and never per person, with placement <journey>.<rung>.<variant>. Three recipients of variant a all get the same URL, so the link carries no one's identity.
notify:
engagement:
links:
offer: https://app.example.com/offerOpens come back as aggregate counts per variant through the getqr webhook preset and share.track.engagement. They appear in engagement.results next to the return and unsubscribe rates, never decide the winner, and never count toward the ignored signal. A link goes through share, never a shortener of your own; see App links for links routes and Share links and QR codes for the host rules.
Send time, variants and copy
sendAt: "usual"picks the local hour the person was most often active over 28 days, from 24 hourly counters of their ownactiveevents, with at least 5 active days andpersonalisationconsent. Otherwise, and always for children, it usessend-time.default. A time inside quiet hours moves to the next allowed minute.- A rung's
{ flag, variants }picks its template with the flag, so a person keeps one variant on every instance.engagement.resultsnames awinneronly when its return rate beats the first variant's, its unsubscribe rate is no higher, each variant has at least 100 sends and no group is hidden byanalytics.min-group. - With
copy.ai: true,engagement.copywrites text withai.generateusing the template as the brief. Placeholders stay as tokens, so no recipient data reaches the model. The copy is cached per tenant, template, version, locale and variant, and must pass the copy rules. A refused answer, a tenant over its limit or a failing driver sends the template text (MAIN-ENGAGE-0003). - A
{{proof.<name>}}placeholder runs a declared analytics query. A hidden, empty or undeclared result sends the text without the proof line (MAIN-ENGAGE-0007). Comparative proof never reaches children.
Children
A children's audience is an age band under 18, a child label, or users.audience: children with no known adult. The strict rules apply even when audience.children is not set: no streak-loss or loss framing, no guilt, no false urgency, no comparison with other people (MAIN-ENGAGE-0005), and the default send time. notify.engagement.guardian is required whenever a child may use the app.
Check, manifest and privacy label
mainsdk check has an engagement gate. The rule pass runs offline; the model pass runs only with check.ai: true and an ai driver, and the output carries a note: line (and notes in --json) when it was skipped.
| Code | Finding |
|---|---|
MAIN-ENGAGE-0010 | Confirmshaming in opt-out copy |
MAIN-ENGAGE-0011 | Social proof typed as a literal count instead of {{proof.*}} |
MAIN-ENGAGE-0012 | Engagement messages sent with no notify.engagement.max-per-week |
MAIN-ENGAGE-0013 | A children's audience is possible with no guardian settings |
MAIN-ENGAGE-0014 | Streak-loss, guilt or comparison in copy that can reach children |
mainsdk discover --out manifest.json writes notify.engagement into the manifest: the guardrail values, whether the strict rules and guardian controls apply, and each journey with its rungs. mainsdk privacy labels --format web prints the one-line promise, built from the resolved guardrails and never written by hand:
At most 4 reminders a week, none between 21:00 and 08:00, and they stop after 5 are ignored. For children: no streaks, guilt or comparisons, and a guardian can pause or limit reminders.Pushed config
A push may only tighten: lower max-per-week or stop-after-ignored, widen quiet-hours (paths notify.engagement.max-per-week, notify.engagement.stop-after-ignored and notify.engagement.quiet-hours). A loosening push is refused with MAIN-ENGAGE-0008, the last good value stays, and the refusal is audited.
What is connected, and what is not
app.engagement is wired to notify, email, workflows, events, flags, access (the guardian_of relation), share (links), ai (copy, when the app has an ai section) and jobs (held messages). These modules are not built yet, so set app.socialPorts.engagement = { ... } before the first use if you have your own: analytics (proof queries and the engagement.sent event), privacy (the personalisation consent) and children (the analytics audience rule). Without them, proof lines fall back to the text without proof, send time uses the default basis, and a child is known by ageBand or a child label. engagement.erase(userId) is called by your erase flow until privacy.erase calls it.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-ENGAGE-0001 | The person reached the weekly limit | Nothing; the message is skipped |
MAIN-ENGAGE-0002 | Messages stopped after ignored ones | The person returns on their own |
MAIN-ENGAGE-0003 | AI copy refused, over the limit or failed | Template text was sent |
MAIN-ENGAGE-0004 | Not the child's guardian, or a change loosens a limit (403) | Use a guardian; only lower limits |
MAIN-ENGAGE-0005 | Pressure or comparison in copy for a child | Rewrite the copy |
MAIN-ENGAGE-0006 | urgent: true on an engagement message (400) | Send it with notify in another category |
MAIN-ENGAGE-0007 | A proof placeholder was undeclared, hidden or empty | Declare the query, or accept the text without proof |
MAIN-ENGAGE-0008 | An invalid guardrail, or a pushed value that loosens one | Fix main.yaml; push only tighter values |
Swapping drivers
engagement has no driver of its own. Messages leave through the channels notify picks, and journeys run on the workflows driver, so swap those: change notify.channels, or workflows.driver. The guardrails and your calls stay the same. See Swap a driver.
Testing
The test app from @mainsdk/testing reads a notify.engagement section like the one above, and time-dependent rules (weekly limit, quiet hours, ignored messages) are tested with createEngagement(deps), which takes a now function so a test can move the clock. Name each test after the spec criterion, for example engagement AC1 skips the fifth message in a week.
Helpers for wiring and tests
| Call | What it does |
|---|---|
createEngagement(deps) | Build the instance from the parsed section and ports, for tests and custom wiring |
parseEngagement(raw) | Read the notify.engagement section; MAIN-ENGAGE-0008 for a missing or bad value |
engagement.apply(pushed) | Apply a pushed value; throws MAIN-ENGAGE-0008 for one that loosens |
engagement.erase(userId) | Delete a person's counters and stop handle |
Next
- Notifications: the channels, preferences and quiet hours underneath.
- Workflows: the engine behind journeys.
- App links: routes for
{{link.*}}placeholders. - Send engagement reminders: the steps, end to end.
Was this page helpful?