MainSDK
Search⌘K
Interfaces

Messaging and notifications

Engagement journeys

engagement

Bring 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.

Building for 0.4ships as a experimental APIPhase 1 · Core, public betaSpec engagementFree in your app, Apache-2.0

Status 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.
Stability levels
Updated 2026-10-28

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 }

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.

main.yamlBuilt in on notify and workflows (default)
engagement:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-ENGAGE-0010Checkmainsdk check, before deployConfirmshaming in unsubscribe or opt-out copy
MAIN-ENGAGE-0011Checkmainsdk check, before deploySocial proof not backed by data: a literal count or name ("1,240 people finished today") instead of a {{proof.*}} placeholder
MAIN-ENGAGE-0012Checkmainsdk check, before deployEngagement messages are sent (journey, ladder, engagement.send or an engagement category) with no notify.engagement.max-per-week
MAIN-ENGAGE-0013Checkmainsdk check, before deployA children's audience is possible with no notify.engagement.guardian settings
MAIN-ENGAGE-0014Checkmainsdk check, before deployCopy that can reach a children's audience has streak-loss or guilt pressure, or comparative social proof
MAIN-ENGAGE-0001RuntimeError responses and logsEngagement message skipped: the person reached the weekly limit
MAIN-ENGAGE-0002RuntimeError responses and logsEngagement messages stopped for this person: stop-after-ignored messages in a row were ignored
MAIN-ENGAGE-0003RuntimeError responses and logsAI copy was refused by the copy rules, was over the tenant's copy.limit, or failed; the template text was used
MAIN-ENGAGE-0004RuntimeError responses and logsGuardian control refused: the caller isn't the child's guardian, or the change loosens a limit (403)
MAIN-ENGAGE-0005RuntimeError responses and logsNot sent to a children's audience: the copy has streak-loss or guilt pressure, or comparative social proof
MAIN-ENGAGE-0006RuntimeError responses and logsurgent: true on an engagement message refused; engagement never skips quiet hours (400)
MAIN-ENGAGE-0007RuntimeError responses and logsA social proof placeholder had no declared query, or its result was hidden or empty; the text without proof was used
MAIN-ENGAGE-0008RuntimeError responses and logsA 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.

FeaturePhaseMilestoneSpec ticket
engagement contract and main.yaml keysPhase 10.4engagement#contract
Journeys and ladders as durable workflowsPhase 10.4engagement#journeys
Weekly limit, quiet hours and stop after ignoredPhase 10.4engagement#guardrails
Send time from the person's own activityPhase 10.4engagement#send-time
Variants through flags with return and unsubscribe ratesPhase 10.4engagement#variants
Copy from ai within a tenant limitPhase 10.4engagement#ai-copy
Strict rules and guardian controls for childrenPhase 10.4engagement#children
Social proof from declared queriesPhase 10.4engagement#proof
Engagement checksPhase 10.4engagement#checks
Manifest and privacy label linePhase 10.4engagement#label

Quick start

main.yaml
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 back
app/engagement/come-back.ts
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: { 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

main.yaml
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

text
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 event

ladder 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

text
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 it

Before 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

text
engagement.guardian(child).get()
engagement.guardian(child).set({ paused?, maxPerWeek?, quietHours? })   // only lowers or widens

Only 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.

ChannelThe stopHow it reaches engagement
EmailRFC 8058 one-click List-Unsubscribe header; the message goes as marketing mailapp.engagement listens to the email notices, so one POST to the link stops the category
PushA stop action in the push payload, naming the person only by a handleCall engagement.stopByHandle(handle, { channel: "push" }) from the action's handler
WhatsApp and SMSThe STOP keywordPass inbound text to engagement.keyword(user, text, { channel }); a stop word stops, anything else returns null
app/push/stop.ts
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).

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.

main.yaml
notify:
  engagement:
    links:
      offer: https://app.example.com/offer

Opens 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 own active events, with at least 5 active days and personalisation consent. Otherwise, and always for children, it uses send-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.results names a winner only 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 by analytics.min-group.
  • With copy.ai: true, engagement.copy writes text with ai.generate using 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.

CodeFinding
MAIN-ENGAGE-0010Confirmshaming in opt-out copy
MAIN-ENGAGE-0011Social proof typed as a literal count instead of {{proof.*}}
MAIN-ENGAGE-0012Engagement messages sent with no notify.engagement.max-per-week
MAIN-ENGAGE-0013A children's audience is possible with no guardian settings
MAIN-ENGAGE-0014Streak-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:

text
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

CodeWhenFix
MAIN-ENGAGE-0001The person reached the weekly limitNothing; the message is skipped
MAIN-ENGAGE-0002Messages stopped after ignored onesThe person returns on their own
MAIN-ENGAGE-0003AI copy refused, over the limit or failedTemplate text was sent
MAIN-ENGAGE-0004Not the child's guardian, or a change loosens a limit (403)Use a guardian; only lower limits
MAIN-ENGAGE-0005Pressure or comparison in copy for a childRewrite the copy
MAIN-ENGAGE-0006urgent: true on an engagement message (400)Send it with notify in another category
MAIN-ENGAGE-0007A proof placeholder was undeclared, hidden or emptyDeclare the query, or accept the text without proof
MAIN-ENGAGE-0008An invalid guardrail, or a pushed value that loosens oneFix 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

CallWhat 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet