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 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.
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
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
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:
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:
notify:
engagement:
links:
offer: https://app.example.com/offerWrite {{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:
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)showssentThisWeekat 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
stopByHandleon a push, stops the category on every channel. mainsdk checkpasses with noMAIN-ENGAGE-0010to0014findings. Withcheck.ai: falseit prints anote:that the model pass was skipped.mainsdk privacy labels --format webprints 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: trueis refused. Engagement never skips quiet hours.MAIN-ENGAGE-0008: a guardrail inmain.yamlis 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?