Skip to main content

Basic flow

When someone writes “hola”, the bot asks for the name, waits for the answer, stores it in 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

If 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:
A 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

The target must be registered too (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

If the predicate (sync or async) returns a falsy value, the flow stops: with a message (a string or (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:
A flow needs a stable id to be checkpointed. A string trigger already provides one; if you built the flow with a regex/predicate trigger, give it an explicit id with .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>:
A key that is not a string literal cannot be tracked; those values stay reachable as 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 with flow() compile down to exactly this runtime.
Without { 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).
Do not catch the control signalsc.goto(), c.end(), a timeout and too many attempts work by throwing a ConversationError. If you wrap waits in try/catch, rethrow it: if (e instanceof ConversationError) throw e.

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.
The handler’s type is 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:
For 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 with validate rejections. Reaching maxAttempts (default 3) throws RetriesExceeded and the conversation ends with retriesMessage. Each new wait starts at zero. For unlimited retries use maxAttempts: Infinity.
  • With no previous wait it fails with RetryUnavailableError (sent to the 'error' event, and errorMessage is sent).
  • Always await (or return) it. A conversation has one pending-wait slot: a new wait started while an un-awaited retry is still waiting fails with ConcurrentWaitError.
  • Do not call it inside validate (return an error string there). If you wrap a step in try/catch, rethrow ConversationError (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 /reset or bot.abortConversation, on goto and on bot.stop(), so no timers are left behind.
  • Inside the callback: c.end(farewell?) or c.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 towards maxAttempts; for a reminder that does not count, use ctx.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 with timeoutMessage. 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 a goto).

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, the groupMode filter) 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 the ctx.
  • Uses the bot’s Scheduler. An invalid duration (≤ 0, not finite, above 2^31 − 1 ms) throws RangeError. Inside a plugin, pass the returned unsubscribe to ctx.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