@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
#privatefields onBot. A plugin receivesObject.create(bot)asctx.bot; its methods run withthisset to that object, and a#privateread through it would throw. The kernel is reached throughthis, 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 reachCALL 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
messageandsendhook contracts. - Plugins: what a plugin-scoped bot tracks and undoes.