Not on npm yet
@botmesh/sandbox lives in the monorepo and is not published to npm. Use it from a clone:
pnpm install && pnpm build.
Baileys has no simulator here: WhatsApp Web is a binary socket protocol, not a documented
HTTP API. Test Baileys bots with
MockProvider and the terminal simulator, and pair a test
number for the last check.
Channels
Every simulator is a small
node:http server (Discord adds a WebSocket gateway on the same
port). Every provider points at it through the base-URL option it already has: graphUrl (Meta,
Messenger, Instagram), apiUrl and gatewayUrl (Discord), baseUrl (Telegram, Gupshup,
HighLevel), apiBaseUrl (Twilio, TikTok). sim.providerConfig() returns that configuration,
including the media-download options that allow http://127.0.0.1.
The web chat
- API requests: every call the provider made, with its status and any difference from the documented request shape.
- Webhooks: every delivery to the bot, with its HTTP status and retry attempt.
- Faults & user: make the next send fail with a platform error (rate limit, invalid recipient, outside the 24 h window, media too large, authentication, server error). Here you can also change the simulated user’s id and name.
Local-only API
The page talks to the hub through a small JSON API. Any web page you open in the same browser could also reach127.0.0.1, so the API protects itself:
- It binds to
127.0.0.1by default.--hostchanges that, and the CLI warns when the address is not loopback.--hostonly moves the hub: the simulators always stay on127.0.0.1, because like the provider APIs they imitate they check neitherHostnor a token. - Every request must carry a loopback
Hostheader on the hub’s port (127.0.0.1,localhostor[::1], plus the names in--allow-host/allowedHosts). That defeats DNS rebinding, where a hostile domain is re-pointed at 127.0.0.1. When the browser sends anOriginorSec-Fetch-Siteheader, it must be the hub’s own origin. - State-changing calls (
POST /api/<channel>/send|fault|status|reset) also need the per-run token in thex-sandbox-tokenheader. The CLI prints the token at start and the hub embeds it in the page it serves.--token(orstartSandbox({ token })) fixes it for scripts, andsandbox.tokenreturns it from code. - Bodies are bounded (32 MiB for
send, 64 KiB otherwise). Malformed JSON, unknown fields, wrong types and unknown channels get a 4xx with{ "error": "..." }that names the problem, and a simulator that cannot express an input (a location on Discord) answers 422. - Media uploaded through the page is served with
nosniff, asandboxCSP and, except for plain images, audio and video, as a download.
'poll' event
(POLL_VOTE_PARSING in @botmesh/sandbox/providers). Everywhere else they send the
option text. The Telegram and Discord simulators can deliver native votes (poll_answer,
MESSAGE_POLL_VOTE_ADD); today only the Telegram provider parses them, so Telegram gets a
native vote and Discord the option text. A test fails as soon as a provider and the set
disagree, so the set is updated together with the provider.
The demo bot (--bot demo, the default) answers hi, menu, location (sends a location)
and poll (sends a poll), also in Spanish (hola, ubicacion, encuesta), echoes media and locations, and sends anything else to aiPlugin with a scripted
MockAIProvider (no API key). --bot bakery runs the bakery example
through the same channels.
To run your own bot, use startSandbox() from code:
In tests
startHarness() starts the simulator, the bot’s webhook ingress (@botmesh/server) and a Bot
on the real provider:
sim.send(input)): text, media ({ kind, data, mimetype, filename, caption }), location, reply (button or list row), edit (Telegram, Messenger),
pollVote (Telegram, Discord) and quotedId. Each outbound message the provider sends is normalized
into an OutboundRecord (kind, text, buttons, sections, media, location,
template, poll, action) and keeps the raw request.
Spec checks
Each simulator validates the requests it receives against the platform’s documented request shape: required fields, enums, lengths and counts. Examples are 4096-character WhatsApp text, 3 reply buttons with 20-character titles, 10 list rows, Twilio’sTo plus From or
MessagingServiceSid plus a body, Discord’s 2000-character content and 5 action rows, and
Telegram’s 64-byte callback_data. The schemas are written in a JSON Schema subset.
Twilio and Discord publish official OpenAPI documents under the MIT License, so their
simulators also validate every create-message request (and Discord’s edits and webhook
followups) against fragments of twilio-oai and
discord-api-spec. Those issues are tagged
[openapi]. The fragments are vendored in packages/sandbox/src/specs/ with their licenses
and commits, so tests never touch the network. packages/sandbox/scripts/vendor-openapi.mjs regenerates them
from downloaded copies. Meta, Gupshup, HighLevel, TikTok and Telegram publish no official
machine-readable spec, so their schemas stay hand-written from the docs.
In strict mode, the default, an invalid request gets the platform’s own validation error. With
strict: false it succeeds and is only listed in sim.issues().
Scenarios
Simulator options cover the rest:
webhookRetries/webhookRetryDelayMs: redeliver when the bot answers non-2xx.mediaTtlMs: inbound media URLs expire.autoStatus: 'read': receipts after each bot message.strict: answer invalid requests with the platform’s validation error (defaulttrue).
Errors
sim.fail(kind, { times, match, retryAfterSec }) answers in the platform’s own format. The
provider must then reject with the typed ProviderError subclass:
¹ Telegram and Discord have no messaging window: their closest platform answer maps to
RecipientUnreachableError.
A ctx.reply that gets a short Retry-After (5 s at most) is retried in place by the core, so
the end-to-end suites use retryAfterSec: 30 to see the error.
Self-hosted platforms in Docker
Some platforms can run locally: Evolution API, WPPConnect Server, Chatwoot and Mailpit. Their opt-in suites live inpackages/sandbox/tests/docker/ and are skipped unless the variables are
set:
The containers reach the test’s webhook server through
host.docker.internal. Set
BOTWA_TEST_DOCKER_HOST if your Docker uses another name. All ports are published on
127.0.0.1 only. WPPConnect Server is pinned to 1.3.50: 1.3.55 (latest in October 2026)
drops the QR of a session started through the REST API, and it calls only allow-listed webhook
URLs (WEBHOOK_ALLOWED_URLS).
What stays manual
Pairing a real WhatsApp number (scanning the QR for Evolution or WPPConnect, or the Baileys pairing code) needs a phone, so it stays manual:- Start the stack and run
start()on the provider (or create the instance from the Evolution manager athttp://127.0.0.1:47811/manager). - Scan the QR from WhatsApp → Linked devices on a test phone.
- Re-run the suites. Once the instance is
open, sending works too. The suites only need the instance to exist.