> ## Documentation Index
> Fetch the complete documentation index at: https://flow.engineer/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Reminders and nudges

> Send useful follow-ups with schedules, quiet hours, opt-outs and WhatsApp templates.

A reminder is something a person asks for, such as “remind me tomorrow.” A nudge is a message
your business starts, such as an order-ready notification. Flow handles both in the context
of the person's conversation.

## Send a nudge

From the backend code that owns your running agent, address a person who has already written
on that channel. On a phone channel, the number is read as their messages' numbers are, so another
way of writing it finds them (see [Which number finds them](#which-number-finds-them)):

```ts Let the agent phrase it
const result = await agent.nudge({
  to: { channel: "whatsapp", address: "+15550100100" },
  prompt: "Their order is ready for pickup.",
});
console.log(result.status, result.reason);
```

```ts Use exact wording
const result = await agent.nudge({
  to: { channel: "whatsapp", address: "+15550100100" },
  text: "Your pickup code is 1042.",
  idempotencyKey: "pickup-1042",
});
console.log(result.status, result.reason);
```

Prefer `prompt` when the agent should write in the person's language and decide whether the
message still fits. Use `text` when the wording must be exact. Use `template` for a declared
WhatsApp template with its header, body and button values.

**Check the result:** `nudge()` settles with a status and optional reason. A message can be held
for quiet hours, refused outside WhatsApp's window, or dropped because the person opted out.

| Status                 | What happened                                                    |
| ---------------------- | ---------------------------------------------------------------- |
| `sent`                 | A message went out                                               |
| `silent`               | The agent decided not to send one                                |
| `scheduled` or `held`  | It will be considered later                                      |
| `dropped` or `refused` | A contact or delivery rule prevented it                          |
| `expired`              | Its delivery window passed                                       |
| `failed`               | The attempt failed; the person was not sent a model-error notice |

## Message someone first

WhatsApp lets a business message a number that never wrote to it, with an approved template. A
template nudge does that: it makes the person and their conversation, as their first message
would, and sends the template.

```ts
const result = await agent.nudge({
  to: { channel: "whatsapp", address: "+91 98765 43210" },
  template: { name: "seller_welcome", body: ["Asha"] },
  idempotencyKey: "seller-417-welcome",
});
// { id: "n_…", status: "sent" }
```

* **Give the full number, starting with `+` or `00`.** It is kept in E.164 (`+919876543210`), as
  inbound numbers are, so their reply lands in the same conversation, where the agent knows which
  template they read. A national number such as `"(555) 010-0177"` or `"07700 900123"`, or bare
  digits such as `"5550100177"`, is refused (`unknown_person`), never read as another country's:
  `defaultCountryCode` does not apply on WhatsApp.
* **A number that is already someone joins them.** If they wrote on another phone channel, the
  template goes in that conversation, and their STOP, pauses and no-contact rules apply.
* **Only templates.** A `text` or `prompt` nudge to someone who never wrote is refused
  (`unknown_person`), as is a template on SMS or to an address that is not a phone number (7 to
  15 digits).
* **The rest is as for anyone:** a declared template's values are checked before it goes, the
  same `idempotencyKey` sends once, and quiet hours apply once their time zone is known.

To send a template only to someone who has already written, check first. `agent.contact(to)`
finds people the way a nudge does, so it is `null` exactly when the nudge would make a new person:

```ts
const to = { channel: "whatsapp", address: "+1 555 010 0177" };
if (!(await agent.contact(to))) return; // never written: no template to a stranger
await agent.nudge({ to, template: { name: "order_shipped", body: ["Ana", "1042"] } });
```

In the playground, the Person panel's template to a test person who has not written appears in
their thread in the playground, never through the provider.

### Which number finds them

* **On WhatsApp**, a number counts when it is written in full (`+1 (555) 010-0177`,
  `0015550100177`), or as WhatsApp writes it (`15550100177`) for someone who has already written.
  A national number with spaces, brackets or a leading 0 is refused `unknown_person`, and so are
  bare digits for a template to a new person.
* **On SMS and other phone channels**, a national number is read with `defaultCountryCode`, as
  incoming numbers are ([phone identities](https://flow.engineer/docs/conversations.md)).
* **A person your `resolve` keys** is found the same way, whichever format the business uses.
* **`agent.contact(to)`** finds them by the same rules, so it answers for the person the nudge
  would reach.
* **One number written two ways** on a channel (the business's format and the provider's): the
  nudge goes to the one they last wrote from, with that identity's 24-hour window. Scheduled
  messages pick it the same way.
* **A test person** who wrote both from the playground and from their real phone: a nudge from
  your backend goes to the real phone, and the Person panel's stays in the playground.

## Schedule and deduplicate

Set `at` to a future time in milliseconds since the epoch, on a `text` or `prompt` nudge (a
template cannot be scheduled yet). `idempotencyKey` makes repeated submissions return the original
result and send once, including concurrent calls.

To call off a scheduled nudge before it goes, cancel it by that key (0.8.0 or later):

```ts Cancel a scheduled nudge
await agent.nudge({ to, text: "Your session starts soon.", at: startsAt - 15 * 60_000, idempotencyKey: `session:${id}` });
// The session was cancelled:
await agent.cancelNudge(`session:${id}`); // { id: "n_...", status: "cancelled" }
```

It resolves to the nudge's state: `cancelled`, or what it was already if it is not waiting to go (it
went, or it had no `at`), or `null` for a key no nudge has. A nudge scheduled before 0.8.0 is not
found, and goes as planned. For the agent to check on something itself later, use a
[wake-up](https://flow.engineer/docs/wake-ups.md) instead.

Use `expiresAt` for information that becomes stale. The default expiry is 24 hours; a “your
table is ready” message should usually have a shorter window.

## Reminders and contact preferences

People can ask the agent to schedule, list or cancel reminders. The agent uses their time zone
for clock times and does not need a custom reminder tool.

“No promotions” and “nothing before 9am” are stored per person. They apply to business-started
messages across channels.

> **STOP, START, HELP and intercept hooks**
>
> On SMS (STOP; START; HELP, INFO) and WhatsApp (STOP, UNSUBSCRIBE; START), a message that is only the keyword is answered with no model call. STOP keeps a hard rule: no nudge, scheduled message or reminder goes to that person, on any channel, until they text START; the agent still answers when they write, and cannot lift it. Set the replies, or other words, with `sms(provider, { keywords: { replies: { stop, start, help } } })` (`keywords: false` turns them off); without a `help` reply the agent answers HELP. A reply left out sends nothing, for providers that send their own. Which words carriers or regulators expect differs by country (some US carrier rules expect STOPALL, UNSUBSCRIBE, CANCEL, END and QUIT too), so check your market's rules; Flow Framework's default on SMS is STOP only. For the fuller list, `sms(provider, { keywords: US_CARRIER_KEYWORDS })` (with your replies: `{ ...US_CARRIER_KEYWORDS, replies }`), or name your own words: `keywords: { stop: ["STOP", "ARRET"] }`.
>
> Your `intercept` sees these words first, with `message.keyword` set to `"stop"`, `"start"` or `"help"` (`null` for any other message), so an agent that keeps its own opt-out state can update it. `message.channel` is the channel every message came in on, by its name in `channels`. Flow Framework keeps or lifts the STOP whatever the hook returns, and also when it throws; a reply the hook returns goes instead of Flow Framework's, never both. The keyword and its reply are kept in the history and the transcript, as any message answered without the model is. An agent that keeps its own opt-out for texts only:
>
> ```ts
> const optedOutOfSms = new Set<string>();
> createAgent({
>   // ...
>   intercept: (conversationId, text, message) => {
>     if (message.channel !== "sms") return null; // a STOP on WhatsApp is Flow Framework's alone
>     if (message.keyword === "stop") optedOutOfSms.add(conversationId);
>     if (message.keyword === "start") optedOutOfSms.delete(conversationId);
>     return null; // Flow Framework's own reply goes
>   },
> });
> ```

## WhatsApp's 24-hour window

Outside it, WhatsApp delivers only templates. Declare a fallback, a template with no values that says something is waiting ("You have a new message. Reply to see it."), and a nudge still reaches the person:

```ts
createAgent({
  // ...
  templates: [
    { name: "message_waiting", language: "en", category: "utility", body: "You have a new message. Reply to see it." },
    { name: "order_shipped", language: "en", category: "utility", body: "Hi {{1}}, your order {{2}} has shipped." },
  ],
  fallbackTemplate: { name: "message_waiting" },
});
```

* **A `prompt` nudge**: the agent is told the window is closed and may send one of your templates that says the same thing, its values filled in (`send_template`, built in).
* **When none fits** (or for a `text` nudge), Flow Framework sends the fallback, and the nudge is held (`held`, `window_closed`) until they reply, when the agent tells them (or the text goes as written). Past its `expiresAt` (24 hours by default) it expires.
* **Each fallback emits a `template.missing` event** with what the nudge said: the messages worth a template of their own.

**A scheduled `prompt` nudge** (`at`) that comes due past the window goes as one sent then would,
when the agent has a `fallbackTemplate`: the agent may send a template that says it, else the
fallback goes and the nudge is held until they reply. Its scheduled run is recorded as skipped,
with a reason naming the nudge it went on as (`outside_window: went on as nudge n_…`); read that
nudge's state (`agent.nudgeState(id)`, or `GET /nudges/<id>`) for how it ended. A scheduled `text`
nudge past the window is skipped (`outside_window`), with a fallback or without.

Without a fallback, a `text` or `prompt` nudge outside the window is refused (`outside_window`),
and a scheduled one is skipped. A template nudge always goes as it is. Meta decides each template's category from its text and may charge or limit it as marketing: check yours.

## Quiet hours

What the business starts waits for each person's quiet hours: 22:00 to 07:00 in their own time by default (`createAgent({ quietHours: { from: "21:00", to: "08:00" } })` to change it, `false` for none). They apply only where the person's time zone is known (see [Time zones](https://flow.engineer/docs/conversations.md#time-zones)): without it nothing is held.

* **A nudge in their quiet hours is held** (`status: "held"`), and goes when the hours end or as soon as they write, whichever comes first. Everything held reaches one turn: the agent writes one short message for all of it, not a text each, after answering whatever they just asked. A `text` or template nudge goes as written.
* **It can go stale.** A held nudge past its `expiresAt` (default 24 hours) is dropped as `expired`; give a short one to "your table is ready". `respectQuietHours: false` sends it now, quiet hours or not, for what cannot wait (a security alert).
* **A scheduled nudge** (`at`) that comes due in their quiet hours moves to when they end.
* **Their own reminders** go at the time they asked for, quiet hours or not, unless the agent marks one as able to wait (`respect_quiet_hours` on `schedule_message`: "check for new listings and let me know"); a repeat like that skips its runs in their quiet hours.
* **People set their own.** "Not after 9pm" sets theirs (`set_quiet_hours`, built in), "no messages this week" pauses everything the business starts (`pause_messages`), with no model call to check either. Your backend sets or reads them too: `agent.setQuietHours({ to, quietHours })`, `agent.contact(to)`.

The contact record (`agent.contact(to)`, or `GET /people/contact`) is what the agent goes by for a person: `timeZone` and `timeZoneSource` (`business` or `person`), `quietHours` and `quietHoursSource` (`person`, `agent`, `off`, or `no_time_zone`), `stop`, `pauses` and `topics` (what they asked not to be messaged about, in their words).

Quiet hours are the framework's delivery settings. Configure them for your own product and market requirements.

## Nudges over HTTP

When the system that decides to nudge is a separate service, mount `nudgeHandler` in the agent's server and call it over HTTP. It is a plain `(req, res)` handler; hand it the requests under its `base` (`/nudges` by default; anything else gets 404). It needs a token of at least 32 characters, from a secret, or it throws at setup:

```ts
import { createServer } from "node:http";
import { nudgeHandler } from "@flow-engineer/flow";

const nudges = nudgeHandler(agent, { token: process.env.NUDGE_TOKEN! });
createServer((req, res) => {
  if (req.url?.startsWith("/nudges")) return nudges(req, res);
  // ... your webhooks
}).listen(3000);
```

* `POST /nudges` with `{ to: { channel, address }, text | prompt | template, at?, idempotency_key?, respect_quiet_hours?, expires_at? }`: exactly one of `text`, `prompt` or `template`, as in `agent.nudge()`; `at` is milliseconds since the epoch; unknown fields are refused; at most 16 KB. It answers `202 { id, status, reason? }` as soon as the nudge is recorded, without waiting for the send or the agent's turn: usually `queued`. What is decided as it is recorded is in that answer too, still a 202, since it is the nudge's state and not an error: `refused` (`unknown_person`, for a text or prompt nudge to someone who never wrote, with `id` `""` since nothing is recorded; `outside_window`), `dropped` (they texted STOP, or paused messages), `held` (their quiet hours) or `scheduled`. A bad body, a channel the agent does not have, or a declared template's wrong values get `400 { error }`.
* `GET /nudges/<id>`: `200 { id, status, reason? }`, its state now: `queued`, `sent`, `silent`, `dropped`, `refused`, `failed`, `scheduled`, `held`, `expired` or `cancelled` (by `agent.cancelNudge`). An id it never recorded gets 404.
* The same `idempotency_key` returns the first nudge's id and its state now, and sends once.
* Every request needs `Authorization: Bearer <token>`, compared in constant time; wrong tokens are limited per address (10 in 15 minutes, then 429), as on the playground. Answers are JSON with `cache-control: no-store`. Each accepted nudge is logged (id, status, channel, kind, time and the caller's IP address), never its text or the token.

```sh
curl -s -X POST https://agent.example.com/nudges \
  -H "Authorization: Bearer $NUDGE_TOKEN" -H "content-type: application/json" \
  -d '{"to":{"channel":"whatsapp","address":"+15550100100"},"prompt":"Their order is ready for pickup.","idempotency_key":"order-1042-ready"}'
# {"id":"n_3f9c2a1b","status":"queued"}
curl -s https://agent.example.com/nudges/n_3f9c2a1b -H "Authorization: Bearer $NUDGE_TOKEN"
# {"id":"n_3f9c2a1b","status":"sent"}
```

What you know about people goes the same way, for a business that keeps it in its own database: mount `peopleHandler(agent, { token })` and

* `PUT /people/time-zone` with `{ to: { channel, address }, time_zone }`;
* `PUT /people/quiet-hours` with `{ to, quiet_hours: { from, to } | "off" | null }` (null: back to the agent's);
* `GET /people/contact?channel=&address=`: their contact record (below).

Each answers `200`, or `404 { status: "refused", reason: "unknown_person" }` for someone who has never written, or `400 { error }` (a name that is not a time zone, hours that are not `HH:MM`).

```sh
curl -s -X PUT https://agent.example.com/people/time-zone \
  -H "Authorization: Bearer $NUDGE_TOKEN" -H "content-type: application/json" \
  -d '{"to":{"channel":"sms","address":"+15550100100"},"time_zone":"Asia/Kolkata"}'
# {"status":"set"}
```

The contract is [`spec/nudges.openapi.yaml`](https://github.com/flow-engineer/flow/blob/main/spec/nudges.openapi.yaml). In code, `agent.recordNudge(input)` is the same early answer and `agent.nudgeState(id)` the same read.
`agent.recordNudge(input, { via: "playground" })` is what the playground's Person panel sends: a
template it sends to a test person who never wrote makes them the playground's person, so what goes
to them shows in the playground rather than through the provider.

> **[Send signed business events](https://flow.engineer/docs/events.md)**
>
> Turn backend events into notifications with delivery levels and deduplication.
