Basic flow
c.state.name and greets them. flow() takes any
trigger: a string, a list, a regex, a
predicate, or WELCOME, IMAGE and the other special triggers.
Register and start flows
A flow’s id is its string trigger, normalized (
flow('Hola') is 'hola'; a list uses its first string). A flow built
with a regex, a predicate or a special trigger needs .id('name') to be
started by name, used as a goto target or made .persistent().
While a user is inside a flow, their messages go to it, not to other
triggers. Commands still win, so /reset (with
slashCommandsPlugin) always gets a user
out.
DSL methods
say — send a message
{ delay } ('500ms', '2s', '1m' or milliseconds) also works on ask,
choose (4th argument, after the state key: .choose(prompt, options, 'choice', { delay: '1s' }))
and media. It is a pause before the step, separate from the
typing indicator that
BotConfig.typing adds to every .say. To pause between steps without sending anything, use
.delay('3s'). An invalid delay, or one longer than a Node timer can hold
(2^31 − 1 ms, about 24 days), throws when the flow is built. Ending the
conversation (/reset, a replacement, bot.stop()) cancels a pending delay.
ask — send a message and wait for the reply
validate returns an error string, the bot sends it back and re-asks. The
options are the same on every wait (.ask(), .choose(), and c.ask(),
c.waitText(), c.choose()… in conversations):
When the last attempt is rejected, the conversation ends with the
retriesMessage (”❌ Too many attempts. Conversation ended.” by default).
Schema validation (zod-compatible)
schema accepts any object with a safeParse() method: zod, valibot
adapters or your own validator. Core does not depend on zod. The parsed
value is stored in c.state, and a rejected answer re-asks with
otherwise or, if it is not set, with the schema’s first issue message (or
the localizable core.wait.invalid built-in when the schema gives none, see
i18n):
schema runs after validate and shares its maxAttempts budget. It also
works in conversations: await c.ask(q, { schema }) and
await c.waitText({ schema }) resolve with the parsed value.
do — run an action, no message
media — send media
kind is image, video, audio, voice, document, sticker or
location. The source can be a URL, a path, a Buffer, or a function
(ctx, c) => source computed at runtime. The 4th argument takes delay
plus per-kind options (filename, ptv, gifPlayback, pack, author).
choose — branching (numbered menu)
choose sends a numbered menu built from options (Record<string, string>, key → label) and stores the chosen key in c.state.choice —
or in whatever key you pass as the 3rd argument:
.choose(prompt, options, 'answer').
Each option can instead carry a then sub-flow that runs right after it’s
picked; control returns to the parent flow afterwards:
then can also be a whole flow (it runs inline and returns, like .use()
below). An option with goto
leaves the current flow for another conversation, which is how you build
a “back to the main menu” option:
timeout — inactivity expiration
.timeout() only sets the wait duration — it takes no message argument.
The text sent when it expires is configured once, globally, on the bot:
then: sub-flow uses its own .timeout(); it does not inherit the
parent flow’s. Without one, the bot-wide conversation.timeout (or the
built-in 5-minute fallback) applies.
goto — jump to another flow
bot.use(flow('b')...) or
bot.conversation('b', ...)); bot.lint() reports a goto to a missing
flow as goto-target-missing.
use — run a sub-flow and come back
.use(subFlow) runs another flow inline and then continues with the next
step. The sub-flow shares c.state, so whatever it captures is visible in
the parent (and typed, see Typed state). The sub-flow is
copied when .use() is called; later changes to its builder do not affect
the parent. A c.retry() in a parent step after .use() re-runs the
sub-flow when its last wait was there.
guard — stop unless a condition holds
(ctx, c) => string) it is sent and the conversation
ends; without one it ends silently; with { redirect, message? } the message
(if any) is sent and it jumps to another conversation like goto (a
FlowBuilder works as the target too).
bot.lint() checks redirect targets like goto targets.
persistent — survive restarts
.persistent() checkpoints when the flow starts and after every step, so a
restart while it waits, even at its first .ask(), resumes it there (the
pending question is asked again). That only survives a process restart if
the store is backed by a file:
.id('registrar').
scope — restrict to dm / group / all
form — several typed fields in one step
.form(definition, opts) collects text, number, email, date, select,
multi-select and opt-in fields with validation. On the WhatsApp Cloud API it is
sent as a native WhatsApp Flow; elsewhere the bot asks field by field. See
Forms (WhatsApp Flows).
c.state — memory between steps
c.state is a plain, mutable object scoped to the running flow. .ask and
.choose write to it automatically; .do and the function form of .say
read and write it directly:
Typed state
.ask(), .choose() and .use() add the keys they capture to the flow’s
type, so c.state is typed without casts. With a schema, the key gets the
schema’s output type; without one it is a string, and .choose() gives
the union of option keys. Declare state the flow expects up front with
flow<S>(), and read a builder’s type with FlowStateOf<typeof f>:
unknown.
Complete example: registration
Beyond the DSL
For arbitrary control flow (loops, custom branching, calling an external API mid-conversation) write the conversation as an async function. Flows built withflow() compile down to exactly this runtime.
{ trigger }, start it with bot.trigger('quiz', { chatId }) or
c.goto('quiz') from another conversation. The c object:
opts takes the wait options
(timeout, validate, maxAttempts, otherwise, filter, schema).
ctx in bot.conversation() is a full MessageContext
The second argument of a bot.conversation() handler is the full
MessageContext, typed through DefaultTools: ctx.tools (including
augmentations such as ctx.tools.ai or ctx.tools.nlu), ctx.user,
ctx.handoff(), ctx.photo, ctx.poll, ctx.react, ctx.quote,
ctx.senderInfo… No casts needed.
ConversationHandler<TTools = DefaultTools>. In the DSL (flow().do((ctx, c) => ...)) ctx is still a RuntimeMessage;
use userOf(ctx) and bot.handoff(ctx.from) there.
c.retry(): repeat the last wait
c.retry(message?) repeats the most recent wait (ask, waitText,
waitMedia, waitLocation, choose, waitAny) with the same options (validate,
filter, timeout, otherwise, maxAttempts). Before waiting it re-sends the
ask question or the choose menu, or message instead. For choose,
message only replaces the header; the numbered options stay.
waitText/waitMedia/waitLocation/waitAny have no prompt, so they only send
message if you pass one. c.retry('') waits again silently.
In an async conversation it returns the new value:
waitMedia/waitAny, type it: await c.retry<RuntimeMessage>('Send another photo')
(c.retry<InboundLocation>() after waitLocation).
In a flow step (.do() or a .choose() then action) whose last wait
belongs to an earlier step, c.retry() rewinds the flow to that step: it is
asked again (with message instead of the question, if given), its state
key gets the new answer, and the following steps run again, including the
.do() that called it. It throws a control signal synchronously, so code
after it in the step does not run (like c.end()):
- It works in sub-flows (
then: subFlow,.use(subFlow)) and in.persistent()flows resumed after a restart. If the wait was made in the same.do()(await c.waitText()), it retries in place. - Budget: each
c.retry()counts as one invalid attempt of that wait, shared withvalidaterejections. ReachingmaxAttempts(default 3) throwsRetriesExceededand the conversation ends withretriesMessage. Each new wait starts at zero. For unlimited retries usemaxAttempts: Infinity. - With no previous wait it fails with
RetryUnavailableError(sent to the'error'event, anderrorMessageis sent). - Always
await(orreturn) it. A conversation has one pending-wait slot: a new wait started while an un-awaited retry is still waiting fails withConcurrentWaitError. - Do not call it inside
validate(return an error string there). If you wrap a step intry/catch, rethrowConversationError(if (e instanceof ConversationError) throw e).
c.idle(): inactivity inside a conversation
- The callback runs once when
duration('30s','2m','1h','1d','10 minutes'or ms, up to about 24.8 days) passes with no message from the conversation’s user. Every inbound message reschedules it, even one the pending wait filters out; after it fires, the next message re-arms it. - Calling
c.idle()again replaces the previous watchdog. It is disarmed when the conversation ends, on/resetorbot.abortConversation, ongotoand onbot.stop(), so no timers are left behind. - Inside the callback:
c.end(farewell?)orc.goto('menu')finish or redirect the conversation at the pending wait.c.retry(msg?)re-sends the pending question as a reminder and keeps waiting for the same answer (it counts towardsmaxAttempts; for a reminder that does not count, usectx.reply). If no wait is pending it does nothing. The callback cannot start new waits. Other errors go to the'error'event and the conversation goes on. - It is independent from the wait
timeout, which ends the conversation withtimeoutMessage. It works in flow.do()steps and stays active between steps. Commands handled by the dispatcher do not count as activity. It is not persisted (it does not survive a restart or agoto).
bot.onIdle(): inactivity per chat
- Per chat (
ctx.from); in a group, any participant counts. - It fires once per period, counted from the last inbound message the bot finished processing. Each new message reschedules and re-arms it.
- Messages dropped before the end of the pipeline (a
before('message')hook that aborts, thegroupModefilter) do not count as activity. bot.stop()cancels everything; registrations survive stop/start.- Human handoff: a handed-off chat’s timers are cancelled and suspended (its messages do not re-arm them) until it is resumed, also for handoffs restored from the store at start.
- Handler errors (sync or async) go to the
'error'event with thectx. - Uses the bot’s
Scheduler. An invalid duration (≤ 0, not finite, above 2^31 − 1 ms) throwsRangeError. Inside a plugin, pass the returned unsubscribe toctx.onDispose.
Checking your flows
bot.describe() draws your flows as a Mermaid diagram and bot.lint()
reports broken gotos, unreachable steps, duplicate triggers and more. See
Inspect & lint flows.
Next steps
- Flow examples — full recipes
- Special triggers — WELCOME, message types and CALL
- Hooks — intercept messages
- Plugins — package functionality