> ## Documentation Index
> Fetch the complete documentation index at: https://botmesh.afaya.com.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Conversation Flows

> Flows are multi-step conversations. flow(trigger) gives you a chainable DSL that compiles to a real async conversation under the hood: the bot asks, the user answers, the bot stores the answer in c.state and continues.

## Basic flow

```ts theme={null}
import { flow } from '@botmesh/core'

const greet = flow('hola')
  .ask('👋 Hello! What is your name?', 'name')
  .say((_ctx, c) => `Nice to meet you, ${c.state.name}!`)

bot.use(greet)
```

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](https://botmesh.afaya.com.co/guide/concepts.md#when-simple-responses): a string, a list, a regex, a
predicate, or `WELCOME`, `IMAGE` and the other [special triggers](https://botmesh.afaya.com.co/guide/triggers.md).

## Register and start flows

| Call | What it does |
| - | - |
| `bot.use(flow)` / `bot.use([a, b])` | Register flows; each one starts when its trigger matches. |
| `bot.registerFlow(flow)` | Same, and returns the flow id (to start or replace it later). |
| `bot.trigger(id, { chatId })` | Start a flow from code: a scheduled task, a webhook, an admin action. See [Starting a flow programmatically](https://botmesh.afaya.com.co/guide/messages.md#starting-a-flow-programmatically). |
| `bot.abortConversation(ctx)` | Cancel the conversation in progress for that user (what `/reset` does). Takes a `ctx` or `{ from, sender, isGroup }`. |

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`](https://botmesh.afaya.com.co/guide/plugins#built-in-slashcommands)) always gets a user
out.

## DSL methods

### say — send a message

```ts theme={null}
flow('x').say('text')                                // fixed text
flow('x').say((_ctx, c) => `Hi ${c.state.name}`)      // dynamic, reads c.state
flow('x').say('Thinking…').say('Done!', { delay: '2s' }) // pause 2s before sending
```

`{ 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](https://botmesh.afaya.com.co/guide/messages.md#typing-indicator-and-reply-delays) 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

```ts theme={null}
flow('x').ask('What is your name?', 'name')  // stores the reply in c.state.name

flow('x').ask('Email?', 'email', {
  validate: (body) =>
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body) ? true : '❌ Invalid email, try again',
  maxAttempts: 3,
  timeout: '2m',
})
```

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

| Option | Default | What it does |
| - | - | - |
| `validate(body, msg)` | none | Return `true` to accept, or an error text to send before asking again. May be async. |
| `schema` | none | A zod-compatible schema; the **parsed** value is stored (see below). |
| `maxAttempts` | `3` | Answers the wait accepts in total, the first one included. `Infinity` = unlimited. |
| `retries` | | The same budget counted after the first answer: `retries: 2` is `maxAttempts: 3`. Pass one of the two. |
| `otherwise` | the `validate` or schema message | Text sent when an answer is rejected without a message of its own. |
| `timeout` | `conversation.timeout` (`'5m'`) | How long to wait for this answer. |
| `filter(msg)` | none | A message that does not pass is not taken by the wait: it goes on to the other handlers and costs no attempt. |
| `delay` | none | Pause before sending the question (flow steps only). |

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](https://botmesh.afaya.com.co/guide/i18n.md#built-in-messages)):

```ts theme={null}
import { z } from 'zod'

flow('registro')
  .ask('How old are you?', 'age', { schema: z.coerce.number().min(18, 'You must be 18 or older') })
  .say((_ctx, c) => `Next year you will be ${c.state.age + 1}`)   // c.state.age: number
```

`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

```ts theme={null}
flow('x').do((ctx, c) => {
  c.state.registeredAt = Date.now()
})
```

### media — send media

```ts theme={null}
flow('x')
  .media('image', 'https://picsum.photos/400/300', 'Our menu')
  .media('document', './invoice.pdf', 'Your invoice', { filename: 'invoice.pdf' })
  .media('voice', './greeting.ogg')
  .media('location', { lat: 4.71, lng: -74.07 }, 'Bogotá')
```

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

```ts theme={null}
flow('confirmar')
  .choose('Confirm your order?', { si: 'Yes', no: 'No' })
  .say((_ctx, c) => c.state.choice === 'si' ? '✅ Confirmed' : '❌ Cancelled')
```

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

```ts theme={null}
flow('menu')
  .choose('What are you interested in?', {
    ofertas: { label: 'Offers 🏷️', then: (_ctx, c) => { c.state.interest = 'ofertas' } },
    soporte: { label: 'Support 🛠️', then: (_ctx, c) => { c.state.interest = 'soporte' } },
  })
  .say((_ctx, c) => `Got it: ${c.state.interest}`)
```

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

```ts theme={null}
const menu = flow('menu').choose('Main menu', { pedidos: 'Orders', ayuda: 'Help' })

flow('ayuda').choose('Help topics', {
  envios: { label: 'Shipping', then: flow('envios').say('We ship in 48h') },
  volver: { label: '⬅️ Back', goto: menu },
})
```

### timeout — inactivity expiration

```ts theme={null}
flow('soporte')
  .timeout('60s')
  .ask('Describe your issue', 'issue')
```

`.timeout()` only sets the wait duration — it takes no message argument.
The text sent when it expires is configured once, globally, on the bot:

```ts theme={null}
const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  conversation: { timeoutMessage: '⏰ Session expired due to inactivity.' },
})
```

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

```ts theme={null}
flow('a').say('Redirecting you...').goto('b')
```

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

```ts theme={null}
const address = flow('direccion').ask('Street?', 'street').ask('City?', 'city')

flow('pedido')
  .say('Shipping details')
  .use(address)
  .say((_ctx, c) => `Shipping to ${c.state.street}, ${c.state.city}`)
```

`.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](#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

```ts theme={null}
flow('admin')
  .guard((ctx) => admins.includes(ctx.sender), '⛔ This menu is for admins only')
  .guard(() => isOfficeHours(), { redirect: 'fuera-de-horario' })
  .say('Admin menu')
```

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

```ts theme={null}
flow('registrar').persistent().ask('Name?', 'name').say('Done')
```

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

```ts theme={null}
const bot = new Bot({
  provider: new BaileysProvider({ session: 'my-bot' }),
  store: new MemoryStore({ file: './store.json' }), // or a real store — see the Store guide
})
```

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

```ts theme={null}
flow('soporte').scope('dm').ask('Describe your issue', 'issue')
```

### 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)](https://botmesh.afaya.com.co/guide/forms.md).

## `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:

```ts theme={null}
flow('x')
  .ask('Name?', 'name')
  .do((_ctx, c) => { c.state.name = c.state.name.trim() })
  .say((_ctx, c) => `Hi ${c.state.name}`)
```

### 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>`:

```ts theme={null}
const f = flow<{ plan: 'free' | 'pro' }>('alta')
  .ask('Age?', 'age', { schema: z.coerce.number() })
  .choose('Pay how?', { card: 'Card', cash: 'Cash' }, 'payment')
  .do((_ctx, c) => {
    c.state.age      // number
    c.state.payment  // 'card' | 'cash'
    c.state.plan     // 'free' | 'pro'
  })
```

A key that is not a string literal cannot be tracked; those values stay
reachable as `unknown`.

## Complete example: registration

```ts theme={null}
const register = flow('registrar')
  .say('📝 *Let us register you*')
  .ask('Name?', 'name')
  .ask('Email?', 'email', {
    validate: (body) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body) ? true : '❌ Invalid email',
  })
  .say((_ctx, c) => `✅ ${c.state.name} <${c.state.email}>`)

bot.use(register)
```

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

```ts theme={null}
bot.conversation('quiz', async (c, ctx) => {
  let score = 0
  for (const [question, answer] of [['2 + 2?', '4'], ['Capital of France?', 'paris']]) {
    const reply = await c.ask(question, { timeout: '1m' })
    if (reply.trim().toLowerCase() === answer) score++
  }
  await ctx.reply(`Score: ${score}/2`)
}, { trigger: 'quiz' })
```

Without `{ trigger }`, start it with `bot.trigger('quiz', { chatId })` or
`c.goto('quiz')` from another conversation. The `c` object:

| Member | What it does |
| - | - |
| `c.ask(text, opts?)` | Send `text` and wait for a text answer. Resolves with the answer (the parsed value with `schema`). A native button or list tap counts as an answer: its text is the button id or selected title (`ctx.body`). |
| `c.waitText(opts?)` | Wait for a text message (or a button/list tap, as with `ask`), without a prompt. |
| `c.choose(prompt, options, opts?)` | Send a numbered menu and resolve with the chosen key. |
| `c.waitMedia(types?, opts?)` | Wait for media and resolve with the message. By default `image`, `video`, `audio`, `document` or `sticker`; voice notes only when listed (`['voice']`). Download the bytes with [`loadMediaOf()`](https://botmesh.afaya.com.co/guide/messages#access-media-content). |
| `c.waitLocation(opts?)` | Wait for a shared location and resolve with its `InboundLocation` (see [Receive a location](https://botmesh.afaya.com.co/guide/messages.md#receive-a-location)). |
| `c.waitAny(opts?)` | Wait for any message. |
| `c.say(text)` | Send text (same as `ctx.reply`, typed for the conversation). |
| `c.state` | Plain object kept for the whole conversation (persisted with `.persistent()` flows). |
| `c.retry(message?)` | Repeat the last wait. See [below](#c-retry--repeat-the-last-wait). |
| `c.idle(duration, fn)` / `c.idleClear()` | Inactivity watchdog. See [below](#c-idle--inactivity-inside-a-conversation). |
| `c.goto(name, args?)` | Leave this conversation and start another one. |
| `c.end(farewell?)` | End the conversation, optionally sending a last message. |
| `c.t(key, params?)`, `c.lang` | Translate in the user's current language. See [i18n](https://botmesh.afaya.com.co/guide/i18n.md#conversations). |

`opts` takes the [wait options](#ask)
(`timeout`, `validate`, `maxAttempts`, `otherwise`, `filter`, `schema`).

> [!WARNING]
>
> **Do not catch the control signals**
>
> `c.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.

```ts theme={null}
bot.conversation('photo', async (c, ctx) => {
  const img = await c.waitMedia(['image'], { timeout: '2m' })
  await ctx.react('📸')
  await ctx.reply(`Received (${img.type})`)
})
```

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:

```ts theme={null}
bot.conversation('hangman', async (c, ctx) => {
  let letter = await c.ask('Type a letter')
  while (letter.length !== 1) letter = await c.retry('❌ Just one letter')
  await ctx.reply(`✅ ${letter}`)
})
```

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

```ts theme={null}
flow('game')
  .ask('Type a letter', 'letter')
  .do((_ctx, c) => {
    if (String(c.state.letter).length !== 1) c.retry('❌ Just one letter')
  })
  .say((_ctx, c) => `✅ ${c.state.letter}`)
```

* 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

```ts theme={null}
bot.conversation('game', async (c, ctx) => {
  c.idle('2m', async () => {
    await ctx.reply('⏰ Disconnected for inactivity')
    c.end('👋 See you!')               // ends the conversation (sends the farewell)
  })
  await ctx.reply('Type a letter')
  const letter = await c.waitText({ timeout: '10m' })
  c.idleClear()                         // disarm it once they answered
})
```

* 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

```ts theme={null}
bot.onIdle('10m', async (ctx, { chatId, idleMs, lastActivityAt }) => {
  await ctx.reply('Still there? 👀')        // ctx = the chat's last processed message
})
const off = bot.onIdle('1h', (ctx) => bot.send(ctx.from, 'Closing your session for inactivity'))
off()                                        // stop watching and cancel its timers
bot.clearIdle(chatId)                        // cancel a chat's pending timers (all chats without an argument)
```

* 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](https://botmesh.afaya.com.co/guide/handoff.md): 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 `goto`s, unreachable steps, duplicate triggers and more. See
[Inspect & lint flows](https://botmesh.afaya.com.co/guide/flow-tools.md).

## Next steps

* [Flow examples](https://botmesh.afaya.com.co/examples/registration-flow.md) — full recipes
* [Special triggers](https://botmesh.afaya.com.co/guide/triggers.md) — WELCOME, message types and CALL
* [Hooks](https://botmesh.afaya.com.co/guide/hooks.md) — intercept messages
* [Plugins](https://botmesh.afaya.com.co/guide/plugins.md) — package functionality


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.