---
title: "Send engagement reminders without dark patterns"
description: "Send a short ladder of reminders to people who went quiet, within a weekly cap and quiet hours, that stops when they return or stop reading."
section: "guides"
status: "Building for 0.4"
spec: "engagement"
canonical: "https://mainsdk.dev/docs/guides/engagement-messages"
updated: "2026-10-28"
---

# Send engagement reminders without dark patterns

> Send a short ladder of reminders to people who went quiet, within a weekly cap and quiet hours, that stops when they return or stop reading.

Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/engagement-messages · Updated: 2026-10-28

You want people who went quiet to come back, without spamming them or using guilt. This guide sends up to three reminders over a week, respects a weekly cap and quiet hours, and stops when the person returns. It takes about 30 minutes.

`engagement` is in 0.4 (spec `engagement`) and runs from the repository; it is not published to npm. Status: experimental.

## You need

- A MainSDK app with [notifications](https://mainsdk.dev/docs/interfaces/notify) set up for at least one channel.
- The event that means a person came back, such as `user.active`.
- Two or three approved message templates.

## 1. Set the guardrails

```yaml title="main.yaml"
notify:
  engagement:
    max-per-week: 4
    quiet-hours: "21:00-08:00"
    stop-after-ignored: 5
    active: user.active
    ignored-after: 48h
    send-time: { default: "17:00" }
    categories: [reminders]
```

These are checked at run time. A later change that loosens one is refused, and the last good value stays.

## 2. Write the journey

```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: "reminder.gentle-2" },
    { after: "7d", template: "reminder.last" },
  ], { sendAt: "usual" }));
```

`ladder` waits for each rung, checks whether the person came back, and sends at a time that suits them.

## 3. Make every stop one step

Each engagement message carries a stop. Email goes with an RFC 8058 one-click unsubscribe, so set `email.unsubscribe-url`. Push carries a stop action with a `handle`; call it back:

```ts title="app/push/stop.ts"
import { app } from "mainsdk";

export const onStop = (handle: string) => app().engagement.stopByHandle(handle, { channel: "push" });
```

For WhatsApp and SMS pass inbound text to `engagement.keyword(user, text, { channel })`. A stop applies to the category on every channel before the next send, with no confirmation step.

## 4. Link without tracking people

Use a placeholder and declare its target. One link is minted per journey, rung and variant, never per person, and its opens come back as counts:

```yaml title="main.yaml"
notify:
  engagement:
    links:
      offer: https://app.example.com/offer
```

Write `{{link.offer}}` in the template. Opens never decide a winner and never count toward the ignored signal.

## 5. Check the copy

Copy must not use streak-loss or guilt. Social proof must come from a placeholder such as `{{proof.finished-today}}`, backed by a declared query, not a typed number. `mainsdk check` reports `MAIN-ENGAGE-0010` and `0011` when it does not.

## 6. Turn on children's rules if they can use the app

If any child can have an account, add the guardian controls and keep the strict copy rules:

```yaml title="main.yaml"
notify:
  engagement:
    guardian: { controls: [pause, frequency, quiet-hours, view] }
```

Without guardian settings, `mainsdk check` fails with `MAIN-ENGAGE-0013`.

## Check it worked

- `engagement.status(user)` shows `sentThisWeek` at most 4.
- A message due inside quiet hours is deferred until they end, never sent early.
- Five ignored messages in a row stop engagement messages for that person (`MAIN-ENGAGE-0002`).
- One POST to the email's unsubscribe link, or `stopByHandle` on a push, stops the category on every channel.
- `mainsdk check` passes with no `MAIN-ENGAGE-0010` to `0014` findings. With `check.ai: false` it prints a `note:` that the model pass was skipped.
- `mainsdk privacy labels --format web` prints the promise built from your guardrails.

## If it fails

- `MAIN-ENGAGE-0001`: the weekly cap was reached. Nothing to fix; the message waits.
- `MAIN-ENGAGE-0006`: `urgent: true` is refused. Engagement never skips quiet hours.
- `MAIN-ENGAGE-0008`: a guardrail in `main.yaml` is invalid, so start fails. Fix the value.

## Prompt for your coding agent

> Add a come-back journey with three reminders using MainSDK's engagement messages. Keep the weekly cap at 4, quiet hours from 21:00 to 08:00, and stop after five ignored messages. Use no guilt or streak copy, and use a proof placeholder for any social proof.
