Skip to content

Platform sandbox ​

@botmesh/sandbox simulates the messaging platforms on your machine, so you can test a bot on every channel end to end without registering an app, a phone number or a public webhook.

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.

Private package

@botmesh/sandbox lives in the monorepo and is not published to npm. Use it from a clone: pnpm install && pnpm build.

Channels ​

ChannelProviderInboundWebhook authentication
WhatsApp Cloud API@botmesh/metawebhook + GET handshakeX-Hub-Signature-256
Messenger@botmesh/messengerwebhookX-Hub-Signature-256
Instagram DM@botmesh/instagramwebhookX-Hub-Signature-256
Twilio (WhatsApp, SMS)@botmesh/twilioform webhook + status callbacksX-Twilio-Signature
Gupshup@botmesh/gupshupv2 callbacksshared token
HighLevel@botmesh/gohighlevelInboundMessageX-GHL-Signature (Ed25519)
TikTok@botmesh/tiktokDMs and commentsTikTok-Signature
Discord@botmesh/discordGateway v10 WebSocketbot token
Telegram@botmesh/telegramgetUpdates or webhookX-Telegram-Bot-Api-Secret-Token

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 ​

bash
pnpm build
pnpm sandbox
# → open http://127.0.0.1:4610

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:

bash
pnpm --filter @botmesh/sandbox start -- --help
pnpm --filter @botmesh/sandbox start -- --channels whatsapp,telegram --port 4620
pnpm --filter @botmesh/sandbox start -- --bot bakery      # examples/bakery-ai with a scripted model
pnpm --filter @botmesh/sandbox start -- --telegram webhook --no-status

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 hola, menu, ubicacion and 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:

ts
import { startSandbox } from '@botmesh/sandbox'

const sandbox = await startSandbox({
  bot: 'none',
  setup: (bot, channel) => {
    bot.when('hola', (ctx) => ctx.reply(`Hi from ${channel}`))
  },
})
console.log(sandbox.url)

In tests ​

startHarness() starts the simulator, the bot's webhook ingress (@botmesh/server) and a Bot on the real provider:

ts
import { CloudAPIProvider } from '@botmesh/meta'
import { RateLimitedError } from '@botmesh/core'
import { WhatsAppSimulator, startHarness } from '@botmesh/sandbox'

const h = await startHarness(
  new WhatsAppSimulator(),
  (sim) => new CloudAPIProvider(sim.providerConfig()),
  (bot) => {
    bot.when('menu', (ctx) => ctx.buttons('Pick one', [{ id: 'bread', title: 'Bread' }]))
    bot.when('bread', (ctx) => ctx.reply('Good choice'))
  },
)

await h.sim.sendText('menu')                                   // signed webhook → bot
const menu = await h.sim.waitForOutbound((r) => r.kind === 'buttons')
await h.sim.send({ reply: { kind: 'button', id: 'bread', messageId: menu.id } })
await h.sim.waitForOutbound((r) => r.text === 'Good choice')
expect(h.sim.issues()).toEqual([])                             // every request matched the docs

h.sim.fail('rateLimit', { retryAfterSec: 30 })                 // next send: 429 + Retry-After
await h.stop()

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. 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 ​

HelperSimulates
duplicate(sim, input, n)the same event delivered n times (a platform retry after a lost 200)
outOfOrder(sim, inputs)events built in order and delivered newest first
burst(sim, inputs)concurrent events from one or many users
expiredMedia(sim, input)a media URL that has expired when the bot downloads it
statusSequence(sim, id)sent → delivered → read for one bot message

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:

FaultWhatsAppTwilioTelegramDiscordTyped error
rateLimit429, code 130429429, code 20429429, retry_after429, retry_afterRateLimitedError
invalidRecipientcode 131026code 21211400 chat not found50007RecipientUnreachableError
outsideWindowcode 131047code 63016403 bot blocked¹50001 Missing Access¹OutsideSessionWindowError
mediaTooLarge413, code 131053code 11751413413, code 40005PayloadTooLargeError
auth401, code 190401, code 20003401401ProviderError

¹ 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 interactions

On 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:

bash
docker compose -f packages/sandbox/docker/compose.yml up -d
docker compose -f packages/sandbox/docker/compose.yml run --rm chatwoot-init   # first time
node packages/sandbox/docker/chatwoot-token.mjs                                # prints the Chatwoot variables

BOTWA_TEST_EVOLUTION_URL=http://127.0.0.1:47811 BOTWA_TEST_EVOLUTION_KEY=sandbox-evolution-key \
BOTWA_TEST_WPPCONNECT_URL=http://127.0.0.1:47812 BOTWA_TEST_WPPCONNECT_SECRET=sandbox-wppconnect-secret \
BOTWA_TEST_MAILPIT_URL=http://127.0.0.1:47814 \
BOTWA_TEST_CHATWOOT_URL=… BOTWA_TEST_CHATWOOT_TOKEN=… BOTWA_TEST_CHATWOOT_ACCOUNT_ID=… \
  pnpm --filter @botmesh/sandbox exec vitest run tests/docker
SuiteCovers
Evolution APIhealth; instance creation with a webhook that sends the shared secret; the QR and connection webhooks reach the bot through @botmesh/server and are accepted; start() reports the QR; typed errors for an unpaired instance and a wrong API key; forged webhooks get 401
WPPConnect Servertoken from the secret key; session start with the webhook token in the URL query; the QR reaches the bot; typed errors for an unpaired session and a wrong secret
Chatwootfull round trip with @botmesh/chatwoot: user and bot messages appear in a conversation; an agent reply posted through Chatwoot's API comes back through Chatwoot's own webhook, reaches the user and hands the chat off; resolving the conversation resumes the bot
Mailpit@botmesh/email SMTP sends checked through Mailpit's API (identity, Auto-Submitted, attachments). Inbound IMAP is covered by the GreenMail suite of @botmesh/email

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.

Released under the MIT License.