@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
/v1REST API (createRestApi, with an OpenAPI 3.1 document), plus two smaller surfaces (createControlPlaneApiand the legacycreateManagerApi).
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 withcreateBot() 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 forconfigStore: new FileBotConfigStore({ dir }): one JSON file per bot under<dir>/<tenantId>/<botId>.json, atomic writes, mode0600(directories0700). Unparseable files are skipped, never deleted (onCorruptreceives them). Bot and tenant ids that differ from an existing one only by letter case (shop/Shop) are refused withCONFLICT: on Windows and macOS they would share a file or directory.MemoryBotConfigStorekeeps configs in RAM (tests). Without a store nothing is persisted andrestore()throwsNOT_CONFIGURED.
Supervision and reconnect
WithautoReconnect, the manager supervises every bot you asked to run:
- when a bot emits
connection: closed(exceptsessionTakenOver) and the provider does not recover on its own withingraceMs, or - when a start fails,
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 theseBot 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 asAuthorization: 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 }.contentis an allowlisted subset ofOutboundContent:text,image,video,audio,voice,document,sticker(media only as{ url: "https://..." }, no paths or buffers),location,contactandpoll(2–12 options).trigger:{ flowId, chatId, userId?, isGroup?, state? }(stateup to 16 KiB of JSON). It starts the flow likebot.trigger()withifActive: 'replace':404when the flow is unknown, the subject is blocked or the chat is handed off;409 BOT_NOT_RUNNINGwhen 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 acceptIdempotency-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:
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
tenantIdand at least one key; there is no anonymous mode. Keys go inX-API-KeyorAuthorization: Bearer <key>. - Routes:
GET /v1/healthandGET /v1/bots(bots:read),POST /v1/bots/:id/startandPOST /v1/bots/:id/stop(bots:operate),GET /v1/audit(audit:read). - Responses are
{ data, requestId }; errors are{ error: { code, requestId } }with400 BAD_REQUEST(a malformed bot id in the path),401 UNAUTHORIZED,403 FORBIDDEN,404 NOT_FOUND,409 CONFLICT(anIdempotency-Keyreused for another request) and500 INTERNAL. AnX-Request-Idis 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 lastmaxEntries, 10 000 by default; pass your own throughauditLog). Metadata keys that look like tokens, secrets, passwords or API keys are redacted.
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
@botmesh/managerREADME- Operations
- Golden transcripts: gate deployments on recorded conversations