Skip to content

Ask the docs

Answers come only from these docs and are written by an AI model (Gemini), so check the linked pages. Your question is not kept. Do not include personal details or keys.

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.

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

const result = await agent.nudge({
to: { channel: "whatsapp", address: "+15550100100" },
prompt: "Their order is ready for pickup.",
});
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.

StatusWhat happened
sentA message went out
silentThe agent decided not to send one
scheduled or heldIt will be considered later
dropped or refusedA contact or delivery rule prevented it
expiredIts delivery window passed
failedThe attempt failed; the person was not sent a model-error notice

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.

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:

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.

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

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

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

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:

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
},
});

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:

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.

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

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:

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

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

Turn backend events into notifications with delivery levels and deduplication.