slack-mock
A mock Slack server for end-to-end testing Slack bots. It speaks the Slack Web
API and Socket Mode, so a real @slack/bolt app connects to it unchanged
(only clientOptions.slackApiUrl differs). Messages, threads, reactions,
files, modals and assistant threads are stored, journaled as JSONL, and
rendered as Slack-looking HTML for screenshots.
Built for the agent-swarm Slack integration. Bun + TypeScript, no runtime dependencies.
Install
bun add -d @desplega.ai/slack-mock # from npm (once published)
bun add -d github:desplega-ai/slack-mock # or straight from the repo (Bun runs the TypeScript sources)
Quick start (from a checkout)
bun install
bun run start # slack-mock serve --port 4040
bun run demo # same, seeded with threads, blocks, files, an ephemeral and a DM
Point your bot at it:
SLACK_BOT_TOKEN=xoxb-mock-bot-token \
SLACK_APP_TOKEN=xapp-mock-app-token \
SLACK_API_URL=http://127.0.0.1:4040/api/ \
bun src/http.ts # agent-swarm reads SLACK_API_URL into clientOptions.slackApiUrl
Talk to the bot as a human and watch the thread:
curl -X POST http://127.0.0.1:4040/mock/messages -H 'content-type: application/json' \
-d '{"channel":"general","user":"alice","text":"<@U0BOT00000> hello"}'
open http://127.0.0.1:4040/c/general # live view: new messages arrive over SSE and flash (?live=0 turns it off, ?refresh=2 polls instead)
bun src/cli.ts screenshot http://127.0.0.1:4040/c/general --out general.png
UI
Use the avatar and name chip to choose who posts from every composer.
The browser remembers your choice across channels and threads.
Open /c/support?as=taras to select Taras. The page removes as from the URL after selection.
Public workspaces show the chip after presenter sign-in.
Optional user status text appears beside names in the users table and the chip's user list.
Each message offers reaction and thread icons, including messages without replies.
The thread icon opens the thread without focusing the composer.
Icons stay visible on phones and touch screens. Desktop shows them on hover or keyboard focus.
Choose an emoji from the picker beside the message, or select an existing reaction to add or remove your own.
Reactions use the identity in the chip and require presenter sign-in in public workspaces.
Below 700px, the sidebar becomes a top bar with a channel switcher.
Threads fill the phone width. The composer follows the visible viewport and includes padding for the iOS home bar.
Capture the phone layout with bun test test/phone-ui.test.ts (requires Chrome).
The PNG is saved to test/artifacts/phone-support-thread.png at 390 x 844.
In tests
import { App } from "@slack/bolt";
import { SlackMock } from "@desplega.ai/slack-mock";
const slack = await SlackMock.start({ port: 0, manifest: "slack-manifest.json" });
const app = new App({
token: slack.env.SLACK_BOT_TOKEN,
appToken: slack.env.SLACK_APP_TOKEN,
socketMode: true,
clientOptions: { slackApiUrl: slack.env.SLACK_API_URL },
});
await app.start();
await slack.waitForConnection();
const ask = await slack.postMessage({ channel: "general", user: "alice", text: `<@${slack.bot.userId}> hi` });
const reply = await slack.waitForMessage({ channel: "general", thread_ts: ask.ts, from: "bot" });
What tests can do:
| humans do | postMessage (text, thread, files), editMessage, deleteMessage, addReaction, removeReaction, slashCommand, clickButton, submitView, startAssistantThread, changeAssistantContext, addUser, addChannel, openDm, invite |
|---|---|
| observe | messages, thread, ephemeralMessages, findMessages, waitForMessage, apiCalls, waitForApiCall, deliveries (every envelope and its ack), assistantThread |
| break things | injectFault({ method, error, httpStatus, retryAfterSec, extra }), disconnectSockets("refresh_requested"), options ackTimeoutMs, maxRetries, triggerIdTtlMs, echoBotMessages, subscribedEvents |
The same operations exist over HTTP under /mock/* for use from another
process (see docs/design.md).
HTTP mode (signed requests)
For apps that take Slack's HTTP transport, the mock can post deliveries to request URLs instead of
pushing them over a WebSocket. Every request carries X-Slack-Request-Timestamp and an
X-Slack-Signature (v0= HMAC-SHA256 of v0:<timestamp>:<raw body>) made with the signing secret
you give it.
slack-mock serve --http-events-url http://127.0.0.1:3013/slack/events --signing-secret "$SLACK_SIGNING_SECRET" \
[--http-interactivity-url URL] [--http-commands-url URL] [--http-retry-delays-ms 1000,5000,30000]
const slack = await SlackMock.start({
httpDelivery: { eventsUrl, signingSecret, interactivityUrl, commandsUrl, retryDelaysMs: [1000, 5000], timeoutMs: 3000 },
});
| Delivery | Request |
|---|---|
| Events API | JSON body (the event_callback payload). Redelivered on a non-2xx, a timeout (timeoutMs, default 3s) or a refused connection, with X-Slack-Retry-Num and X-Slack-Retry-Reason (http_error, http_timeout, connection_failed), after each retryDelaysMs entry (default 1 min, 5 min, 30 min). |
| Slash commands | Form-encoded fields. Posted once. A JSON response body is the ack payload. |
| Interactivity | Form with one payload field. Posted once. A JSON response body is the ack payload. |
Interactivity and slash commands go to eventsUrl unless their own URL is set. deliveries() lists every
request with its attempts and ack status. new HttpDelivery(...).verifyUrl() runs the url_verification
challenge. Socket Mode stays the default; with httpDelivery set no app connection is needed.
Frames from a journal
A run recorded with dataFile can be replayed into one PNG per event that touched a
thread (or, without a thread, a channel): the view after each message.add,
message.update, message.delete, reaction.add and reaction.remove line, in journal
order, plus final-thread.png (a copy of the last frame) and final-desktop.png (the full
UI with the thread panel, 1280x900). Nothing is served: the journal is replayed in memory and
each page is screenshotted from a temp file by the same headless Chrome helper as
screenshot. A file.add line carries no message, so a shared file first appears in the
frame of the message that shares it.
slack-mock frames --journal run.jsonl --channel C0GENERAL0 --thread 1788518410.672000 --out ./frames
# 5 message.add /abs/frames/01-message.add.png
# 6 reaction.add /abs/frames/02-reaction.add.png
# ...
# final-thread=/abs/frames/final-thread.png
# final-desktop=/abs/frames/final-desktop.png
Leave out --thread for the channel view. --manifest app.json (bot display name),
--width, --height and --no-desktop are optional. The same from code:
import { frames } from "@desplega.ai/slack-mock";
const result = await frames({ journal: "run.jsonl", channel: "general", thread: ask.ts, out: "./frames" });
// result.frames[1] = { index: 6, kind: "reaction.add", path: "/abs/frames/02-reaction.add.png" }
// result.finalThread, result.finalDesktop
Stitch the PNGs into a GIF with ffmpeg if you want motion; the mock only produces frames.
Slack behaviour that is modelled
- Socket Mode:
apps.connections.open,hello, envelopes forevents_api,slash_commandsandinteractive, acks with response payloads, redelivery withretry_attemptwhen an envelope is not acked, server pings,disconnectmessages, reconnects. - Events:
message(channels, groups, im),app_mention,message_changed,message_deleted,file_share,reaction_added/removed,member_joined_channel,assistant_thread_started,assistant_thread_context_changed. The bot's own messages are echoed back like Slack does (Bolt'signoreSelfdrops them). - Web API: auth, chat (post, update, delete, ephemeral, permalink, streams,
markdown_textkept as the message text and rendered as Markdown,markdown_text_conflictnext totextorblocks), conversations (list, info, create, join, invite, leave, archive, history, replies, members, open), users (info, list, lookupByEmail), reactions, files (info, upload v2 flow, download with bearer token), views (open, update, push), assistant.threads (status, title, prompts), agents.sessions.setStatus, pins, bots, team. - UI: Block Kit
markdownblocks render as Markdown (headings, bold, italic, strike, links, lists, code);tableanddata_tablerender as tables (column_settingsalignment,data_tablepages atpage_size, default 5);data_visualizationrenders pie, bar, line and area charts as SVG. Their documented limits (rows, cells, caption, 2 charts per message, chart labels matchingaxis_config.categories) reject withinvalid_blocksonchat.postMessageandchat.update. Unknown blocks show a grayunsupported: <type>box. Errors come back as{ ok: false, error }with the real codes (not_in_channel,already_reacted,message_not_found,name_taken,expired_trigger_id,message_not_in_streaming_state, ...). - Slash command and interactive
response_urls are served by the mock (ephemeral/in_channel,replace_original,delete_original).
Commands
bun test # unit and integration tests (real Bolt client)
bun run test:e2e # boots ../agent-swarm against the mock (AGENT_SWARM_REPO to override; needs agent-swarm PR #1310)
bun run typecheck
bun run lint
bun run build # dist/ (JS for Bun + .d.ts). CI publishes to npm when the version in package.json changes on main
Hosted demo
https://demo.slack-mock.dev runs scripts/demo-server.ts: the mock plus a small
demo bot (scripts/demo-bot.ts) in one container, deployed to Fly.io from
main by .github/workflows/deploy.yml (fly.toml, Dockerfile). State
lives in a JSONL journal on a volume and resets daily. Run it yourself:
docker build -t slack-mock-demo . && docker run -p 8080:8080 -e ADMIN_AUTH=demo:secret slack-mock-demo
PUBLIC_URL, ADMIN_AUTH (basic auth for the UI and /mock/*), DATA_FILE,
RESET_EVERY_HOURS and MANIFEST configure it. The same knobs exist on the
CLI (--public-url, --auth) and as SlackMockOptions (publicUrl, adminAuth).
The same image also serves a workspace for an external bot, the Agent Swarm demo at
https://swarm-demo.slack-mock.dev (fly.swarm.toml, app slack-mock-swarm). Knobs that make
that possible, all read by scripts/demo-server.ts:
| Env | Effect |
|---|---|
DEMO_BOT=off | Skip the built-in demo bot; some other Bolt app connects over Socket Mode. |
SLACK_MOCK_BOT_TOKEN, SLACK_MOCK_APP_TOKEN | The tokens the app must present (botToken / appToken options). The defaults are public, so set these on any shared host. |
APP_NAME | Bot display name (appName). |
SEED_FILE | JSON with users, channels, messages and driver.prompts (scripts/seed.ts); replaces the built-in #general and is applied whenever the journal has no channels, so the workspace comes back after every reset. seeds/agent-swarm-demo.json is the shipped example. |
UI_PUBLIC=true | Keep / and /c/... readable without credentials while ADMIN_AUTH still gates /mock/* (publicUi option). The composer then shows a sign-in instead of the browser's basic-auth prompt. |
PRESENTER_AUTH | user:password that permits POST /mock/messages, POST /mock/reactions, and GET /mock/presenter (presenterAuth option). A presenter can post messages and manage reactions without the admin credential. Opening /c/<channel>#presenter=user:password signs that browser in. |
DRIVER_INTERVAL_MINUTES | Every N minutes post the next driver.prompts entry as that user, mentioning the bot, while an app is connected (scripts/driver.ts). 0 disables it. |
Docs
skills/slack-mock/SKILL.md: how to use the package from another repo (agents and humans).docs/design.md: architecture, decisions, status.docs/research/: the research behind it (agent-swarm's inbound/outbound Slack surface, Bolt and socket-mode internals, payload shapes, protocol docs and prior art, e2e conventions, Block Kit surface).