---
title: "Engagement journeys"
description: "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."
section: "interfaces"
status: "Building for 0.4 · ships as a experimental API"
spec: "engagement"
canonical: "https://mainsdk.dev/docs/interfaces/engagement"
updated: "2026-10-28"
---

# Engagement journeys

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

Status: Building for 0.4 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/engagement · 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`](https://mainsdk.dev/docs/interfaces/notify) and [durable workflows](https://mainsdk.dev/docs/interfaces/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

- Status: Building for 0.4 · ships as a experimental API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/engagement.md
- Category: Messaging and notifications

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)`

In main.yaml:

```yaml
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 }
```

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

Pick one in main.yaml with `engagement.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in on notify and workflows | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-ENGAGE-0010` | check | Confirmshaming in unsubscribe or opt-out copy |
| `MAIN-ENGAGE-0011` | check | Social proof not backed by data: a literal count or name ("1,240 people finished today") instead of a `{{proof.*}}` placeholder |
| `MAIN-ENGAGE-0012` | check | Engagement messages are sent (`journey`, `ladder`, `engagement.send` or an engagement category) with no `notify.engagement.max-per-week` |
| `MAIN-ENGAGE-0013` | check | A children's audience is possible with no `notify.engagement.guardian` settings |
| `MAIN-ENGAGE-0014` | check | Copy that can reach a children's audience has streak-loss or guilt pressure, or comparative social proof |
| `MAIN-ENGAGE-0001` | runtime | Engagement message skipped: the person reached the weekly limit |
| `MAIN-ENGAGE-0002` | runtime | Engagement messages stopped for this person: `stop-after-ignored` messages in a row were ignored |
| `MAIN-ENGAGE-0003` | runtime | 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` | runtime | Guardian control refused: the caller isn't the child's guardian, or the change loosens a limit (403) |
| `MAIN-ENGAGE-0005` | runtime | Not sent to a children's audience: the copy has streak-loss or guilt pressure, or comparative social proof |
| `MAIN-ENGAGE-0006` | runtime | `urgent: true` on an engagement message refused; engagement never skips quiet hours (400) |
| `MAIN-ENGAGE-0007` | runtime | A social proof placeholder had no declared query, or its result was hidden or empty; the text without proof was used |
| `MAIN-ENGAGE-0008` | runtime | A guardrail value in `main.yaml` is invalid (start fails), or a pushed value loosens one (refused, last good kept) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| engagement contract and main.yaml keys | 1 | 0.4 | `engagement#contract` |
| Journeys and ladders as durable workflows | 1 | 0.4 | `engagement#journeys` |
| Weekly limit, quiet hours and stop after ignored | 1 | 0.4 | `engagement#guardrails` |
| Send time from the person's own activity | 1 | 0.4 | `engagement#send-time` |
| Variants through flags with return and unsubscribe rates | 1 | 0.4 | `engagement#variants` |
| Copy from ai within a tenant limit | 1 | 0.4 | `engagement#ai-copy` |
| Strict rules and guardian controls for children | 1 | 0.4 | `engagement#children` |
| Social proof from declared queries | 1 | 0.4 | `engagement#proof` |
| Engagement checks | 1 | 0.4 | `engagement#checks` |
| Manifest and privacy label line | 1 | 0.4 | `engagement#label` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="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
```

```ts title="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

```yaml title="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](https://mainsdk.dev/docs/reference/main-yaml). 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.

| Channel | The stop | How it reaches engagement |
| --- | --- | --- |
| Email | 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` |

```ts title="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`](https://mainsdk.dev/docs/interfaces/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`](https://mainsdk.dev/docs/interfaces/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.

```yaml title="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](https://mainsdk.dev/docs/interfaces/links) for `links` routes and [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share) 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.

| 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:

```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

| 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](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

The test app from [`@mainsdk/testing`](https://mainsdk.dev/docs/interfaces/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](https://mainsdk.dev/docs/interfaces/notify): the channels, preferences and quiet hours underneath.
- [Workflows](https://mainsdk.dev/docs/interfaces/workflows): the engine behind journeys.
- [App links](https://mainsdk.dev/docs/interfaces/links): routes for `{{link.*}}` placeholders.
- [Send engagement reminders](https://mainsdk.dev/docs/guides/engagement-messages): the steps, end to end.
