Skip to main content
The terminal simulator replaces the provider with an in-memory one. The sandbox does the opposite: it keeps the real provider and replaces the platform. Your bot talks over real HTTP to a local server that answers like WhatsApp, Telegram or Twilio. That server delivers webhooks signed with a secret it chooses, so signature checks, error mapping, media downloads and retries all run the production code path.
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.
Pick the tool by what you want to check: 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

The command starts the nine simulators, one bot per channel and a web page (one static HTML file, no build step). Pick a channel in the sidebar and write as a user. You can send files (📎), share a location (📍), tap buttons and list rows, and answer polls. You see the bot’s answers as the platform would render them, including delivery and read ticks. The inspector on the right has three tabs:
  • 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.
Options:

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 reach 127.0.0.1, so the API protects itself:
  • It binds to 127.0.0.1 by default. --host changes that, and the CLI warns when the address is not loopback. --host only moves the hub: the simulators always stay on 127.0.0.1, because like the provider APIs they imitate they check neither Host nor a token.
  • Every request must carry a loopback Host header on the hub’s port (127.0.0.1, localhost or [::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 an Origin or Sec-Fetch-Site header, 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 the x-sandbox-token header. The CLI prints the token at start and the hub embeds it in the page it serves. --token (or startSandbox({ token })) fixes it for scripts, and sandbox.token returns 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, a sandbox CSP and, except for plain images, audio and video, as a download.
Poll buttons send a native vote on channels whose provider turns one into a '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:
What a simulated user can do (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’s To 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 (default true).

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.
Discord and Telegram interactionsOn Discord (interactionsRequireAck) the core acknowledges every interaction on its own: a deferred ack (interaction_callback:6) before the handler’s first reply, shortly before the 3 s deadline or when the handler ends, unless the handler called ctx.acknowledge(text) first (interaction_callback:4). On Telegram the ack is optional, so without ctx.acknowledge() the button keeps its loading spinner. The sandbox records each acknowledgement as an action record.

Self-hosted platforms in Docker

Some platforms can run locally: Evolution API, WPPConnect Server, Chatwoot and Mailpit. Their opt-in suites live in packages/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:
  1. Start the stack and run start() on the provider (or create the instance from the Evolution manager at http://127.0.0.1:47811/manager).
  2. Scan the QR from WhatsApp → Linked devices on a test phone.
  3. Re-run the suites. Once the instance is open, sending works too. The suites only need the instance to exist.
Never point the Docker suites at a production Chatwoot or a paired production number: they create inboxes, webhooks and instances, and remove them at the end.