Skip to main content
Experimental — not published yet@botmesh/manager is experimental: a real, tested implementation that is still private (not on npm, not part of the beta) and whose API may change. Use it from the monorepo (workspace:*).
@botmesh/manager runs many bots in one process and exposes them over HTTP. It has four pieces:
  • BotManager: a registry of bots (add, start, stop, restart, startAll, stopAll, shutdown) plus typed events.
  • Persistence and supervision: serializable bot configs per tenant, restore() after a restart and automatic reconnects with backoff.
  • JSON flows: declarative, code-free flow documents compiled to core flows and hot-swapped at runtime through the core dynamic flow registry.
  • HTTP APIs: the full /v1 REST API (createRestApi, with an OpenAPI 3.1 document), plus two smaller surfaces (createControlPlaneApi and the legacy createManagerApi).
All HTTP handlers are plain node:http handlers with no dependencies.

Quick start

BotManager

Anything with start() / stop() can be managed (the structural ManageableBot type): a core Bot or a plain stub. Features beyond start/stop (sending, flows, conversations, message history) are used only when the bot has the matching method; otherwise the REST API answers 501 NOT_SUPPORTED. A core Bot has all of them. Events (typed through ManagerEventMap): Logging goes through pino at debug/warn: inject a logger (new BotManager({ logger })) or set BOTMESH_MANAGER_LOG_LEVEL=debug (default silent; the pre-rename BOTWA_MANAGER_LOG_LEVEL is still read).

Multi-tenant persistence

A bot created with createBot() is backed by a BotConfigRecord: id, tenantId, name?, provider?: { type, options? }, flows? (JSON flow documents), meta?, desiredState (running | stopped), version, createdAt and updatedAt. The provider block is opaque to the manager: your factory(config) interprets it. Bot and tenant ids must match [A-Za-z0-9][A-Za-z0-9_.-]{0,63} (they become file names). Bots created without a tenant use defaultTenantId ('default'). Storage is pluggable through BotConfigStore (list, get, put, delete):
  • persistence: { dir } is shorthand for configStore: new FileBotConfigStore({ dir }): one JSON file per bot under <dir>/<tenantId>/<botId>.json, atomic writes, mode 0600 (directories 0700). Unparseable files are skipped, never deleted (onCorrupt receives them). Bot and tenant ids that differ from an existing one only by letter case (shop / Shop) are refused with CONFLICT: on Windows and macOS they would share a file or directory.
  • MemoryBotConfigStore keeps configs in RAM (tests). Without a store nothing is persisted and restore() throws NOT_CONFIGURED.
Secretsprovider.options is written to disk as is. Store a reference (an env var name, a SecretVault ref) and resolve it inside the factory.

Supervision and reconnect

With autoReconnect, the manager supervises every bot you asked to run:
  • when a bot emits connection: closed (except sessionTakenOver) and the provider does not recover on its own within graceMs, or
  • when a start fails,
it restarts the bot with backoff and jitter until retries is exhausted. Config-backed bots are recreated through the factory (fresh provider). A manual stop() cancels the supervision. computeBackoff(attempt, opts) and resolveAutoReconnect(input) are exported for custom supervisors.

Core dynamic flow registry

The manager drives these Bot methods from @botmesh/core; you can use them directly in any bot: With ifActive: 'replace' (what the deprecated bot.triggerFlow() always did) it goes through the same access-list and handoff gates, the chat’s FIFO slot, language resolution and metrics, and replaces a conversation already running (or dormant) on the same key. data is shallow-merged into c.state (the same option as bot.trigger(); state is a deprecated alias, and passing both throws a TypeError); body becomes ctx.body of the synthetic message (default ''). It rejects when the flow is not registered; started is false when the bot is stopped, the subject is blocked by the access lists or the chat is handed off (reason says which).

JSON flows

A flow document is pure data, so it is safe to accept over the API and to persist. compileFlowDocument(doc) validates it and returns a CompiledFlow for bot.registerFlow() / bot.replaceFlow(); it throws FlowDocumentError (with issues: { path, message }[]) when the document is invalid. validateFlowDocument(doc) returns the issues without throwing, and isFlowDocument(doc) is a type guard.
Steps: Documents also take media, form, guard, subflow, http, handoff and ai steps: every step kind is described in JSON flow schema. Texts accept {{ path }} placeholders over c.state and $body, $from, $sender, $isGroup. Expressions (condition.if, ask.validate.expr) use a small safe language parsed at compile time (no eval, no regexes): literals, state paths, == != < <= > >= && || ! + -, parentheses and the functions len, lower, upper, trim, number, string, contains, startsWith, endsWith, empty, oneOf. Limits (FLOW_DOCUMENT_LIMITS): 500 steps, nesting depth 8, 4096 characters per text, 20 options, 50 keywords, expressions up to 1000 characters. The JSON Schema (draft 2020-12) is exported as FLOW_DOCUMENT_SCHEMA and served at GET /v1/flows/schema. On a managed bot use manager.addFlow(botId, doc), manager.putFlow(botId, flowId, doc) (create or hot-swap; the document id must match), manager.removeFlow(botId, flowId) and manager.listFlowDocuments(botId). For config-backed bots the documents are persisted with the config and registered again on every rebuild. subflow steps resolve against the bot’s other stored flows (also in createBot({ flows }), updateBot() and restore()), and removeFlow() answers CONFLICT (409 over REST) while another stored flow still uses the one being removed as a subflow. A subflow is inlined when a flow is compiled, so putFlow() also recompiles every stored flow that uses the changed one (directly or through another subflow); all of them compile before anything changes. The config is saved before the live bot changes: if the save fails, the call rejects and the bot keeps running the previous flows.

REST API /v1 (createRestApi)

createRestApi(manager, options) returns a (req, res, next?) => void handler. Requests outside the prefix call next() when given (so it can be mounted inside another server), or get 404.

Authentication and tenants

Send the token as Authorization: Bearer <token> or X-API-Key: <token> (compared through SHA-256 digests in constant time). A token without tenantId (or with '*') sees every tenant; a tenant token only sees its own bots, and any other bot answers 404. Scopes ('*' grants all): bots:read, bots:write, bots:operate, connections:read, messages:read, messages:send, conversations:read, conversations:write, flows:read, flows:write, metrics:read.

Routes

All paths are relative to the prefix. Request bodies:
  • send: { chatId, text } or { chatId, content }. content is an allowlisted subset of OutboundContent: text, image, video, audio, voice, document, sticker (media only as { url: "https://..." }, no paths or buffers), location, contact and poll (2–12 options).
  • trigger: { flowId, chatId, userId?, isGroup?, state? } (state up to 16 KiB of JSON). It starts the flow like bot.trigger() with ifActive: 'replace': 404 when the flow is unknown, the subject is blocked or the chat is handed off; 409 BOT_NOT_RUNNING when the bot is stopped.
  • POST /bots: { id?, tenantId?, name?, provider?, flows?, meta?, autoStart? }. A tenant token can only create bots in its own tenant.

Responses and status codes

Successful responses are { data, requestId } (the OpenAPI document and the flow schema are returned as is). Errors are { error: { code, message, requestId, details? } }. Every response carries X-Request-Id (a valid incoming one is echoed), Cache-Control: no-store and X-Content-Type-Options: nosniff. While rate limiting is on (the default), every route, public ones included, is limited and adds RateLimit-Limit and RateLimit-Remaining.

Idempotency

Mutating routes accept Idempotency-Key (1–200 visible ASCII characters). Keys are scoped per token. Repeating a request with the same key, method, path and body replays the stored response (header Idempotent-Replayed: true); reusing the key for a different request is 422 IDEMPOTENCY_KEY_REUSED. 5xx outcomes are not stored, so they can be retried. The default MemoryIdempotencyStore and TokenBucketRateLimiter live in process memory: plug your own IdempotencyStore / RateLimiter when running several instances.

OpenAPI document

The OpenAPI 3.1 document is generated from the same route table the router uses (ROUTES), so it cannot drift from the implementation. Get it from a running server:
or build it in code, without a server:
buildOpenApiDocument({ prefix?, title?, version?, servers? }) defaults to '/v1', 'botmesh Manager API', '1.0.0' and a relative server. It declares both security schemes (bearerAuth and apiKey through X-API-Key) and an x-required-scope per operation.

Other HTTP surfaces

createControlPlaneApi

A minimal audited surface with tenant-bound API keys. It also defaults to the /v1 prefix, so do not mount it next to createRestApi on the same prefix.
  • It requires tenantId and at least one key; there is no anonymous mode. Keys go in X-API-Key or Authorization: Bearer <key>.
  • Routes: GET /v1/health and GET /v1/bots (bots:read), POST /v1/bots/:id/start and POST /v1/bots/:id/stop (bots:operate), GET /v1/audit (audit:read).
  • Responses are { data, requestId }; errors are { error: { code, requestId } } with 400 BAD_REQUEST (a malformed bot id in the path), 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 409 CONFLICT (an Idempotency-Key reused for another request) and 500 INTERNAL. An X-Request-Id is echoed only when it is 1-128 characters of [A-Za-z0-9._:-]; otherwise one is generated.
  • Health, list and start/stop calls, and every denied or unknown-bot request of an authenticated key, are written to an AuditLog (in memory and bounded: the last maxEntries, 10 000 by default; pass your own through auditLog). Metadata keys that look like tokens, secrets, passwords or API keys are redacted.
Related building blocks: SecretVault (AES-256-GCM, 32-byte master key; put returns only { ref, version }), IdempotencyRegistry (remembers the last maxEntries keys, 1000 by default), assertAuthorized and verifyApiKey (constant-time comparison).

Desired state reconciler

MemoryDesiredStateStore and BotReconciler separate the desired state (running / stopped) from the observed state. reconcile(tenantId, botId) starts or stops a bot only when needed; reconcileTenant(tenantId) does it for every bot of a tenant. Writes use compare-and-set versions (DesiredStateConflictError), so competing updates cannot silently overwrite each other. Replace the memory store with a durable DesiredStateStore before running more than one process.

Legacy createManagerApi

createManagerApi(manager, { prefix?, auth?: { token, header? } }) keeps the original compact API: GET {prefix}/health, GET {prefix}/metrics (Prometheus text), GET {prefix}/bots, GET {prefix}/bots/:id, POST {prefix}/bots/:id/start and POST {prefix}/bots/:id/stop. Without auth it is unauthenticated and logs a one-time warning; always set a token. A failed start/stop answers 500 { "error": "start failed" } (the error itself goes to onError(error, { id, action }), never to the client). Prefer createRestApi for new deployments.

See also