Core Concepts
Understand how the Dispatcher decides what happens to each incoming message, and the pieces you compose a bot from.
The inbound pipeline
Every incoming message goes through the same pipeline, in this order. The first four steps run in the Bot as soon as the provider emits the event; the rest run inside the Dispatcher, on a FIFO queue per chat (two messages from the same chat are never processed in parallel). The first thing that consumes the message wins and stops the routing:
flowchart TD
A[Message in] --> ACL{"Blacklisted /<br/>not whitelisted?"}
ACL -->|Yes| X1[Dropped: not persisted, no hooks,<br/>not recorded as seen]
ACL -->|No| DD{"Already seen?<br/>(dedupe: chatId + id)"}
DD -->|Yes| X0[Dropped]
DD -->|No| P[Persist chat, contact, message]
P --> GM{"groupMode filter"}
GM -->|Ignored| X2[Stops here]
GM -->|OK| L["Resolve ctx.lang (i18n)"]
L --> B["before('message') hooks<br/>(ctx.lang re-resolved if a hook rewrote ctx.body)"]
B --> HO{"Chat handed off<br/>to a human?"}
HO -->|"Yes (except allowCommands)"| J
HO -->|No| C{"Command? e.g. /reset"}
C -->|Yes| D[Command handler]
C -->|No| E{Active conversation<br/>for this participant?}
E -->|Yes| F[Conversation / flow step continues]
E -->|No| R{Dormant persistent-flow<br/>checkpoint?}
R -->|Yes| F
R -->|No| G{"Matchers by specificity<br/>(WELCOME first)"}
G -->|Yes| H[Handler runs]
G -->|"No match / ctx.pass()"| I[onMessage catch-all]
D --> J["after('message') hooks"]
F --> J
H --> J
I --> J
J --> K["'message' event emitted"]
- Blacklist / whitelist: a blocked sender or chat is dropped silently, before anything else. It is not persisted, no hooks run, no
'message'event fires and it is not recorded as seen by dedupe, so a redelivery after un-blocking is processed. See Handoff & access lists. - Inbound dedupe: a redelivered message or interaction (same
chatId- id, from webhook retries or a Baileys offline resync) is dropped. On by default; see Inbound dedupe. With durable processing on (opt-in), the event is then written to the inbox journal before anything else runs, acknowledged once its conversation steps settled, and replayed from step 1 after a crash.
- Persistence: the chat, contact and message are upserted in the store.
groupModefilter: a group message the bot must ignore stops here (it is still persisted).- Language resolution, then
before('message')hooks: when i18n is enabled,ctx.langis resolved first (storedctx.userpreference → provider-reported language →detect→ default), so hooks and handlers already see it (ctx.langSourcesays which one won). Then the hooks run and mayabort()the message. If a hook rewrotectx.body(e.g. the@botmesh/voicetranscript) and the language came fromdetector the default, it is resolved again sodetectsees the final text (actx.langa hook set itself is kept). - Handoff gate: while a chat is handed off to a human, steps 7–11 are skipped (except the commands in
handoff.allowCommands). The message is still persisted, hooks still run and'message'still fires. - Commands (
/reset,/help, ...) always win, even over an active conversation. That's what lets/resetescape a stuck flow. - Active conversation: if this participant already started a
floworbot.conversationthat's waiting for a reply, the message goes there. - Dormant resume: if a
.persistent()flow was checkpointed in the store (e.g. before a restart), the flow resumes at the saved step. - Matchers registered with
bot.when(...), tried by specificity:WELCOME(first contact only), then text triggers (an exact string/equalsmatch beatsstartsWith, a regex orcontains), then predicate functions, then single-type triggers (IMAGE,onImage...), then multi-type triggers (MEDIA). With equal specificity, the one registered first wins. bot.onMessage(): the catch-all (alsoaiPlugin). It runs only if nothing above consumed the message.- Finish:
after('message')hooks, then the'message'event (bot.onIdletimers re-arm here).
When the bot runs as one of several replicas (coordination), one more step sits between 4 and 5: the message took a ticket in its chat's cross-replica FIFO when it was queued, and waits there for the chat's turn. Only one replica holds a chat's turn at a time and turns follow ingress order, so the FIFO per chat holds across replicas. A message whose turn cannot be had is dropped and reported as 'error' (its dedupe id is released). The turn is released once the conversation the message fed is waiting again or has ended.
Incoming calls take a separate path: the access-list and handoff gates come first and block a call completely (no 'call' hooks, no 'call' event, no CALL trigger; it counts in metrics().dropped). An admitted call runs the before/after('call') hooks and the 'call' event, then the same language resolution and only CALL triggers. No message hooks, commands or 'message' event.
bot.trigger(name, { chatId }) (start a flow without an inbound message; the deprecated bot.triggerFlow() is a thin wrapper over it with ifActive: 'replace') takes its own path too: the access lists, then the chat's FIFO slot, the handoff gate, the language resolution and the conversation start. No dedupe, persistence, message hooks, matchers or 'message' event.
If a handler throws, the onError('message') hooks run and the error is always emitted on bot.on('error', ({ error, ctx }) => ...), even when no error hook is registered.
Handlers consume by default: once one runs, the pipeline stops there. Call ctx.pass() inside a handler to yield to the next matching handler instead of stopping — this is how low-specificity handlers like onImage/ onType cooperate with a broader when:
bot.when(/.+/, async (ctx) => {
if (ctx.type === 'image') { ctx.pass(); return } // yield to the next handler
await ctx.reply('text received')
})
bot.onType('image', async (ctx) => {
await ctx.reply('image received') // now reached, because the handler above passed
})1. Bot + Provider — the center
import { Bot } from '@botmesh/core'
import { BaileysProvider } from '@botmesh/baileys'
const bot = new Bot({ provider: new BaileysProvider({ session: 'my-bot' }) })
await bot.start()The Bot (from @botmesh/core) is the transport-agnostic engine: it runs the Dispatcher, hooks, flows, conversations and the scheduler. The provider is the transport boundary — @botmesh/baileys connects to WhatsApp; @botmesh/meta (official Cloud API), @botmesh/telegram and the experimental Messenger, Instagram and Discord transports plug into the same engine. All transports feed the bot the same normalized events.
Sends need a running bot: before bot.start() and after bot.stop(), bot.send(), ctx.reply() and ctx.delete() reject with BotNotRunningError and nothing reaches the provider (a 'ready' listener may already send). Presence hints (ctx.typing(), ctx.markRead(), the automatic typing indicator) are best-effort instead: while the transport is disconnected or stopped they are skipped without error, logged at debug and counted in metrics().presenceSkipped. Concurrent start() / stop() calls run one at a time, in call order; each stop() of a running bot emits 'stopping' before the drain and the transport stop, then 'stopped'. An event that breaks the provider contract is dropped and reported on 'error' as an InvalidProviderEventError; an event of a kind the core does not know (a newer provider) is ignored.
2. when — simple responses
bot.when('hola', async (ctx) => {
await ctx.reply('👋 Hello!')
})When the message matches the trigger, run this function. The default match strategy is normalized equals: lowercased, accents stripped, spaces collapsed — 'hola' matches "hola"/"Hóla ", but NOT "/hola" or "hola amigo". Triggers accept text, a list, a regex, a predicate, or an explicit { pattern, match, scope } object:
bot.when('hola', handler)
bot.when(['bye', 'goodbye'], handler)
bot.when(/\d{4,}/, handler)
bot.when({ pattern: 'factura', match: 'contains' }, handler) // v1-style "contains"3. flow / conversation — multi-step conversations
const register = flow('registrar')
.ask('Name?', 'name')
.say((_ctx, c) => `Hi ${c.state.name}`)
bot.use(register)A flow compiles to a real async conversation: the bot asks, the user answers, c.state stores it and the flow continues — with validation, retries, branching (choose) and timeouts. For full manual control you can skip the DSL and write bot.conversation(name, async (c, ctx) => {...}) directly.
4. Hooks — middleware (before / after / onError)
bot.before('message', async (ctx, next) => {
console.log('📥', ctx.from, ctx.body)
await next()
})
bot.after('message', async (ctx) => {
console.log('📤 processed')
})
bot.onError('message', async (ctx, error) => {
console.error('💥 error:', error)
})Hooks run before or after each message, like Express middleware. 'message' is the main hook event; 'send' fires on every outbound send. A before hook that returns without calling next() stops the event, like ctx.abort().
5. Plugins — packaged functionality
bot.plug(slashCommandsPlugin, { greeting: 'Hi!' })A plugin is a reusable package that can register hooks, handlers and tools. bot.plug() returns an unplug() function that reverts what it registered through ctx.bot (handlers, hooks, nested plugins, runtime flows and onDispose callbacks; listeners, tools, locales and scheduled jobs need an onDispose, see Plugins).
A plugin is plugged once per bot: plugging a plugin whose name is already plugged throws, unless every instance sets multiple: true in its spec.
Inbound dedupe
Webhook transports (Cloud API, Messenger, Instagram, Telegram webhook) retry a delivery when they don't get a 2xx in time, and Baileys can redeliver recent messages after a reconnect. Without protection, the same message would start a flow twice, or be consumed as the answer to the next question of a conversation. The Bot drops redeliveries before they reach any handler. It is on by default:
new Bot({ provider }) // default: { size: 1000 }, no TTL
new Bot({ provider, dedupe: { size: 5000, ttl: '1h' } }) // tune it
new Bot({ provider, dedupe: false }) // turn it off- A message is keyed by
chatId+ref.id, because some transports only make ids unique per chat (e.g. Telegrammessage_id). An interaction (button or list tap) is keyed by itsinteractionId, so two taps on the same menu are two events. - Edits: the same id with a different body (Telegram
edited_message) is processed; an identical retry is dropped. Only a hash of the body is kept, never the text. - Events with an empty id are never deduplicated.
- The access lists run first: a message or interaction from a blocked sender or chat is never recorded, so the same id redelivered after un-blocking is processed (and counts as
blocked, notduplicate, while still blocked). - If persistence fails before the handlers run (store down), the key is released so the transport's retry is processed.
Echo filter
Every provider marks its own messages with fromMe, and the core drops them before anything else. A few transports only learn their own id after a handshake, or a mailbox/gateway can hand back a message the bot just sent without that mark. echoFilter complements fromMe (it does not replace it): the bot remembers the refs of its successful sends and drops an inbound message whose ref is remembered, before the access lists, dedupe and the journal, so an echo never counts as incoming traffic.
new Bot({ provider, echoFilter: true }) // 1000 refs, no TTL
new Bot({ provider, echoFilter: { size: 5000, ttl: '10m' } }) // tune itDrops count in metrics().dropped.echo (Prometheus: botmesh_dropped_total{reason="echo"}) and only when the option is on; without it the bot behaves exactly as before. The ring is in memory (per process) and each remembered ref is consumed by the first matching echo; a later redelivery is the deduper's business.
- A message still queued behind its chat's earlier messages when
bot.stop()runs is dropped unprocessed and its key is released, so the same id redelivered afterbot.start()is processed. sizeis an LRU;ttl(ms or'30s'/'10m'/'1h') counts from the first acceptance.- Limit: it lives in process memory. It does not deduplicate across replicas or after a restart.
Providers must give each inbound event an id that names exactly one logical event, stays the same when it is redelivered, and is '' when the transport cannot guarantee that. This is the inbound id contract, documented on IProvider. The deduper itself (InboundDeduper, inboundDedupeKey…) is an engine internal (@botmesh/core/internal).
'message' event vs onMessage()
These look similar but are not the same:
bot.on('message', ctx => ...)fires for every message the Dispatcher finished processing — including ones a command, an active conversation, or awhenhandler already consumed.bot.onMessage(ctx => ...)is the catch-all — it only runs when nothing else consumed the message.
Group conversations are per-participant by default
In a group, two different participants talking to the bot in the same chat get independent conversations by default (keyed internally by ${from}:${sender}). Use conversationScope: 'chat' if you want a single shared conversation per group chat instead:
const bot = new Bot({
provider: new BaileysProvider({ session: 'my-bot' }),
conversationScope: 'chat',
})The ctx object — your best friend
bot.when('hola', async (ctx) => {
console.log(ctx.body) // message text
console.log(ctx.from) // chat id (opaque; a JID on WhatsApp/Baileys)
console.log(ctx.sender) // sender id (= from in DMs, participant in groups)
console.log(ctx.type) // 'text', 'image', 'audio', ...
console.log(ctx.isGroup) // is it a group?
await ctx.reply('text')
await ctx.photo(url, 'caption')
await ctx.poll('Question?', ['A', 'B'])
await ctx.react('👍')
const db = ctx.tools.db // injected tool
await ctx.user.set('lang', 'en') // persistent per-user profile
})Chat and sender ids
ctx.from (chat) and ctx.sender (user) are opaque strings the provider resolves — the core never sniffs their shape. For the Baileys transport they happen to be WhatsApp JIDs (Jabber IDs):
| Type | Format | Example |
|---|---|---|
| User | number@s.whatsapp.net | 573001234567@s.whatsapp.net |
| Group | id@g.us | 120363xxx@g.us |
| Newsletter | id@newsletter | 120363xxx@newsletter |
You don't need to memorize this — ctx.from gives you the correct id on every transport. If you need the transport-native key of the inbound message (to quote/edit/delete it with provider calls), use ctx.ref ({ id, chatId, native? }).
Durations
Every duration option takes a duration string ('500ms', '30s', '2m', '1h', '1d', '1w', '5 minutes') or a number of milliseconds. That includes every option whose name ends in Ms: typing.minMs, durability.inbox.backoff.baseMs, coordination.turnWaitMs, scheduler.tickMs and the rest.
const bot = new Bot({
provider,
typing: { minMs: '800ms', maxMs: '3s' },
durability: { pollMs: '1s', outbox: { inlineMaxMs: '15s', backoff: { baseMs: '1s', capMs: '1m' } } },
})new Bot() checks every duration option of BotConfig. An invalid value throws a ConfigValidationError whose field is the option's path, so the message tells you which option to fix:
ConfigValidationError: Invalid config.durability.pollMs: expected a duration ('500ms', '30s', '10m', '1h') or milliseconds, got string ("soon")parseDuration() exposes the same grammar.
Logger
BotConfig.logger (and the logger option of every provider and store) takes a LoggerLike: pino, console, or any object with debug, info, warn and error. The packages always call it pino style, so a custom logger only has to handle two shapes:
logger.warn('provider reconnected') // a message
logger.error({ err, chatId }, 'dispatch error') // context object first, then the messageAn error always goes under the err key. The packages never put an object after the message (pino would drop it), and child(bindings) is optional.
Package entries
| Import from | For |
|---|---|
@botmesh/core | Applications: the Bot, the flow DSL, the message types, the options and the errors you can catch. |
@botmesh/core/testing | Tests: createTestBot, MockProvider, the chaos kit, golden transcripts, crash simulation and MemoryCoordinator. |
@botmesh/core/spi | Authors of stores, durability backends, schedule stores, coordinators, tracer integrations and transports: KvDurability, MemoryKv, MemoryScheduleStore, messageKey, toInboundLocation, the record types… Covered by semver. |
@botmesh/core/dev | The botmesh dev terminal simulator. |
@botmesh/core/internal | Engine classes (Dispatcher, ConversationRuntime, HookSystem…). No semver guarantee: do not use it from an application. |
Next steps
- Messages & media — all reply types
- Conversation flows — complex conversations
- Hooks — middleware and logging
- Special triggers — WELCOME, message types and CALL
- Handoff & access lists — pause the bot for a human, block senders
- Examples — practical recipes