Human handoff & access lists
Two gates that decide whether the bot answers at all:
- Human handoff pauses the bot in one chat while a person (an agent, a CRM inbox) takes over.
- Blacklist / whitelist drops messages from given senders or chats before the bot even stores them.
Both are on the Bot (and on pluginCtx.bot inside plugins).
Human handoff
const bot = new Bot({ provider, store, handoff: { allowCommands: ['/bot'] } })
bot.when('talk to a human', async (ctx) => {
await ctx.handoff({ reason: 'user request', meta: { queue: 'sales' } })
await ctx.reply('Connecting you with an agent…') // the handler that hands off can still reply
})
bot.command('bot', async (ctx) => { await bot.resume(ctx.from); await ctx.reply('🤖 I am back') })
bot.on('handoff', ({ chatId, reason, since, meta }) => crm.assign(chatId, reason))
bot.on('resume', ({ chatId, at, reason, handoff }) => crm.close(chatId))
bot.on('message', (ctx) => { if (bot.isHandedOff(ctx.from)) crm.relay(ctx.from, ctx.body) })
// The agent answers outside the pipeline (a string is sent as text)
await bot.send(chatId, 'Hi, I am Ana 👋')
await bot.resume(chatId, { reason: 'ticket closed' })
bot.isHandedOff(chatId) // boolean
bot.getHandoff(chatId) // { chatId, since, reason?, meta? } | undefined (a copy)
bot.listHandoffs() // HandoffState[]The state is per chat (ctx.from): a handoff in a group pauses the bot for every member of that group, but not their DMs.
What happens while a chat is handed off
- Messages are persisted,
before/after('message')hooks run (abeforehook can stillabort()), and the'message'event fires, so an inbox integration sees everything. - Commands, conversations, dormant checkpoints, matchers and the catch-all do not run, and
'command:unknown'is not emitted. Interactions (button and list taps) are blocked too. - Calls into the chat are blocked completely: no
before/after('call')hooks, no'call'event and noCALLtrigger (counted inmetrics().dropped.handoff). - Escape commands (
handoff.allowCommands): with or without the prefix, compared like commands (case and accents ignored). They only pass when abot.command()with that name is registered; otherwise they stay blocked and never reach the catch-all. Make that command callbot.resume(...)rather than start a flow. bot.onIdletimers are cancelled and suspended for the chat until it is resumed.
When the handoff starts
- Every active conversation in the chat is cancelled silently (no
cancelledMessage): all participants, whatever theconversationScope. Its dormant checkpoints are deleted from memory and from the store. - If
ctx.handoff()is called inside a flow.do(), the flow ends: later steps do not run. In flow stepsctxis typed asRuntimeMessage, so usebot.handoff(ctx.from)there.
Return values
handoff()/resume()returnPromise<boolean>:truewhen the state changed. They are idempotent: a second handoff neither changes the reason nor emits an event.- The state changes synchronously, so you do not need to
awaitit before the next line. - They never reject because of store errors; those go to the
'error'event. They throw aTypeErrorsynchronously on an empty chat id. ctx.handoff()resolvesfalsewhen the context was not built by a bot.- An agent's
bot.send()goes through thebefore/after('send')hooks and the outbound ledger like any other send. It does not resume the bot.
Persistence
- The state is stored in
StoredChat.handoff = { since, reason?, meta? }(set tonullon resume), plus a small index of handed-off chat ids under the store cursor keyHANDOFF_INDEX_KEY. bot.start()restores it with one read per handed-off chat (stores without cursors fall back tolistChats()). No'handoff'event fires on restore. It also drops the dormant checkpoints of every handed-off chat, so an old flow never resumes afterbot.resume().- A failing restore is reported on
'error'and never failsstart(). Aresume()called while the restore is still running waits for it. - It survives restarts with a persistent store (SQLite, Redis, Postgres, MySQL, Mongo, or
MemoryStorewithfile).handoff: { persist: false }keeps it in memory only.metamust be JSON-serializable. bot.deleteSubject(userId)forgets the handoff of that user's chat without events.
Blacklist and whitelist
bot.blacklist.add('573001234567@s.whatsapp.net') // EXACT ids, as they arrive in ctx.sender / ctx.from
bot.blacklist.add(['a@s.whatsapp.net', '1203630@g.us']) // a group chat id blocks the whole group
bot.blacklist.remove('a@s.whatsapp.net')
bot.blacklist.has(id); bot.blacklist.list(); bot.blacklist.size; bot.blacklist.clear()
bot.whitelist.add(['vip@s.whatsapp.net', '1203630@g.us']).enable() // whitelist mode
bot.whitelist.disable(); bot.whitelist.enabledAccess rules
- Both lists are checked against the sender id and the chat id, with exact equality: the core normalizes nothing. On WhatsApp (Baileys) use the full JID, not the bare phone number.
- The blacklist wins. An enabled but empty whitelist blocks everyone. The whitelist is disabled by default.
- A whitelisted user passes in any group; a whitelisted group lets all its members through.
checkAccess(blacklist, whitelist, { chatId, senderId })returns the same decision ('allowed' | 'blacklisted' | 'not-whitelisted') for your own code.
What happens to a blocked message
It is dropped before persistence: the message, chat and contact are not stored, no hooks, commands, conversations or dormant resumes run (the checkpoint stays as it is), and 'message' is not emitted. The drop is debug-logged with { gate, chatId, senderId }, never the body. It applies to messages, interactions and calls (a blocked call runs no 'call' hooks, emits no 'call' event and reaches no CALL trigger); reactions, poll votes, presence and group events are not filtered. Blocked messages and calls count in metrics().dropped.blocked.
Blocking someone does not cancel a conversation already in progress (their replies just stop arriving). Use bot.abortConversation(...) if you need that.
Saving the lists
The lists live in memory; persisting them is up to you:
import fs from 'node:fs'
function save() { fs.writeFileSync('acl.json', JSON.stringify(bot.exportAccessLists())) }
bot.blacklist.onChange(save)
bot.whitelist.onChange(save)
bot.loadAccessLists(JSON.parse(fs.readFileSync('acl.json', 'utf8')))
// format: { blacklist: { ids: [...] }, whitelist: { ids: [...], enabled: true } }add/removetake one id or an array and throwTypeErroron an empty or non-string id (validated before anything changes).onChangeonly reports effective changes;loadalways reports.loadAccessListsalso accepts plain arrays of ids for each list.
Next steps
- Core concepts: where each gate runs
- Hooks
- Control plane