# botmesh (@botmesh/core and friends) > Transport-agnostic messaging bot framework for Node.js. The core (@botmesh/core) > runs any IProvider: @botmesh/baileys (WhatsApp Web via Baileys 7, unofficial), > @botmesh/meta (official WhatsApp Cloud API), @botmesh/telegram, @botmesh/messenger > (Facebook Messenger), @botmesh/instagram (Instagram DMs), the self-hosted > WhatsApp gateways @botmesh/evolution (Evolution API) and @botmesh/wppconnect, > @botmesh/discord, @botmesh/twilio (WhatsApp + SMS), @botmesh/gupshup, > @botmesh/gohighlevel and @botmesh/email, plus an experimental TikTok transport. > Flows for beginners (flow().say().ask()) compile to an async conversation > runtime (bot.conversation with c.ask/c.waitText/c.choose). Inbound pipeline: > fromMe > echoFilter (opt-in) > blacklist/whitelist > dedupe > persist > groupMode > before hooks > handoff > gate > commands > active conversation > dormant persistent-flow resume > > matchers (WELCOME first, then normalized-equals text by specificity, then > type triggers) > catch-all > after hooks + 'message' event; first consumer > wins. Constructing a Bot REQUIRES a provider: > new Bot({ provider: new BaileysProvider({ session }) }). > ESM-only, Node >= 22 (scaffolded projects require Node >= 22.12). Start a project > with `npm create botmesh` or `pnpm add @botmesh/core @botmesh/baileys`. > Release line: @botmesh/core 2.x, the other public packages 1.x (currently the beta: > core 2.0.0-beta, the rest 1.0.0-beta). botmesh is in beta: a plain install gets the > beta, and `@beta` always resolves to the newest one (`latest` is moved by hand after > each publish). > A bot module default-exports defineBot((provider) => new Bot({ provider })) so > `npx botmesh dev bot.js` (terminal simulator) and `node bot.js` (real provider inside > if (isMainModule(import.meta))) share it. ## Docs - Docs site: https://botmesh.afaya.com.co/ (English under /guide/, Spanish under /es/guide/). Sources: https://github.com/andresayac/botmesh/tree/main/docs - API reference (TypeDoc, grouped by topic): https://botmesh.afaya.com.co/api/core — covers @botmesh/core, @botmesh/core/spi and @botmesh/core/testing (/internal is not documented: no semver). Other packages: their README on npm (https://www.npmjs.com/package/@botmesh/) - README: https://github.com/andresayac/botmesh#readme - Troubleshooting (real errors and fixes): https://botmesh.afaya.com.co/guide/troubleshooting - Upgrading: stable API surface https://botmesh.afaya.com.co/guide/stable-migration · behaviour changes https://botmesh.afaya.com.co/guide/migration-2 · dependencies https://botmesh.afaya.com.co/guide/migration · channels & plugins https://botmesh.afaya.com.co/guide/upgrade-channels-plugins · from bot-whatsapp/BuilderBot v1 https://github.com/andresayac/botmesh/blob/main/docs/MIGRATION-v1-to-v2.md ## Guide pages (English; Spanish mirrors live under https://botmesh.afaya.com.co/es) - [Quick start: simulator, WhatsApp, Telegram, troubleshooting](https://botmesh.afaya.com.co/guide/getting-started) - [Your first bot](https://botmesh.afaya.com.co/guide/first-bot) - [Core concepts: pipeline, dispatcher, identity, dedupe](https://botmesh.afaya.com.co/guide/concepts) - [Configuration reference: every BotConfig option, durations, lifecycle events, logging (logIds), catchable errors](https://botmesh.afaya.com.co/guide/configuration) - [Packages and versions: the 36 public packages, beta/latest dist-tags, private packages, entry points](https://botmesh.afaya.com.co/guide/packages) - [Upgrade to stable: ordered checklist from the 2.0 alphas, moved imports, deprecated names](https://botmesh.afaya.com.co/guide/stable-migration) - [Architecture: core modules, inbound/outbound pipelines, where the opt-ins plug in](https://botmesh.afaya.com.co/guide/architecture) - [Channels overview: choosing a channel, capability matrix, cross-channel contract](https://botmesh.afaya.com.co/guide/channels) - [WhatsApp via Baileys (@botmesh/baileys)](https://botmesh.afaya.com.co/guide/baileys) - [WhatsApp Cloud API (@botmesh/meta)](https://botmesh.afaya.com.co/guide/whatsapp-cloud-api) - [Evolution API (@botmesh/evolution)](https://botmesh.afaya.com.co/guide/evolution) - [WPPConnect (@botmesh/wppconnect)](https://botmesh.afaya.com.co/guide/wppconnect) - [Twilio (@botmesh/twilio)](https://botmesh.afaya.com.co/guide/twilio) - [Gupshup (@botmesh/gupshup)](https://botmesh.afaya.com.co/guide/gupshup) - [GoHighLevel (@botmesh/gohighlevel)](https://botmesh.afaya.com.co/guide/gohighlevel) - [Messenger (@botmesh/messenger)](https://botmesh.afaya.com.co/guide/messenger) - [Instagram (@botmesh/instagram)](https://botmesh.afaya.com.co/guide/instagram) - [Telegram (@botmesh/telegram)](https://botmesh.afaya.com.co/guide/telegram) - [Discord (@botmesh/discord)](https://botmesh.afaya.com.co/guide/discord) - [Email (@botmesh/email)](https://botmesh.afaya.com.co/guide/email) - [TikTok (experimental, private package)](https://botmesh.afaya.com.co/guide/tiktok) - [Messages & media, broadcasts, bot.trigger()](https://botmesh.afaya.com.co/guide/messages) - [Proactive messages: bot.send, broadcast, bot.trigger, schedule/every/delay, onIdle, enqueue](https://botmesh.afaya.com.co/guide/proactive-messages) - [Conversation flows (flow() DSL, bot.conversation)](https://botmesh.afaya.com.co/guide/flows) - [Special triggers (WELCOME, media types, CALL)](https://botmesh.afaya.com.co/guide/triggers) - [Forms (WhatsApp Flows)](https://botmesh.afaya.com.co/guide/forms) - [Persistent user data (ctx.user)](https://botmesh.afaya.com.co/guide/user-data) - [Internationalization](https://botmesh.afaya.com.co/guide/i18n) - [Human handoff & access lists](https://botmesh.afaya.com.co/guide/handoff) - [Hooks (middleware)](https://botmesh.afaya.com.co/guide/hooks) - [Plugins](https://botmesh.afaya.com.co/guide/plugins) - [AI plugin (@botmesh/ai)](https://botmesh.afaya.com.co/guide/ai) - [AI tool calling](https://botmesh.afaya.com.co/guide/ai-tools) - [AI long-term memory & RAG](https://botmesh.afaya.com.co/guide/ai-memory-rag) - [Streaming replies (ctx.stream)](https://botmesh.afaya.com.co/guide/streaming) - [Voice: speech-to-text and text-to-speech (@botmesh/voice)](https://botmesh.afaya.com.co/guide/voice) - [Integrations (Chatwoot, tickets, moderation, Dialogflow, ...)](https://botmesh.afaya.com.co/guide/integrations) - [Dependency injection (bot.provide / bot.unprovide), in the Plugins guide](https://botmesh.afaya.com.co/guide/plugins) - [Scheduler](https://botmesh.afaya.com.co/guide/scheduler) - [Rate limiting (anti-ban)](https://botmesh.afaya.com.co/guide/rate-limiting) - [Persistent stores](https://botmesh.afaya.com.co/guide/store) - [Control plane (@botmesh/manager: BotManager, /v1 REST API, OpenAPI, JSON flows)](https://botmesh.afaya.com.co/guide/control-plane) - [Webhook server (@botmesh/server)](https://botmesh.afaya.com.co/guide/server) - [WhatsApp Cloud API 24h window and pricing](https://botmesh.afaya.com.co/guide/session-window) - [Operations: bot.metrics(), Prometheus, conversation events, ops packages](https://botmesh.afaya.com.co/guide/operations) - [Tracing: BotConfig.telemetry, span catalogue, @botmesh/otel, Jaeger / Grafana Tempo](https://botmesh.afaya.com.co/guide/tracing) - [Durable processing: inbox/outbox, replay, sweeper](https://botmesh.afaya.com.co/guide/durability) - [Multiple replicas: coordination, chat turns, hand-over](https://botmesh.afaya.com.co/guide/replicas) - [JSON flow documents (schema, expressions, migrations, publish gate)](https://botmesh.afaya.com.co/guide/flow-schema) - [Security: webhook authentication, untrusted surfaces, audit](https://botmesh.afaya.com.co/guide/security) - [Threat model: assets, trust boundaries, threats T1-T12 and residual risks](https://botmesh.afaya.com.co/guide/threat-model) - [MCP server (@botmesh/mcp): validate/analyze/migrate/diff flow documents, docs search](https://botmesh.afaya.com.co/guide/mcp) - [Terminal simulator (botmesh dev)](https://botmesh.afaya.com.co/guide/dev-simulator) - [Platform sandbox (local simulators of every channel + web chat)](https://botmesh.afaya.com.co/guide/sandbox) - [Testing (createTestBot with a config or a defineBot factory, MockProvider, chaos)](https://botmesh.afaya.com.co/guide/testing) - [Inspect & lint flows (bot.describe/bot.lint)](https://botmesh.afaya.com.co/guide/flow-tools) - [Golden transcripts](https://botmesh.afaya.com.co/guide/golden-transcripts) - [ESLint plugin (eslint-plugin-botmesh)](https://botmesh.afaya.com.co/guide/eslint) - [Sub-APIs: v1 sub-APIs → v2 capabilities and provider.socket](https://botmesh.afaya.com.co/guide/sub-apis) - [Going to production (checklist)](https://botmesh.afaya.com.co/guide/production) - [Deployment](https://botmesh.afaya.com.co/guide/deploy) - [Redis Cluster](https://botmesh.afaya.com.co/guide/redis-cluster) - [Migrating to stable: API surface (canonical names, entry points)](https://botmesh.afaya.com.co/guide/stable-migration) - [Upgrade notes: channels & plugins](https://botmesh.afaya.com.co/guide/upgrade-channels-plugins) - [Migrating to stable: behaviour (English defaults, retries/maxAttempts, sends need a running bot)](https://botmesh.afaya.com.co/guide/migration-2) - [Migrating to stable: dependencies](https://botmesh.afaya.com.co/guide/migration) - [Coming from v1 (bot-whatsapp/BuilderBot): mapping and behaviour changes](https://botmesh.afaya.com.co/guide/migration-v1) - [Troubleshooting: install errors, 428 handshake, LID identity, webhook signatures, rate limits](https://botmesh.afaya.com.co/guide/troubleshooting) - [FAQ](https://botmesh.afaya.com.co/guide/faq) - Examples (each one complete bot.js with a tested transcript; index: https://botmesh.afaya.com.co/examples): beginner: [greeting bot](https://botmesh.afaya.com.co/examples/greeting-bot), [FAQ bot](https://botmesh.afaya.com.co/examples/faq-bot), [send messages](https://botmesh.afaya.com.co/examples/messages), [menu bot with choose() and buttons](https://botmesh.afaya.com.co/examples/menu-bot), [poll bot](https://botmesh.afaya.com.co/examples/poll-bot), [media replies](https://botmesh.afaya.com.co/examples/media), [reminder bot with delay/cron](https://botmesh.afaya.com.co/examples/reminders), [registration flow](https://botmesh.afaya.com.co/examples/registration-flow); intermediate: [shopping flow](https://botmesh.afaya.com.co/examples/shopping-flow), [support with timeout](https://botmesh.afaya.com.co/examples/support-flow), [rate-limiter plugin](https://botmesh.afaya.com.co/examples/rate-limiter), [appointment booking](https://botmesh.afaya.com.co/examples/appointment-booking), [order tracking](https://botmesh.afaya.com.co/examples/order-tracking), [human handoff](https://botmesh.afaya.com.co/examples/human-handoff), [multi-language bot](https://botmesh.afaya.com.co/examples/multi-language), [group bot](https://botmesh.afaya.com.co/examples/group-bot), [WhatsApp and Telegram at once](https://botmesh.afaya.com.co/examples/whatsapp-and-telegram) - AI examples (each a runnable bot.js, tested): [FAQ assistant with guardrails](https://botmesh.afaya.com.co/examples/ai-faq), [booking with confirmed tools](https://botmesh.afaya.com.co/examples/ai-tools-booking), [RAG over your FAQ](https://botmesh.afaya.com.co/examples/ai-rag), [streaming on Telegram](https://botmesh.afaya.com.co/examples/ai-streaming), [intent router to flows](https://botmesh.afaya.com.co/examples/ai-router), [human escalation](https://botmesh.afaya.com.co/examples/ai-handoff) - Advanced examples (each runs in the test suite): [Cloud API webhook server](https://botmesh.afaya.com.co/examples/cloud-api-webhook), [production-ready bot](https://botmesh.afaya.com.co/examples/production-bot), [scheduled daily digest](https://botmesh.afaya.com.co/examples/daily-digest), [testing your bot](https://botmesh.afaya.com.co/examples/testing-your-bot), [build a plugin](https://botmesh.afaya.com.co/examples/faq-plugin); index: https://botmesh.afaya.com.co/examples ## Package status (45 packages) Public = published on npm and covered by semver (beta versions until the stable release, see above). Experimental = real, tested implementation but private/unpublished, API may change. Prototype = private, minimal, in-memory sketch; not for production. Public (36 packages): - @botmesh/core — Bot engine, IProvider v2, flows/conversations, hooks, plugins, scheduler, DI, MemoryStore, ctx.user, handoff, access lists, i18n, ctx.stream, bot.broadcast/trigger/metrics, golden transcripts, testing kit (createTestBot/MockProvider/ChaosProvider/golden transcripts/MemoryCoordinator from '@botmesh/core/testing'), `botmesh dev` terminal simulator ('@botmesh/core/dev'). Entries: '@botmesh/core' = what an app needs; '@botmesh/core/spi' = for authors of stores, durability backends (KvDurability, MemoryKv, KvInbox/KvOutbox, computeBackoff), schedule stores (MemoryScheduleStore, isScheduleClaimable, applyScheduleComplete), leases (MemoryLeaseProvider), coordinators, tracer integrations and transports (toInboundLocation, messageKey, form helpers), with semver; '@botmesh/core/internal' = engine classes (Dispatcher, ConversationRuntime, HookSystem, Scheduler, createPacer…), no semver — never import it from app code - @botmesh/baileys — BaileysProvider (WhatsApp Web, unofficial; QR or pairing code via pairingPhoneNumber, deprecated alias pairingCode), RateLimiter, toJid (phone → chat id), isEditedMessage, LID/PN identity resolution. Native values (raw, ref.native, provider.socket, provider.auth) are typed as opaque unknown aliases (BaileysNativeMessage, BaileysSocket, BaileysAuthState); no Baileys type is in the main entry's .d.ts (it type-checks with skipLibCheck: false; importing @botmesh/baileys/internal pulls in Baileys' .d.ts, which needs skipLibCheck: true). JID helpers, IdentityStore, toInbound/toOutbound, media/reconnect/session helpers and socketOf(provider)/authStateOf(provider) live in @botmesh/baileys/internal (no semver; the main entry keeps @deprecated aliases except toInbound/toOutbound); debug is deprecated (pass a logger); poll votes (pluggable pollStore). Protocol stanzas (reactions, deletes, decryption placeholders) are never emitted as messages; a text edit is emitted as a message with the original ref.id and the new text (isEditedMessage(ctx); editedMessages: 'ignore' drops them; only edits by the original's author, in its chat, of a message still in the provider's cache are delivered); reconnect: boolean | { maxAttempts, baseMs, capMs }; toJid throws on input without digits and on { groupId, userId }; ctx.voice/ctx.sticker accept URLs and streams (SSRF-checked download, capped by remoteMedia.maxBytes) and stickers carry pack/author; polls need 2-12 unique options (else UnsupportedContentError); invalid pairingPhoneNumber/browser throw TypeError in the constructor; with pnpm 11 deny the '@whiskeysockets/baileys' and protobufjs build scripts (allowBuilds: false). Built on @whiskeysockets/baileys 7.0.0-rc14, pinned exactly (no stable 7.x exists upstream as of October 2026; 6.x is the legacy line); each new candidate ships only after the full suite incl. LID identity tests passes (README: Upstream upgrade policy). Voice notes are transcoded by spawning ffmpeg (FFMPEG_PATH, else the optional peer ffmpeg-static when its binary exists, else ffmpeg on the PATH; missing → UnsupportedContentError); no bundled ffmpeg - @botmesh/meta — CloudAPIProvider (official WhatsApp Cloud API, webhook; native buttons/lists/templates/WhatsApp Flows forms; 24h session window manager (sessionWindow) and pricing usage (provider.usage); feed inbound via provider.ingest(rawBody, headers), which answers 503 while stopped so Meta redelivers; accessToken/phoneNumberId/verifyToken are validated at construction; every Graph request times out after requestTimeoutMs, default 60000; media uploads after uploadTimeoutMs, default 300000; phoneNumberId may be a safe integer; interactive limits (>=1 button, unique button ids/titles after the 20-char cut, 1-10 list rows with unique ids) reject with UnsupportedContentError before sending; labels are cut on grapheme boundaries; an interactive reply that is not a button/list press arrives as an unknown message; apiVersion defaults to DEFAULT_GRAPH_API_VERSION from @botmesh/graph-core = 'v26.0' (same default in messenger/instagram; pin apiVersion to stay on a version); the 'played' voice-note status is reported as status 'read' with platformData.played: true; lang (default 'en', set it to BotConfig.lang) is the language of the default submit label publishForm() bakes into a Flow, publishForm(def, { lang }) overrides it; session-window API: manager config/queries (SessionWindowManager constructor/forScope/state/isOpen/expiresAt/pending/clearPending, SessionWindowOptions, SessionWindowStore, MemorySessionWindowStore) are semver-covered, while manager.send/noteInbound/noteFailedStatus and RawSend are @experimental provider plumbing; deprecated: toProviderError (use toGraphError from @botmesh/graph-core), GraphClient, the verifyChallenge/verifySignature re-exports, debug) - @botmesh/telegram — TelegramProvider (Bot API, long-poll or webhook; native drafts/edits for ctx.stream; edited_message is delivered as a message with the original ref.id, isEditedMessage(ctx) detects it, editedMessages: 'ignore' drops edits; self-hosted Bot API server via baseUrl + mediaDownload { allowHttp, allowPrivateHosts, maxBytes }; webhook mode requires webhookSecret and ingest(body, headers) takes Buffer/Uint8Array, JSON string or object with plain or Fetch Headers in any case; group commands /cmd@BotName reach bot.command('cmd') (username from getMe, or the username option with selfId); requestTimeoutMs (default 120000) bounds every request, long polls get pollTimeoutMs (default 30000; pollTimeout in seconds is deprecated) + 15 s, file uploads get uploadTimeoutMs (default 300000); ingest() after stop() answers 503 and emits nothing; /cmd@BotName in a media caption is handled like text; 429 retry_after is capped at 24 h; errors and logs never carry the token; bot.stop() cuts poll-loop retry waits; malformed Updates are skipped and logged, never thrown; polls are sent non-anonymous and each poll_answer becomes a 'poll' event with the Baileys shape { ref, senderId, options: [{ name, votes, voters }] } for polls this process sent (last 500), a channel refuses public-voter polls, so the poll is retried once as anonymous and the channel remembered; anonymousPolls: true keeps anonymous polls; error mapper toTelegramError (toProviderError deprecated); debug is deprecated, pass logger) - @botmesh/messenger — MessengerProvider (Facebook Messenger Send API, public from the beta; webhook: provider.verifyChallenge(query) for the GET handshake, provider.ingest(rawBody, headers) for POSTs (503 while stopped so Meta redelivers), appSecret required (X-Hub-Signature-256) unless allowUnsignedWebhooks; postback buttons (max 3), generic-template lists (max 10 rows) and cards; quick replies/postbacks arrive as button interactions; a list sends its text before the generic template, button text past 640 chars goes first as plain text (no text is cut); a shared link (fallback attachment) is a text message (title + URL); several attachments in one message → one message each (ids mid, mid#2…); message_edits → a message with the original id, isEditedMessage(ctx); message tags via send options platformData { tag, messaging_type, notification_type, persona_id } (other keys ignored, wrong type → TypeError); inbound reactions (message_reactions); typing and mark_seen; media only by public URL; text capped at 2000 chars (split by the core); errors via toMessengerError → RateLimitedError/OutsideSessionWindowError/RecipientUnreachableError; wire helpers parseInbound/toOutbound/toOutboundMessages in '@botmesh/messenger/internal' (no semver); debug deprecated, pass logger) - @botmesh/instagram — InstagramProvider (Instagram DMs via the Messenger API for Instagram, public from the beta; professional account connected to a Facebook Page, Page access token; same webhook contract as Messenger (verifyChallenge, ingest, appSecret); buttons as quick replies (max 13, titles cut on graphemes to 20, unique ids/titles, non-empty text, else UnsupportedContentError), lists degrade to numbered text; media captions are sent as a following text message and a card imageUrl as an image after the card text, or alone when the card has no text (a failed extra part is logged, the send still resolves; a card with neither text nor image throws UnsupportedContentError); a DM with several attachments yields one message per attachment (ids mid, mid#2, mid#3…); selfId marks the account's own messages fromMe; ingest answers 503 while stopped so Meta redelivers; apiVersion ('vNN.N') and graphUrl (http(s)) are validated at construction, undefined keeps the defaults; DMs only; text capped at 1000 UTF-8 bytes (split by the core); errors via toInstagramError; GraphClient/parseInbound/toOutbound in '@botmesh/instagram/internal' (no semver); InstagramSendResponse is the send raw shape; debug deprecated, pass logger) - @botmesh/discord — DiscordProvider (gateway websocket + REST; buttons, select menus, slash commands as interactions, acknowledged by the core once within ~3 s, autoAcknowledgeMs backstop default 2000; GatewayIntents/DEFAULT_INTENTS (frozen), add GatewayIntents.DirectMessages for DMs; MESSAGE_UPDATE edits arrive as messages with the original ref.id, isEditedMessage(ctx), editedMessages: 'ignore'; one delivery per edited_timestamp; one bot reaction per message (reactionMemory LRU); error mapper toDiscordError (no alias); allowedMentions default { parse: ['users'] } (echoed @everyone/@here/roles never ping; SendOptions.mentions, platformData.allowed_mentions and native payloads override; quoted replies keep replied_user: true unless allowedMentions.replied_user is false); REST ids percent-encoded, empty/./.. id rejects ProviderError; a message without text or file is 'sticker' or 'unknown', never empty text; poll votes not delivered; gateway socket, REST client, mappers and payload types in @botmesh/discord/internal, no semver; debug deprecated) - @botmesh/twilio — TwilioProvider (Programmable Messaging: WhatsApp + SMS/MMS in one class; X-Twilio-Signature over the exact webhookUrl, ingestRequest({ url, body, headers }) for per-request URLs, or webhookPath + Host/X-Forwarded-* headers with ingest(body, headers, requestUrl) keeping the request's query string; the constructor rejects whitespace in accountSid/authToken/API key/webhookUrl and a whatsapp: smsFrom; chatId = Twilio From as is; media by public URL only; buttons/lists as numbered text; template → ContentSid + ContentVariables; 63016 → OutsideSessionWindowError; NumMedia > 1 → one message per attachment (ids MessageSid, MessageSid:i); TwilioApiError/toTwilioError, computeTwilioSignature/verifyTwilioSignature; parser/mapper in @botmesh/twilio/internal, no semver) - @botmesh/gupshup — GupshupProvider (Gupshup WhatsApp API: v1 send endpoints, v2 callbacks authenticated by a shared webhookToken in the URL query or a header (Gupshup does not sign): ingestRequest({ url, body, headers }) or ingest(body, headers, url) (the @botmesh/server ingress passes the url); buttons → quick replies (≤3, unique titles) → list (≤10, listButtonText default 'Options') → numbered text (URL buttons keep the link); labels cut to WhatsApp limits (titles 20/24, descriptions 72, footer 60); template via /template/msg; chatId = sender phone digits; GupshupApiError/toGupshupError; parser/mapper in @botmesh/gupshup/internal, no semver) - @botmesh/gohighlevel — GoHighLevelProvider (HighLevel/LeadConnector Conversations API; InboundMessage webhooks verified with Ed25519 X-GHL-Signature against the bundled GHL_WEBHOOK_PUBLIC_KEY (publicKey overrides); chatId = contactId across SMS/WhatsApp/Email/FB/IG/Live chat, replies on the contact's last channel (defaultChannel until known; channel names match in any casing); Email HTML bodies arrive as plain text in ctx.body (ctx.raw.body keeps the HTML; chat.platformData.subject/threadId/emailMessageId); accessToken without the Bearer prefix, validated at construction; template → templateId; GoHighLevelApiError/toGhlError, verifyGhlSignature; parser/mapper in @botmesh/gohighlevel/internal, no semver) - @botmesh/email — EmailProvider (IMAP IDLE inbound or ingest(rawMime) from an inbound-mail webhook, SMTP outbound; imapflow and nodemailer are optional peers loaded lazily (a missing one → ProviderError naming it, from start() for imapflow, from the first send for nodemailer); each thread is a chat (threading: 'thread' | 'sender'), cursorThreadStore(store) persists threads and the mailbox UID cursor; loop protection, bounce handling (status 'failed', hard-bounce suppression), requireAuthentication { authservId }; toEmailError (toEmailProviderError deprecated); MIME/address/loop/bounce helpers in @botmesh/email/internal, no semver) - @botmesh/sqlite-store — SQLiteStore (better-sqlite3 peer dependency, WAL; schema version in PRAGMA user_version (SQLITE_SCHEMA_VERSION), ordered migrations on open, version 2 includes the schedules/schedule_fences/durable tables, a file from a newer release is refused with SQLiteSchemaVersionError code SQLITE_SCHEMA_TOO_NEW; busyTimeoutMs default 5000; logger: Partial) - @botmesh/redis-store — RedisStore (shared state across replicas; atomic cross-replica upserts; acquireLockFenced/renewLock/releaseLock (acquireLock and the seconds-based ttl option were removed: use acquireLockFenced and sessionTtl)) + RedisCoordinator (multi-replica coordination: store.coordinator() or new RedisCoordinator({ url, prefix })). Redis Cluster (opt-in): pass a node-redis cluster client or `cluster: { rootNodes }` → key layout 'cluster' (hash-tagged groups, index sets instead of SCAN; same names otherwise unchanged); migrate an existing keyspace offline with migrateToClusterLayout({ source, target? }). Key-layout internals (storeKeys, bucketOf, encodeSegment, hashSlot, INDEX_BUCKETS, LAYOUT_MARKER) live in @botmesh/redis-store/internal (no semver). Guide: https://botmesh.afaya.com.co/guide/redis-cluster. `redis` (node-redis) is a peer dependency: ^4.7 || ^5 || ^6 (RESP2 or RESP3); the default key prefix 'botwa' is permanent (existing data lives under it) - @botmesh/evolution — EvolutionProvider (Evolution API v2, self-hosted WhatsApp Web gateway on Baileys: REST with the apikey header, webhooks into provider.ingest(body, headers) → HTTP status, authenticated with webhookSecret (header x-webhook-secret or Bearer) or webhookJwtKey (Evolution jwt_key, HS256 JWT checked); LID/PN twins reconciled before emitting, identityPersistence keeps ids stable across restarts; polls (2–12 unique options, else UnsupportedContentError), reactions, text edits, typing, read receipts; buttons/lists/cards degrade to numbered text; connection.update 'refused' (QRCODE_LIMIT reached) → closed/sessionInvalid, call start() again; keep webhookByEvents off (it suffixes the URL); deleteMessage without a native key assumes fromMe: true; error mapper toEvolutionError (an instance without a WhatsApp session, never paired or logged out ('Connection Closed') → ProviderError 'not connected to WhatsApp'); invalid options throw TypeError). Wire types (EvolutionMessage, EvolutionWebhook…) are @experimental; parser, outbound mapper and JWT helpers live in @botmesh/evolution/internal (no semver) - @botmesh/wppconnect — WPPConnectProvider (wppconnect-server REST gateway on WA-JS: Bearer token or secretKey → token generated on start() and regenerated once on 401; await provider.ingest(body, headers, requestUrl) because the webhook token travels in the query string (?token=…); unknown LIDs resolved via contact/pn-lid before emitting; markRead marks the whole chat; connection from status-find webhooks: isLogged/qrReadSuccess → ready, notLogged/qrcode → authenticating, disconnectedMobile → closed sessionInvalid, inChat ignored; a session not started/paired rejects ProviderError 'WPPConnect session is not connected'; bot.stop() does not close the server session; error mapper toWppError; invalid options throw TypeError). Wire types (WppMessage, WppSender, WppId) are @experimental; parser and outbound mapper live in @botmesh/wppconnect/internal (no semver) - @botmesh/postgres-store — PostgresStore ({ connectionString | pool, tablePrefix (≤ 42 chars, [A-Za-z0-9_]), maxMessagesPerChat, purgeBatchSize, logger }; using it before start() fails with 'not started', even with an injected pool (which stays usable after stop(), so bots can share one store / PostgresDurability); JSONB tables created on start(); store.schedules() → PostgresScheduleStore; PostgresDurability / PostgresDurableKv ({ pool | connectionString, tablePrefix }) for BotConfig.durability; ctx.user values in user_data TEXT round-trip exactly, U+0000 included; the JSONB tables store U+0000 / lone surrogates as U+FFFD). pg ^8.13 is a peer dependency loaded only for an own pool; NativePostgresPool is structural (no @types/pg needed) - @botmesh/mysql-store — MysqlStore ({ uri, host/port/user/password/database (override the matching parts of uri) | pool, tablePrefix (≤ 43 chars), ... }; a malformed uri throws a TypeError that never repeats it; start() names host:port when the server is unreachable; ids over 1024 bytes and ctx.user values of 16 MiB or more fail with a RangeError; MySQL 8.x and MariaDB; VARBINARY byte-exact keys; store.schedules() → MysqlScheduleStore; MysqlPoolLike / MysqlConnectionLike / NativeMysqlPool; a statement picked as a deadlock victim is retried up to 5 times; MariaDB rejects JSON nested deeper than 32 levels). mysql2 ^3.11 peer dependency loaded only for an own pool; no durability backend - @botmesh/mongo-store — MongoStore ({ url | client, dbName, collectionPrefix, ... }; mongodb ^6.10 || ^7 peer dependency, the own client is built on start(); store.schedules() → MongoScheduleStore (single-document atomic commands, standalone mongod works); deleteChat is one transaction on a replica set, two deletes on a standalone; ids entering filters must be strings). No durability backend - Store semantics: messageKey(ref) = `{chatId}:{id}` with ':' and '%' percent-escaped (ids with ':' such as Twilio's whatsapp:+1… never collide; rows from older versions under legacyMessageKey(ref) are still read/deleted and moved on re-save); MemoryStore keeps 1000 messages per chat by default (RAM only; maxMessagesPerChat: Infinity disables), persistent stores are unbounded unless maxMessagesPerChat is set; SQL/Mongo stores purge expired ctx.user data in batches (purgeBatchSize, default 1000) - @botmesh/ai — aiPlugin + AIClient (multi-provider LLM autoresponder, tool calling, long-term user memory, RAG hook; aiPlugin({ instance }) injects a ready-built AIProvider such as MockAIProvider) - every provider config takes logger?: LoggerLike (pino, console or any { debug, info, warn, error }; packages call it pino-style: logger.warn('msg') or logger.warn({ err, ...context }, 'msg')); @botmesh/baileys adapts it for Baileys itself (toBaileysLogger() lives in @botmesh/baileys/internal; the main-entry name is deprecated) - create-botmesh — project scaffolder CLI (templates: baileys, meta, telegram, ai, games, support, store, restaurant, clinic, ai-full; every project ships an AGENTS.md; baileys/meta/telegram/ai install slashCommandsPlugin (before aiPlugin) so /reset and /cancel escape the sample menu flow; `npm start` of meta/telegram/ai/ai-full stops with one line naming missing .env variables before building a provider; refuses a target inside the botmesh monorepo itself unless --force, so from a checkout run `node packages/create-botmesh/lib/cli.js ../my-bot`; programmatic API: scaffold, sanitizeName, getTemplate, TEMPLATE_NAMES; TEMPLATES/agentsMd/findBotMeshMonorepo live in create-botmesh/internal, no semver, deprecated on the main entry) - @botmesh/http (secureFetch/fetchBuffer: anti-SSRF downloads; SecureFetchOptions = method/headers/body/signal + allowHttp/allowPrivateHosts/timeoutMs/maxBytes/maxRedirects/resolver/fetch, no longer extends RequestInit and has no `redirect`; a ReadableStream/async-iterable body is streamed (duplex: half set automatically); resolveHost, deprecated alias resolvePublicHost) and @botmesh/graph-core (shared Meta Graph webhook/error helpers; checkGraphSignature({ body, headers, appSecret, logger }), the positional form is deprecated) and @botmesh/wa-gateway-kit (REST client over secureFetch that never follows redirects, outbound media where only http(s) URLs reach the gateway, { url } accepts only http(s) or base64 data: (a path/file: there rejects with UnsupportedContentError) and { path }/plain path strings are read and sent inline, WaIdentityStore LID/PN reconciliation shared by evolution/wppconnect; gatewayBaseUrl rejects credentials/query/fragment with a TypeError) — public dependencies of the channel packages, covered by semver - @botmesh/server — webhook ingress, the recommended way to host webhook channels (raw body bytes for signature checks, body-size limit 413, body/ingest timeouts 408/503, concurrency cap 503 + Retry-After, GET verifyChallenge / POST ingest(raw, headers, path?query) routing (WPPConnect query tokens work as is), ingest() statuses 200-599 (1xx and anything else → 200), absolute-form request targets routed by path, config mistakes throw TypeError "@botmesh/server: …" naming the option, /healthz and /readyz gated by bot.getConnectionState()) for node:http (createNodeHandler), fetch runtimes (createFetchHandler), and the subpaths @botmesh/server/express, /fastify, /hono; no dependencies; normalizePath/splitUrl in @botmesh/server/internal (no semver). Guide: https://botmesh.afaya.com.co/guide/server - @botmesh/events — event envelopes, leased journals (MemoryEventJournal, FileEventJournal, SharedFileEventJournal for several processes on one host), EventWorker retries + dead-letter, fencing tokens (StaleLeaseError) against expired leases; file journals store JSON (a BigInt or circular payload is rejected with a TypeError, nothing stored; a Date reads back as an ISO string; an envelope that is not a valid record, e.g. a NaN/Date occurredAt or a missing partitionKey, is rejected by every journal before anything is stored); EventWorker options and journal times/durations are validated (TypeError); no @botmesh/core needed; @botmesh/events/durability: eventsDurability({ journal }) as BotConfig.durability.backend (@botmesh/core optional peer) - @botmesh/otel — OpenTelemetry for the core: createOtelTracer() / otelTracer({ api }) / lazyOtelTracer(), otelMetricsPlugin() for bot.metrics(); @opentelemetry/api optional peer loaded lazily (SpanMetrics in @botmesh/otel/internal, no semver). Guide: https://botmesh.afaya.com.co/guide/tracing - @botmesh/rag — createRag (loaders md/txt/json/pdf with the optional pdf-parse peer → RagError MISSING_DEPENDENCY when absent, chunking, embeddings local/openai/ollama or a custom Embedder over secureFetch, in-memory cosine index with JSON persistence; pass it as aiPlugin({ rag })). Helpers (splitMarkdownSections, resolveChunkOptions, cosine, hashText, tokenize, resolveEmbedder, createPdfParser) live in @botmesh/rag/internal (no semver) - @botmesh/voice — voicePlugin (speech-to-text for voice notes: OpenAI/Deepgram, ctx.transcript and ctx.body; text-to-speech replies: OpenAI/ElevenLabs; ctx.tools.voice = VoiceClient; typed VoiceError subclasses). Audio sniffing, resolvers and MIME tables in @botmesh/voice/internal - @botmesh/nlu — local intent matching (IntentEngine, nluPlugin, whenIntent; deterministic token-overlap scoring, {slot} entities, no network). define() throws on phrases with no letters/digits (emoji only), non-string entries, adjacent or duplicate slots; normalization folds case, accents, full-width forms and invisible characters; punctuation splits words, so an id like A-1 does not fill one slot; unplugging removes ctx.tools.nlu - @botmesh/dialogflow — dialogflowPlugin (dialogflowContext is a deprecated alias): Dialogflow ES/CX detectIntent over REST with a service-account JWT (no Google SDK), whenDialogflowIntent routes intents to handlers or flows (parameters → c.state); languageCode defaults to 'en', sessionScope to 'participant' in groups, texts over maxTextLength (256) are not sent, bad options throw DialogflowError INVALID_CONFIG at plug(), a 401 retries once with a fresh token. Normalizers and JWT helpers in @botmesh/dialogflow/internal - @botmesh/chatwoot — chatwootPlugin (two-way sync with a Chatwoot API inbox, agent replies hand the chat off, resolving resumes the bot; webhook verified by token and/or timestamped HMAC; handleWebhook/webhookHandler). Mapping, verifier and formatters in @botmesh/chatwoot/internal - @botmesh/tickets — ticketsPlugin + TicketDesk (ctx.tools.tickets: priority queue, positions, working hours with time zones, handoffOnAssign/resumeOnClose; store cursor persistence, durableKvTicketStorage for replicas) - @botmesh/moderation — moderationPlugin + Moderator (anti-spam windows, word/regex and invite-link filters, warn/mute/kick/delete with degradation when the provider lacks delete/kick; no bundled provider exposes groups.removeParticipants yet, so kick falls back to mute). Invalid config throws a TypeError/RangeError naming the field at moderationPlugin(); invisible characters and fullwidth forms are folded before matching words and invites. Link/word scanners in @botmesh/moderation/internal - @botmesh/flow-schema — code-free JSON flow documents (schema, validation, safe expressions, compileFlowDocument, exportFlowDocument, migrations, deploy helpers); see "JSON flows" below. Expression engine and step-source metadata in @botmesh/flow-schema/internal (no semver); nested delay/form steps use the semver hooks pauseControls/runFormStep of @botmesh/core/spi - @botmesh/mcp — MCP server over stdio (npx -y @botmesh/mcp, bin botmesh-mcp; --help/--version; no third-party dependencies): flow_validate / flow_analyze / flow_migrate / flow_diff / flow_digest / flow_schema / flow_example over @botmesh/flow-schema + core lintFlows/describeFlows, plus docs_search / docs_page when started with --docs (a local Markdown tree; the guides are not bundled). An invalid document answers valid:false with JSON Pointer issues (isError is only for a bad request: arguments are checked against each tool's inputSchema, unknown keys/enums/ranges fail with a message naming them). Tools are annotated read-only/idempotent; batches (2025-03-26) are answered with an array; serveStream bounds lines with maxMessageLength. Claude Code: claude mcp add botmesh -- npx -y @botmesh/mcp ; Cursor/Claude Desktop: mcpServers { command: 'npx', args: ['-y', '@botmesh/mcp'] }; VS Code: .vscode/mcp.json servers. Main entry: createMcpServer, serveStream, runStdio; framing/tools/docs search in @botmesh/mcp/internal (no semver). Guide: https://botmesh.afaya.com.co/guide/mcp - eslint-plugin-botmesh — ESLint 9+ flat-config rules (no-v1-api, no-await-runtime-enter, no-async-plugin-install, no-empty-trigger, require-timeout-on-ask, prefer-ctx-user-over-global-state) with configs.recommended / configs.strict; eslint is a peer dependency. Guide: https://botmesh.afaya.com.co/guide/eslint Experimental (private, not published): - Channels: @botmesh/tiktok (Business Messaging DMs + comment replies) - @botmesh/manager — BotManager multi-bot registry (persisted per-tenant configs, auto-reconnect) + HTTP control plane: createRestApi (full /v1 REST API with scoped tokens, OpenAPI 3.1, idempotency, rate limiting), plus createControlPlaneApi and the legacy createManagerApi; JSON flow documents (compileFlowDocument) - @botmesh/sandbox — local simulators of the platforms (signed webhooks, realistic errors), a web chat and suites against self-hosted platforms in Docker Product packages (private, persistent over any DurableKv of @botmesh/core: MemoryKv from '@botmesh/core/spi', FileDurability, SQLiteDurableKv, PostgresDurableKv, RedisDurableKv): @botmesh/inbox (conversations, CAS assignment, handoff, notes, SLA, ledger projection), @botmesh/crm (contacts, tags, attributes, consent, segments), @botmesh/campaigns (consent-aware planning, durable per-recipient reservations with TTL, sendCampaign over bot.broadcast), @botmesh/hub (validated plugin manifests, permission-gated enable), @botmesh/deployments (immutable versions, active version, gate reservations, rollback, audited gate runs, golden-transcript publish gate). Shared storage glue: @botmesh/tenant-kv (tenant-scoped JSON documents with backend CAS). Each package: Memory*Store reference + Kv*Store adapter + test-utils/*-store-conformance.ts ## JSON flows - @botmesh/flow-schema (public): versioned code-free documents (v2; a v1 document is migrated on load by validate/compile/flowFromArtifact, its retries: N becoming maxAttempts: N so it keeps its budget; isFlowDocument accepts only v2) validated against a draft 2020-12 schema (GET /v1/flows/schema): say, ask, choose, condition, delay, goto, end, media, form, guard, subflow, http, handoff, ai (tools by id). Safe expressions only (no eval/Function/regex/index/prototype), {{ path }} templates, JSON-pointer validation issues, compileFlowDocument -> CompiledFlow (resolveSubflow, media by URL/stateKey (a state value must be an absolute https URL whose host resolves to public addresses; local paths, private/loopback/metadata hosts and plain http fail the step unless the media: { allowHttp, allowPrivateHosts, resolver } compile option allows them), forms, http via @botmesh/http anti-SSRF with {{ path }} placeholders only in the URL path/query/fragment (percent-encoded), in header values and in JSON body strings; a non-2xx answer fails the step, handoff, ai via ctx.tools.ai with tools: ['id']), exportFlowDocument reverse export (exact round-trip from a document; opaque do/guard -> FlowExportError), migrateFlowDocument (v0->v1->v2, chained), flowDigest/diffFlowDocuments/flowDocumentGate/publishFlow over any registry with a compatible publish() (such as the private @botmesh/deployments). Form field patterns are rejected when they can backtrack catastrophically (nested repetition, alternation in a repeated group, backreferences); a field with a pattern takes answers of at most 1000 characters. ask.validate.expr only sees $answer/$body/$from/$sender/$isGroup (not c.state); dotted state keys (user.name) are readable from templates and expressions. The manager depends on it; subflows resolve against the bot's stored flows (createBot/updateBot/restore too) and removeFlow answers CONFLICT while another stored flow uses it as a subflow. ## API essentials (v2) - import { Bot, flow } from '@botmesh/core'; import { BaileysProvider } from '@botmesh/baileys' - new Bot({ provider: new BaileysProvider({ session, qr: { terminal: true } }) }) ; await bot.start() (sends and ctx.delete() before start()/after stop() reject with BotNotRunningError; concurrent start/stop run in call order; each stop() of a running bot emits 'stopping' (before the drain and the transport stop; plugins cancel long work there) then 'stopped'; a malformed provider event is dropped and reported on 'error' as InvalidProviderEventError, an event of an unknown string kind (newer provider) is ignored; presence hints (ctx.typing/recording/markRead, the automatic typing indicator) are best-effort: skipped without error while the transport's last connection state is not 'ready' or the bot is stopped, logged at debug, counted in metrics().presenceSkipped { typing, markRead }; BotConfig.logIds 'hash' (default) | 'raw' | 'omit' controls chat/sender ids in the bot's log lines; BotConfig.rateLimitRetry { retries: 1, maxWaitMs: 5000 } | false retries a ctx send once on a short RateLimitedError; migration notes: https://botmesh.afaya.com.co/guide/migration-2) - bot.when(trigger, handler) — trigger: 'text' (normalized equals) | RegExp | Array | { pattern, match: 'equals'|'contains'|'startsWith'|'endsWith', scope: 'dm'|'group'|'all' } | predicate fn - bot.command('reset', handler) — always beats active conversations - bot.use(flow('kw').say(t).ask(t, stateKey, { validate, maxAttempts, timeout }).choose(prompt, { key: 'Label' }, stateKey).do(fn).timeout('30s').persistent()); waits take maxAttempts (total answers, first included, default 3) or retries (attempts AFTER the first: retries: N = maxAttempts: N + 1; a wait/form/step that sets retries without maxAttempts logs one logger.warn per bot, since alpha code meant N in total; every retries option in botmesh counts after the first); choose({}) / an empty state key / invalid budgets throw when the flow is defined - Flow DSL extras: .use(subFlow) runs a flow inline and returns with shared c.state; choose options { label, then: subFlow | fn, goto: 'menu' | menuFlow }; .guard(pred, 'farewell' | { redirect, message }); .ask(q, key, { schema }) with any zod-compatible safeParse() object (parsed value stored; also c.ask/c.waitText({ schema })); .media(kind, source, caption?, { delay, filename, ptv, gifPlayback, pack, author }) for image|video|audio|voice|document|sticker|location; { delay: '2s' } on say/ask/choose/media and .delay('3s'); typed state: flow(), ask/choose/use infer c.state keys, FlowStateOf - addKeyword is a deprecated alias of flow (use flow). The v1/BuilderBot API (.addAnswer, .addAction, { capture: true }, BaileysClass, createBot/createFlow) does NOT exist — never generate it. - bot.conversation(name, async (c, ctx) => { await c.ask(q); const t = await c.waitText(); const pick = await c.choose(prompt, options) }) - A handler that starts a conversation must not await it (enter() resolves when the conversation ENDS and would block the chat's FIFO queue) - ctx fields: ctx.from (chat id), ctx.sender, ctx.body, ctx.type, ctx.isGroup, ctx.ref (MessageRef { id, chatId, native? }), ctx.raw (native payload), ctx.senderInfo, ctx.chat, ctx.media (lazy: undefined until loaded — call `const media = await loadMediaOf(ctx, { maxBytes? })` from '@botmesh/core' → { buffer, mimetype, size? } | undefined, cached on ctx.media; rejects MediaTooLargeError over maxBytes), ctx.tools - Ids (ChatId/UserId/MessageId) are opaque strings; the provider resolves identity before emitting, the core never parses them - Outbound content is a tagged union: bot.send(chatId, { kind: 'image', media: { url }, caption }) ; bot.send(chatId, 'text') (a string is sent as text) ; bot.broadcast(chatIds, content | 'text', { batchSize, delayBetween, concurrency, retries (maxRetries is a deprecated alias), onProgress, onFail, signal }) → { total, sent, failed, skipped, errors, messages, cancelled, pending } (sent/failed are counts; honours RateLimitedError.retryAfterMs; bot.stop() cancels it) - bot.trigger(name, { chatId, userId?, isGroup?, data, ifActive: 'skip'|'replace', body? }) starts a registered flow/conversation with c.state seeded from data, through access lists, handoff and the chat queue; resolves { started, reason? } when it is entered (not when it ends); safe to await from a handler of the same chat; bot.triggerFlow(id, target, opts) is a deprecated wrapper over it (ifActive: 'replace', resolves result.started); bot.enterConversation(name, ctx) is deprecated too (use bot.trigger); bot.enterConversation(inlineHandler, ctx) starts an unregistered conversation (never await it in a handler) - bot.metrics() / bot.resetMetrics() runtime counters (incl. presenceSkipped { typing, markRead }); toPrometheus(bot.metrics(), { prefix?, labels? }) from '@botmesh/core' (metric names start with 'botmesh_', e.g. botmesh_presence_skipped_total{kind=typing|mark_read}; prefix: 'botwa_' keeps the alpha names); events 'conversation:started' { id, name, chatId, userId, key } and 'conversation:ended' { ...same, outcome: 'completed'|'ended'|'guarded'|'cancelled'|'timedOut'|'retriesExceeded'|'error', completed, durationMs, goto? } (c.end() -> 'ended', a failed .guard() -> 'guarded', return/goto -> 'completed'; counters and Prometheus outcome labels match) - Send failures are typed ProviderError subclasses: UnsupportedContentError, RateLimitedError, RecipientUnreachableError, PayloadTooLargeError, OutsideSessionWindowError (Cloud API 24h window → send kind 'template'); the chat is on error.chatId, default messages never include it - before('send') hook payload: SendHookContext { chatId, content, options?, stream?, edit? } - hook contexts are typed by the event name (HookContextMap: message → MessageContext, send → SendHookContext, call → CallEvent, reaction/poll/group/presence/status → the provider event; before hooks add abort()); a custom event is unknown unless the handler is annotated, a type argument is passed (bot.after<'ev', Ctx>) or HookContextMap is augmented; HookHandler, HookErrorHandler (onError: (ctx, error)) - before hooks are Koa-style (ctx, next): a before hook that returns WITHOUT calling next() stops the event exactly like ctx.abort() (message not routed, send skipped, provider event not emitted); observer/rewriting hooks must always `await next()`; a second next() call keeps the first dispatch and is reported on 'error' - provider-event hooks (reaction, poll, group, presence, status, call): before -> after -> bot.on(event); an aborted before hook suppresses the after hooks and the bot.on event (same as message/send). Calls from blacklisted/non-whitelisted senders or chats, or into a handed-off chat, are fully blocked first: no 'call' hooks, no bot.on('call'), no CALL trigger (counted in metrics().dropped) - Testing without WhatsApp: import { createTestBot, MockProvider } from '@botmesh/core/testing'. createTestBot() -> { bot, provider, chat, dm(userId), group(groupId, { as }) }; chat helpers: send(text, { type? }), sendMedia(kind, bytes | { url }, caption?), sendLocation(lat, lng) / sendLocation(location), sendReaction(emoji, ref?), pressButton(id | title | index) (native buttons, list rows, card buttons), sendInteraction(data, { kind }), expectReply(text | regexp), expectNoReply({ timeout }), replies(), sent(), lastReply(), transcript({ ids?, timestamps?, scope?, as: 'text' }) (a golden Transcript ready for toMatchSnapshot / assertTranscript), chat.as(userId) (same chat, other participant). MockProvider: incomingMedia(kind, bytes, opts), reaction(emoji, { to }), interaction(data, { kind, respondBy }), new MockProvider({ interactionsRequireAck: true }) to test the core auto-ack (Discord-like: one deferred ack before the first send, before respondBy or after routing unless ctx.acknowledge(text) came first; acks recorded in provider.acks), journal (inbound + sends with refs, maxJournal). Vitest matchers: import { botMatchers } from '@botmesh/core/testing/vitest'; expect.extend(botMatchers); toHaveReplied(text | regexp), toHaveRepliedMatching(regexp | (text, content) => boolean), toHaveRepliedTimes(n) over a TestChat, MockProvider, outbox array or string[] (vitest is an optional peer) - Special triggers: import { WELCOME, MEDIA, IMAGE, VIDEO, AUDIO, VOICE_NOTE, DOCUMENT, STICKER, LOCATION, CONTACT, POLL, CALL, typeTrigger } from '@botmesh/core'. bot.when(WELCOME, h) fires on a user's first contact (per DM, per participant in groups; persisted via store cursors); bot.resetWelcome(chatId, userId?) re-arms it (slashCommandsPlugin /reset does too). Type triggers rank below text/function triggers (IMAGE beats MEDIA). bot.when(CALL, ctx => ctx.reply(...)) fires once per incoming call id (ctx.type 'call', ctx.call is the CallEvent) - Per-user profile: ctx.user.get/set/update/delete/all/clear(path, value, { ttl }) with dot paths ('address.city') and namespaces (ctx.user.ns('game')); keyed by ctx.sender, persisted by all 6 stores; bot.user(userId) outside handlers; userOf(ctx) inside flow .do() steps; bot.purgeExpiredUserData(); exportSubject/deleteSubject include userData - i18n: bot.locale('en', { greeting: 'Hello, {name}!', menu: { title: '...' }, cart: { one: '{count} item', other: '{count} items' } }) (nested keys, {param} interpolation, plural forms via params.count) and bot.locale({ defaultLocale, detect, providerLanguage, userKey, locales }) or BotConfig.i18n (locales: allow-list of served languages, e.g. ['en'] pins a single-language bot: anything else falls back to defaultLocale and setLang refuses it); ctx.t(key, params) / ctx.lang / ctx.defaultLang (the bot default locale), c.t / c.lang in conversations (follow the latest message, also across c.goto()); ctx.lang is resolved per message before before('message') hooks: ctx.user 'lang' -> provider language (SenderInfo.language / platformData.languageCode, e.g. Telegram; an update without one reuses the sender's last reported language) -> detect -> default (ctx.langSource: 'user'|'provider'|'detector'|'default'); if a before('message') hook rewrites ctx.body (e.g. a voice transcript) and the language came from detect/default it is re-resolved after the hooks; ctx.setLang(code) persists to ctx.user (rejects codes no registered locale serves, and while i18n is off); bot.t(key, params, lang), bot.langOf(userId), bot.locales(); built-in core messages (timeouts, retries, errors, wait validation incl. schema rejections, slash commands, flow().form() prompts/hints/skip word/validation errors and the native form cta/submit label/body defaults core.form.cta/submit/body under core.form.*; @botmesh/meta toFlowJson(def, { lang }) picks the published Flow's default submit label, default 'en' like the core) are localizable under core.* (CORE_MESSAGES) with es and en defaults; the default locale is 'en' (BotConfig.lang: 'es' switches the built-in texts to Spanish without turning on language resolution; lang and i18n.defaultLocale must agree: same tag, or the same language more or less specific, e.g. 'es' + 'es-MX', where the more specific wins) - Human handoff: ctx.handoff({ reason, meta }) / bot.handoff(chatId) pause routing for a chat (messages still persisted, hooks run, 'message' fires); bot.resume(chatId), bot.isHandedOff/getHandoff/listHandoffs; agents' replies go out with bot.send(chatId, 'text' | content); events 'handoff'/'resume'; BotConfig.handoff { allowCommands: ['/bot'], persist: true }; survives restarts with a persistent store - Access lists: bot.blacklist.add(ids) / bot.whitelist.add(ids).enable() — EXACT sender or chat ids (full JIDs on Baileys); blocked inbound is dropped before persistence; bot.exportAccessLists()/loadAccessLists() + onChange() for host persistence - Conversations: c.retry(message?) repeats the last wait (in flow .do() it rewinds to the previous ask/choose, or re-runs a preceding .use() sub-flow); c.idle(duration, cb) / c.idleClear() for in-conversation inactivity; bot.onIdle(duration, (ctx, { chatId, idleMs }) => ...) and bot.clearIdle(chatId?) for per-chat inactivity. In bot.conversation(name, async (c, ctx) => ...) ctx is a full MessageContext (ctx.tools typed) - Inbound dedupe is ON by default (BotConfig.dedupe { size: 1000, ttl? } | false): redelivered messages/interactions with the same chatId + id are dropped. Access lists run BEFORE dedupe: a blocked delivery is never recorded as seen, so a redelivery after un-blocking is processed. Handler errors always reach bot.on('error'). Optional BotConfig.echoFilter (true | { size?, ttl? }) remembers the refs of successful sends and drops inbound echoes without fromMe before the access lists/dedupe/journal (metrics().dropped.echo, botmesh_inbound_dropped_total{reason="echo"}) - Tracing is OPT-IN (BotConfig.telemetry { tracer: BotTracer, ids?: 'hash'|'raw'|'omit' (default hash, HMAC with idKey), idKey?, errorMessages?: false, spans?: { persist, pace, streamUpdates } }; omitted = no span code runs). Spans: bot.inbound (consumer) -> bot.persist / bot.dispatch (bot.queue.wait_ms, bot.dispatch.stage) -> bot.handler -> bot.conversation.turn (one per stretch between waits; reply turns parent to the answering message's dispatch, timeouts are root spans linked to the first turn) -> bot.pace -> bot.send (client, bot.send.outcome); plus bot.trigger, bot.broadcast; each lifecycle step (storage/coordination/restore/durability/transport/jobs) is a root bot.lifecycle span (bot.lifecycle.step/phase = start|stop|rollback, so a slow provider handshake is visible); @botmesh/ai adds `chat {model}` (gen_ai.* usage/finish reason), `execute_tool {name}` (bot.ai.tool.outcome) and bot.ai.retrieve; each scheduled task run is a root bot.task span (bot.task.id/kind/cron/interval_ms, durable: scheduled_at/missed/fencing_token, bot.scheduler.role leader|follower|single, bot.scheduler.owner). Checkpoints written while tracing carry the turn's W3C context (FlowCheckpoint.trace { traceparent, tracestate? }); a conversation resumed on another replica or after a restart LINKS its first turn to it (bot.link.reason=resume), parent stays the waking message's dispatch; formatTraceparent/parseTraceparent exported. Never records bodies, prompts, tool args/results or error messages. bot.tracing { enabled, trace(name, opts, fn), startSpan, activeSpan, withSpan, recordError, idAttribute } for own spans; TRACE_ATTR / SPAN_NAMES / noopTracer / NOOP_TRACING from '@botmesh/core'; createRecordingTracer() from '@botmesh/core/testing'. OpenTelemetry: @botmesh/otel (public) createOtelTracer() / otelTracer({ api }) / lazyOtelTracer(), span-derived histograms (spanMetrics: inbound/dispatch/queue.wait/send/lifecycle durations, gen_ai duration/tokens), otelMetricsPlugin() / registerOtelMetrics(bot) for bot.metrics() (metric names start with 'botmesh.'; prefix: 'botwa' keeps the alpha names); @opentelemetry/api is an optional peer loaded lazily. Guide: https://botmesh.afaya.com.co/guide/tracing - Durable processing is OPT-IN (BotConfig.durability { backend? (journal? deprecated alias), instanceId?, failOpen?, inbox: { leaseMs, maxAttempts, backoff }, outbox: { retries, backoff, inlineMaxMs, onAmbiguous: 'skip'|'resend', onDiverged: 'send'|'skip', maxPayloadBytes, leaseMs }, pollMs, retention }; omitted = unchanged in-memory behaviour). Inbound events are journaled before dispatch, acked once their conversation steps settle, replayed after a crash; every send goes through an outbox keyed by SendOptions.idempotencyKey or `:` (durable handlers must send in a deterministic order), so replays never re-send. Backends: MemoryDurability (default), FileDurability.open(path, { appendLog?, fsync? }) (core; a corrupt line in the middle of the append log is refused on open with code 'APPEND_LOG_CORRUPT'), PostgresDurability({ pool | connectionString, tablePrefix, logger }) (@botmesh/postgres-store; an own pool reports idle-connection errors to logger.error instead of crashing), RedisDurability({ client, prefix }) (@botmesh/redis-store); custom ones implement DurableKv + KvDurability. Several bots may share one backend (each uses the scope of its namespace). Set an explicit instanceId per replica on Postgres/Redis; replicas need sticky per-chat routing (conversations stay in process memory). bot.durability { inbox, outbox, replayInbound(id), retrySend(key), sweep(), metrics() }; bot.enqueue(chatId, content, { idempotencyKey }) for fire-and-forget durable sends (long text journaled as key, key#2…; a later part never overtakes an earlier one waiting for a retry, and dies with it; waiting costs no retry attempt; parts are linked by OutboxRecord.previousPart, so user keys ending in # stay independent; the sweeper splits a journaled over-limit record the same way); events 'durability:dead' / 'durability:ambiguous'; SendDeferredError when outbox.inlineMaxMs runs out (the sweeper finishes it). Tests: crashable(backend) + CrashableProvider from @botmesh/core/testing. Guide: https://botmesh.afaya.com.co/guide/durability - Durable media references: IProvider.serializeMediaRef(ref) → { v, data } | undefined and rehydrateMediaRef(stored, ref) (optional hooks; all 13 channels implement them: Baileys, Meta Cloud, Telegram, Twilio, Messenger, Instagram, Discord, Evolution (message key, never the inline base64), WPPConnect (messageId), Gupshup and GoHighLevel (hosted http(s) URL), email (Message-ID + index + digest + optional IMAP UID/UIDVALIDITY) and TikTok (media coordinates or sticker URL); the URL-token channels only journal safe http(s) URLs, overridable with `mediaRefHosts` where the provider offers it) let a replayed inbound message download its media; journaled under durability.inbox.mediaRefs (default true; false = tokens stored as before). Media that cannot be rebuilt: the message still runs and loadMediaOf(ctx, { throwOnError: true }) rejects MediaUnavailableError (reason 'not-durable' | 'provider-mismatch' | 'rehydrate-failed'). Baileys skips view-once media unless BaileysProviderConfig.durableMedia.viewOnce. An @botmesh/events journal can be the backend: eventsDurability({ journal }) from @botmesh/events/durability (FileEventJournal.open(path, { lock: true }) for one process, or SharedFileEventJournal.open(path) for several processes on one host: cross-process mutex per write, shared leases/fencing and outbox; bot records live on channel 'botwa:durable', never claimed by EventWorker). FileDurability refuses a second open of the same file in one process until close(). FileDurability.open(path, { appendLog }) and SharedFileEventJournal.open(path, { appendLog }) append one delta line to .log (snapshot carries logSeq) and compact every compactEvery (1000) entries or compactBytes (4 MiB); a leftover log is always replayed on open and a torn tail is cut off. The SharedFileEventJournal mutex refreshes its mtime (heartbeatMs, default staleLockMs/3), so only a stalled holder is broken. IStore.setCheckpointIf/clearCheckpointIf (optional; all six built-in stores) are atomic compare-and-set checkpoint writes used by ConversationRuntime with coordination (FlowCheckpoint.revision; a refused write is LeaseLostError 'superseded'); a custom store without them keeps the local fence and the bot warns on start. - Flow introspection: bot.describe() (Mermaid; { format: 'json' } for a graph), bot.lint({ minSeverity }) → diagnostics (goto-target-missing, unreachable-step, duplicate-trigger, persistent-memory-store, ...); describeFlows/lintFlows work without a Bot - Terminal simulator: export default defineBot((provider) => { const bot = new Bot({ provider }); ...; return bot }) and guard the real start with if (isMainModule(import.meta)) { ... }; then npx botmesh dev src/bot.js (no phone, hot reload; :as, :group, :media, :click, :state, :reset). Programmatic API in '@botmesh/core/dev' (DevSession, DevProvider); the simulator sets BOTMESH_DEV=1 (and the deprecated BOT_WA_DEV=1) in the environment - Forms: flow('signup').form({ id: 'signup', title, fields: [{ type: 'text'|'number'|'email'|'date'|'select'|'multiselect'|'optin', name, label, required, ... }] }, { stateKey, flatten, mode: 'auto'|'native'|'degraded', nativeId, timeout, maxAttempts | retries }) stores validated answers in c.state[stateKey ?? id]. On @botmesh/meta (capabilities.forms 'native') it is sent as a static WhatsApp Flow when one is mapped (CloudAPIProvider config.flows { signup: '' }, provider.publishForm(def), or { nativeId }); otherwise, and on every other provider, the bot asks field by field. Endpoint (data_exchange) Flows are not supported yet - Chaos testing: createTestBot({}, { chaos: { seed, duplicates, reorder, latency, sendFailures, disconnects, aliases } }) or withChaos(provider, opts) → ChaosProvider; assertStableIdentity/checkIdentityContract/recordEvents check the provider identity contract; assertInboundContract/checkInboundContract (epoch-ms timestamps, body, mentions, type) and assertCapabilityContract/checkCapabilityContract (typing() ⇔ capabilities.typing, markRead() ⇔ readReceipts, editMessage() ⇔ edit, valid maxTextLength/maxTextBytes) and assertPresenceWhileDisconnected/checkPresenceWhileDisconnected (typing()/markRead() resolve without contacting the transport while not connected) check the cross-channel contract ## Other exports worth knowing - Hooks/plugins: bot.before/after/onError(event, handler, priority?) (higher priority first), bot.plug(plugin, config) (config typed by the plugin's definePlugin(), unknown options are compile errors; returns unplug(); Plugin.install MUST be synchronous and return nothing — TypeScript rejects an async install (PluginSpec.install returns void | SyncInstallError), in JavaScript an async rejection goes to the error event; PluginSpec.requires: string[] names plugins that must be plugged first, else plug() throws; one instance per plugin name: plugging an already plugged name throws unless every instance sets PluginSpec.multiple: true; plugins go through bot.plug (bot.use(plugin) is deprecated, bot.use is for flows); unplug runs uninstall() then reverts ctx.bot commands/matchers/conversations/hooks/nested plugins/registerFlow flows/onDispose callbacks, but not on()/once() listeners, provide()d tools, locale(), schedule/every/delay or onIdle (undo those in ctx.onDispose)), bot.provide(name, value) → ctx.tools[name] - Built-in plugin: slashCommandsPlugin (/start, /help, /reset, /cancel, /id, /ping); `greeting` replaces the /start and /help text (default: a localized command list); built-in texts are English unless BotConfig.lang (e.g. 'es') or i18n.defaultLocale sets another language - Durations: every duration option takes a string ('500ms', '30s', '2m', '1h', '1d', '1w', '5 minutes') or a number of ms, including every option named *Ms (typing.minMs/maxMs, durability backoff baseMs/capMs, coordination.turnWaitMs, scheduler.tickMs…); an invalid value throws ConfigValidationError at new Bot() with err.field naming the option (e.g. 'durability.pollMs'); parseDuration() exposes the grammar. Timer-backed options (timeouts, autoSaveInterval, stop drain timeout, onIdle) are capped at about 24 days (2^31-1 ms) and throw RangeError above it - Scheduler: bot.schedule(cron, fn), bot.every(interval, fn), bot.delay(duration, fn) (standard cron day rule: day-of-month and weekday are OR-ed when both are restricted; an expression that can never fire, e.g. minute 60, a 0 step or `0 0 30 2 *`, throws a TypeError); cron time zones: bot.schedule(cron, { timeZone: 'America/Bogota' }, fn), { id, timeZone } for durable tasks, BotConfig.scheduler.timeZone as the default (IANA via Intl; DST: a fixed hour in a skipped hour shifts forward once, in a repeated hour runs once at the first occurrence, wildcard hours follow the real clock); durable tasks (opt-in): bot.schedule/every/delay(spec, { id, missed?: 'skip'|'run-once'|'catch-up', maxCatchUp?, leaseMs? }, (run) => …) persist their next run in BotConfig.scheduler.store (the schedules() of SQLiteStore, RedisStore, PostgresStore, MysqlStore or MongoStore, any ScheduleStore; in memory by default), apply the missed-run policy to slots no replica ran in time and claim each occurrence under a fenced lease so replicas never run it concurrently (at least once: use run.occurrenceKey for idempotency); only the schedule is stored, so register durable tasks at boot on every replica (an id no code registers is an orphan; bot.forgetTask(id) deletes it); optional scheduler.lease (SchedulerLease: a LeaseProvider such as RedisStore#leases(), or a Coordinator; defaults to BotConfig.coordination's coordinator) for leadership (followers still claim occurrences left overdue for leaderTtlMs) - Media handlers: bot.onType('image'|'audio'|..., handler) or bot.when(IMAGE, handler) (bot.onImage/onAudio/onVideo/onDocument/onSticker/onLocation/onContact/onPoll are deprecated); catch-all bot.onMessage(handler) - Inbound location: ctx.sharedLocation = { latitude, longitude, name?, address?, live? } on type 'location' (Baileys, Cloud API, Telegram, Messenger, Instagram, Evolution, WPPConnect, Gupshup, Twilio); c.waitLocation(opts?) in conversations resolves an InboundLocation; StoredMessage.location persists it; ctx.location(lat, lng, name?) SENDS one; providers use toInboundLocation(); tests: provider.incomingLocation(loc) / chat.sendLocation(loc) - Provider events forwarded on the bot: bot.on('ready'|'qr'|'connection'|'reaction'|'poll'|'call'|'group'|'presence'|'status', ...); bot events also include 'handoff', 'resume' and 'error' - Poll votes: bot.on('poll', vote => vote.options /* [{ name, votes, voters }] */); on Baileys the provider decrypts votes for polls this process saw (the bot cannot vote) - Cloud API (@botmesh/meta): username users may arrive with only a BSUID (e.g. 'US.1349...') instead of a phone; the provider keeps one stable id per person, learns phone<->BSUID pairs (identityMap) and sends to BSUIDs via 'recipient' - Cross-channel contract: ctx.timestamp / InboundMessage.timestamp / InboundInteraction.timestamp / DeliveryStatus.timestamp are epoch MILLISECONDS on every provider (receive time when the transport sends none; core still converts a seconds value from a third-party provider). Long text: a text send over capabilities.maxTextLength (UTF-16 units) or capabilities.maxTextBytes (UTF-8, Instagram 1000) is split by the core into several messages in order (paragraph > line > sentence > word; never inside a grapheme or the chat markup: code fences/inline code/emphasis/HTML elements are closed and reopened, links/URLs kept whole); before('send') hooks run ONCE on the whole text and what they return is split (abort skips it all); each part is then its own send (after('send'), log, durable outbox), only the first quotes, explicit idempotencyKey gets #2, #3…, result.parts lists every part ref; a part the durable outbox defers (SendDeferredError) gets the remaining parts journaled behind it; bot.enqueue splits into one record per part (key, key#2…); the outbox sweeper keeps the part order and splits a journaled record over the limit (first part under its key, the rest journaled as key#2… first); new Bot({ splitLongText: false }) opts out; splitMessage(text, { maxLength?, maxBytes?, format? }) splits by hand. Limits: WhatsApp Web (Baileys/Evolution/WPPConnect) 65536, Cloud API/Gupshup/Telegram 4096, Discord/Messenger 2000, Instagram 1000 bytes, Twilio 1600, TikTok 6000. Presence while disconnected: typing()/markRead() resolve without contacting the transport while the provider's last connection state is not 'ready' (all 13 providers; IProvider contract). Unsupported operations are absent (no method, flag false; ctx.typing/markRead/delete then no-op): Cloud API has no typing()/deleteMessage(), Telegram/Discord no markRead(). Too many buttons/rows/poll answers reject UnsupportedContentError (Cloud API, Messenger >3 buttons/>10 rows, Instagram >13 quick replies, Discord >25 buttons/options, >10 poll answers). requestTimeoutMs (60 s) on Cloud API, Discord, Messenger, Instagram (Telegram 120 s); RateLimitedError.retryAfterMs capped at 24 h; an unreadable local media path rejects UnsupportedContentError. Discord defers interactions nobody acknowledged within autoAcknowledgeMs (2000; false = off); a late ctx.acknowledge(text) is a followup (appId, else the application id from READY/the interaction; none known = rejects ProviderError). Gupshup listButtonText (default 'Options'). - ctx (MessageContext) helpers: ctx.reply (ctx.say/answer/text are deprecated aliases), ctx.photo/video/audio/voice/file/sticker/location/contact/poll/buttons, ctx.react/quote/markRead/typing/recording/delete, ctx.pass()/abort(), ctx.sendNative(provider, payload) - Typing indicator and pacing: new Bot({ typing: true | { enabled, minMs, maxMs, charsPerSecond } }) shows "typing…" before ctx text replies (off by default; no indicator and no wait where capabilities.typing is false; a failing indicator never fails the send; ctx.typing()/ctx.recording() never reject either); per reply: ctx.reply(text, { typing: false | true | {...}, delay: '1.5s' }) - ctx.edit(content) edits this context's own last reply (same kind, not followed by bot.send); ctx.edit(ref, content) edits a given message; returns { edited }. Native on Telegram/Discord; elsewhere it sends a new message to ref.chatId ({ fallback: 'skip' } sends nothing) - ctx.react(emoji, target?) resolves { skipped: true } where reactions cannot be sent (Messenger, Instagram); ctx.supports('typing'|'delay'|'edit'|'react') -> 'native'|'degraded'|'unsupported' - Buttons/lists: native on the Cloud API (@botmesh/meta: at most 3 reply buttons, more rejects with UnsupportedContentError — send a list instead; a single Button with url goes out as cta_url, several links or a link mixed with replies reject; a card keeps its image and buttons); Baileys has none — use numbered choose() menus or ctx.poll() - Stores: store: new SQLiteStore({ file }) from @botmesh/sqlite-store, new RedisStore({ url }) from @botmesh/redis-store, or store: new MemoryStore({ file }) for the built-in JSON dump (BotConfig.storeConfig is deprecated); implement IStore for custom backends. .persistent() flows checkpoint when they start and after every step (a restart while the first .ask() waits resumes and re-asks) and only survive a restart with a durable store. - Multiple replicas (opt-in): new Bot({ provider, store, coordination: { coordinator: redisStore.coordinator(), turnWaitMs?, settleMs?, providerSession?: 'auto'|'leader'|'shared', providerDownMs? } }) — each chat is processed by one replica at a time in ingress order (cross-replica FIFO); a .persistent() flow continues on another replica from its checkpoint (the waking message answers the pending question when the flow was parked on its step's first wait, else the step re-runs); a step longer than settleMs is suspended before the turn is released; conversation timers run under the chat's turn; providers with capabilities.exclusiveSession (Baileys, Discord, Telegram polling) start only on the elected replica (on the others bot.send rejects ProviderNotLeaderError). Lost lease: sends reject LeaseLostError; no turn: CoordinationUnavailableError on 'error'. Events 'coordination:leader'|'coordination:follower'|'coordination:lease-lost'. Conversation hand-over (opt-in): coordination.handover { flows?: 'forward'|'drop' (non-persistent flows via TTL-bound ephemeral checkpoints), fallback?: 'notify'|'restart'|'drop'|((info, ctx) => boolean) (async conversations), notifyMessage?, ttl?, maxAnswerBytes? } + event 'coordination:handover'; a step cut short after its first answer resumes with that answer (FlowCheckpoint.answer); with durability a record is acked only after its checkpoint write; checkpoint writes are fenced by the chat turn. Tests: MemoryCoordinator + MemoryCoordinationHub (crash() simulates a dead replica). Without coordination nothing changes. - v1 sub-APIs (bot.groups/profile/privacy/chats/presence) were REMOVED — reach the Baileys socket via socketOf(provider) from @botmesh/baileys/internal (provider.socket is typed unknown) for WhatsApp-specific ops ## @botmesh/ai (separate package) - Install: pnpm add @botmesh/ai + the chosen provider's SDK (@anthropic-ai/sdk ^0.68 || ^1, openai ^6.48 || ^7, or @google/genai ^2 — the deprecated @google/generative-ai is still used as a fallback with a DeprecationWarning; ollama needs none, it talks plain HTTP via fetch; the 9 presets need only openai). Vertex AI / custom Gemini client: aiPlugin({ instance: new GeminiProvider(fromGoogleGenAI(new GoogleGenAI({...})), { model }) }) - bot.plug(aiPlugin({ provider, model, apiKey, baseUrl, persona, maxTokens, effort, thinking, timeoutMs, context: { maxMessages }, autoReply, resetCommand, errorMessage, refusalMessage, humanizeTyping, instance })) — catch-all text autoresponder with per-chat history + a /reset command (install after slashCommandsPlugin so its /reset wins); errorMessage/refusalMessage default to the localized ai.error/ai.refusal texts (es/en); aiPlugin() throws a RangeError/TypeError naming the option for impossible numbers (NaN or > 2^31-1 ms timeouts, maxTokens < 1, bad toolConfirm.timeout, tool without handler); bot.stop() aborts every model call in flight on 'stopping', before the transport stops (autoreplies, background memory extraction and ctx.tools.ai.ask/generate/stream/replyInChat/streamReplyInChat/runTools from user handlers, which reject with an AbortError) - Providers: 'anthropic' (default; default model claude-opus-5-5), 'openai' and 'ollama' (both require `model` explicitly), 'gemini' (default gemini-3.8-flash), and OpenAI-compatible presets 'deepseek'|'qwen'|'glm'|'kimi'|'groq'|'openrouter'|'mistral'|'grok'|'together' (each with its conventional env var, e.g. DEEPSEEK_API_KEY, and a default model; grok-4.7, glm-5.3). Default models are outside semver (they follow the vendors' current models); set `model` when it matters - timeoutMs (default 60000) aborts a hung non-streaming call so it cannot block the chat queue (total deadline); for streaming it is an idle timeout between chunks. A caller's abort signal releases ctx.tools.ai calls even when the provider ignores it - maxTokensField ('max_tokens' | 'max_completion_tokens'): provider 'openai' uses max_completion_tokens for OpenAI/Azure and max_tokens for any other baseUrl unless set; presets use max_tokens - ctx.tools.ai (AIClient, via bot.provide('ai', client)): ask(prompt), generate(input), stream(input) - Tools: aiPlugin({ tools: { name: { description (model-only), title? (user-facing label), schema (JSON Schema validated: type/required/properties/enum/const/items/additionalProperties:false/minimum/maximum/minLength/maxLength/minItems/maxItems, or safeParse), handler(args, tctx), confirm?, confirmMessage?, labels? ({ argKey: { value: 'Human label' } }, shown in the default confirmation), confirmLines?: (args) => string[] (e.g. code-computed prices/total), dedupe?: 'args' | { windowMs }, timeoutMs? } }, toolConfirm: { yes?, no?, timeout?, buttons?, answeredNotice?, expiredNotice?, systemNote? } }). On channels with native buttons (capabilities.buttons: Telegram, Cloud API, Discord, Messenger, Instagram) the confirmation is a Yes/No buttons message (ai.tools.button.yes/no, prompt ai.tools.confirmButtons without the (yes/no) hint); a press equals typing yes/no (typing still works), is acknowledged, buttons are removed by edit where supported (Telegram, Discord), stale presses are acknowledged and dropped before routing (where buttons cannot be edited away — Cloud API, Messenger, Instagram — they get a localized ai.tools.confirmExpired reply unless toolConfirm.expiredNotice: false; toolConfirm.answeredNotice: true also replies ai.tools.confirmAnswered on a press); confirmWith(c, { buttons?, ctx?, answeredNotice? }) inside your own conversation uses the same buttons (ctx from replyInChat's ctx); channels without buttons get the typed question; toolConfirm.buttons: false forces text. Arguments are validated BEFORE confirmation (use enum for product keys). confirm: true asks a readable prompt ("About to: Create order • 5 × chocolate_cake Do you confirm? (yes/no)"); a clear yes runs, a clear no denies, anything else (e.g. "sí pero cambia a 5") is NOT run and returns { executed: false, reason: 'user_replied', userReply } to the model. A system prompt note tells the model not to ask for confirmation itself. Successful confirm/dedupe runs are remembered per chat (repeat notice; dedupe returns { executed: false, reason: 'duplicate', previous }); a handler returning { error } is not remembered as done. Best practices for side-effecting tools: never let the model compute money (quote tool + totals computed in the handler and shown via confirmLines), enum-validate catalog values, provide update/cancel/list tools alongside create, dedupe on create, return { error } for unknown ids, keep confirm on side effects - Formatting: the autoreply converts the model's Markdown per chat with ctx.textFormat = provider.textFormatFor?(chatId) ?? capabilities.textFormat ('whatsapp' → *bold* _italic_ ~strike~ •; 'plain' strips markers; 'markdown' unchanged; Twilio: whatsapp: chats 'whatsapp', SMS 'plain'); aiPlugin({ format: 'auto' | TextFormat | false }); formatMarkdown(text, format) - Testing: MockAIProvider and validateArgs from @botmesh/ai/testing (MockAIProvider implements AIProvider, no network; the main-entry re-export is deprecated) + ContextManager/AIClient from @botmesh/ai for unit tests. Entries: @botmesh/ai (semver), @botmesh/ai/testing (semver), @botmesh/ai/internal (plugin helpers such as toolDefinitions/citedChunks/ToolLedger and the SDK wire type names, no semver). Exported data (PRESETS, DEFAULT_YES/DEFAULT_NO, AI_MESSAGES, AI_LOCALES) is deep-frozen: extend a copy. GenerateInput.onStreamEnd(info: AIStreamEndInfo) runs exactly once after the last chunk, only when the stream ends normally (not on error, abort or early break; enforced even when the client ignores the signal). - WhatsApp policy: since 2026-01-15 Meta's WhatsApp Business Platform terms prohibit general-purpose AI assistants (open-ended ChatGPT-style chatbots). AI that serves the business's own customers (support, sales, bookings) is allowed. Always write a persona scoped to one business that declines unrelated requests; do not generate "ask me anything" WhatsApp bots.