MainSDK
Search⌘K
How-to

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.

Building for 0.4

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

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

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

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:

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.

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:

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:

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.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet