Skip to main content
How @botmesh/core is put together: the package layers, the modules behind the Bot class, the path a message takes in and out, and where the opt-in features (durability, multi-replica coordination, the durable scheduler, tracing) plug in. You don’t need any of this to write a bot. It is for people who extend the core, write a channel or store, or debug what happens between the provider and a handler.

Layers

The framework is a pnpm monorepo. Dependencies only point down: The core is transport-agnostic. It knows nothing about JIDs, QR codes or webhooks: chat, user and message ids are opaque strings it never parses, and the provider resolves identity aliases (LID/PN on WhatsApp, sender_chat on Telegram) before it emits an event, so the same person always arrives with the same senderId. Everything channel-specific lives in the channel package.

Inside the Bot

Bot is a thin facade: its methods carry the public documentation and delegate. The engine lives in packages/core/src/core/bot/: Three rules keep this working:
  • No #private fields on Bot. A plugin receives Object.create(bot) as ctx.bot; its methods run with this set to that object, and a #private read through it would throw. The kernel is reached through this, so through the prototype.
  • Late-bound seams. Modules call the bot back through closures (bot.emit, bot.trigger, bot.isHandedOff…) read at call time, so a spy, a subclass override or a plugin-scoped override installed later is honoured.
  • Behaviour goes in a module, not in the facade. Each module stays under 500 lines and opens with a comment saying what it does and what it does not.

Inbound pipeline

Dashed boxes only exist when their feature is configured. The routing stages inside the Dispatcher, in order, are described in Core concepts. Two entry points skip parts of this path: calls go through the same access and handoff gates and only reach CALL type triggers, and bot.trigger() goes through the access lists, then takes the chat’s queue slot (runExclusive), the handoff gate and language resolution, and starts the conversation directly — no dedupe, persistence, message hooks or matchers.

Outbound pipeline

ctx.edit() takes the same path with edit on the hook payload. The live previews of ctx.stream() (drafts and in-place edits) run only the before('send') hooks; the messages a stream finally sends take the full path. bot.enqueue() (durability only) runs the before('send') hooks, journals the send and returns at once; the outbox sweeper delivers it.

Lifecycle

stop() closes inbound and cancels broadcasts, emits 'stopping' (plugins cancel long work there, e.g. @botmesh/ai aborts its model calls), stops or pauses the steps that produce new work (scheduler, durability sweeper), optionally drains in-flight sends (stop({ drain: true })), closes paced sends and aborts conversations (checkpoints are kept), then stops the remaining steps in reverse order (the storage step first lets sends still in flight and the ledger rows of sends the provider already accepted land, up to 5 s, before it closes the store). If start() fails, the steps already started are rolled back in reverse order and the error is rethrown. An opt-in feature adds a lifecycle step; it does not scatter ifs through the start and stop code.

Where the opt-ins plug in

All of them are off by default. A bot that does not configure them runs exactly the code path described above without the dashed boxes.

See also

  • Core concepts: the full routing order and conversations.
  • Hooks: the message and send hook contracts.
  • Plugins: what a plugin-scoped bot tracks and undoes.