A local, throwaway Slack for developing and testing Slack apps/bots — not a Slack replacement. Your bot connects to it exactly as it would to real Slack (Web API + Socket Mode + Events API), and a Slack-like web UI lets a human "act as" a workspace user and watch the users ↔ bot interaction in real time. Workspace, users and channels are defined declaratively in a config file.
It's the inverse of test-interception libraries like slack-testing-library / slack-mock: a real
running server with a browser UI, plus a programmatic control API so it can also drive automated
tests.
local-slack-demo.mov
- Web API —
auth.test,chat.*,conversations.*,users.*,views.*,reactions.*,emoji.list,apps.connections.open,team.info,bots.info - Two delivery modes (per-app config switch):
- Socket Mode — the bot opens a WebSocket (via
apps.connections.open→ws://…) - Events API (HTTP) — signed POSTs to the bot's request URL (real
x-slack-signature, so Bolt's verification passes)
- Socket Mode — the bot opens a WebSocket (via
- Interactivity — Block Kit rendering, buttons →
block_actions, modals viaviews.open/update/push+view_submission(withresponse_actionerrors/update/push/clear) - Slash commands and the App Home tab
- Threads — a resizable docked thread pane (reply summaries on the parent message,
conversations.replies,reply_count/latest_replyon the parent viaconversations.history) - Message permalinks —
chat.getPermalinkhands back a link that actually works:/archives/<channel>/p<ts>, plus?thread_ts=…&cid=…when the message is a reply. Opening one (pasted in the address bar or clicked in a message) selects the channel, opens the thread if it was a reply, and flashes the message. Links copied out of a real workspace work too — anyhttps://<workspace>.slack.com/…is rewritten to this server's origin when rendered, and URLs in message text are auto-linked the way Slack's client does app_mention—<@BOT_ID>in a message deliversapp_mentionto the mentioned app, in addition to themessageevent, exactly like real Slack. Only the app actually named gets it (unlikemessage, which fans out to every app in the channel), and mentions of an app that isn't in the channel are ignored- Unread indicators and notifications — channels with unseen messages go bold, threads with unseen replies get a dot on their reply summary (opening a channel doesn't clear its threads, matching Slack), and the 🔔 in the top bar badges the unread count and drops down a list of what you missed — click one to jump straight to that message, opening its thread if it was a reply. The same count shows in the browser tab title, and a chime plays on incoming messages (🔊 toggles it; the choice persists)
- Human-driven reactions, edit, and delete — react from the UI (delivers
reaction_added/reaction_removed), and edit/delete your own messages (deliversmessage_changed/message_deleted); bot messages can't be edited/deleted this way - Multiple apps in one workspace — declare several apps under
apps:; each gets its own tokens, delivery mode, Socket Mode connection(s) and Home tab. Channel events fan out to every app that's a member; interactive components (buttons/modals) and slash commands route to the specific app that owns them - @mention and #channel autocomplete — type
@or#in the composer to pick a user/bot or a channel; both render inline with the same blue/highlight style they'd have once sent, but insert real<@USER_ID>/<#CHANNEL_ID|name>syntax so the bot receives an actual reference, not literal text. Pick from the menu with Tab/Enter/click, or just type the handle out in full and ignore the menu — it links on space, and again on send, so@bob/#random/@your-botbecome real references however you finish typing. Only exact handle matches resolve that way: a half-typed@bostays plain text rather than silently linking the wrong person, and an address likea@b.comis left alone. Channel references in a rendered message are clickable and jump to that channel's tab - Emoji rendering —
:shortcode:in message text and reaction pills render as the actual emoji character; declare custom emoji in the config to render/react with your own images instead - Inspector — a live view of raw traffic to/from the bot (envelopes, HTTP, acks, Web API calls)
- Bun ≥ 1.3 (
curl -fsSL https://bun.sh/install | bash) - Node is only needed to run the example Bolt bot (Bolt targets Node).
No install, always the latest version, no Bun required (downloads a prebuilt binary for your platform on first run):
npx local-slack --config config.yaml --openOr via Homebrew:
brew tap parvalabs/tools
brew install local-slack
local-slack --config config.yaml --openSee Single-file binary below for standalone downloads (macOS/Linux/Windows) if you'd rather not use npx or Homebrew.
For development (editing local-slack itself):
bun install
bun run dev # backend on :3000, Vite UI on :5173 (proxies to the backend)Open the UI at http://localhost:5173 (dev) — or run the server standalone and open http://localhost:3000:
bun run start # serves the built UI + API on one port (:3000)Then start the example bot against it (in another terminal):
cd examples/echo-bot && npm install
SLACK_MODE=socket SLACK_API_URL=http://localhost:3000/api/ node index.jsPost a message in the UI as a user → the bot receives it and replies. Type button to get
interactive buttons and a modal; type /echo hi for a slash command; open Apps → testbot for
the App Home tab.
A Python (slack_bolt) version of the same bot lives in
examples/echo-bot-py — the mock isn't Bolt-JS-specific:
cd examples/echo-bot-py && python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
SLACK_API_URL=http://localhost:3000/api/ python index.pyNo code changes — just configuration:
// Socket Mode
new App({
token: "xoxb-test-token", // must match config.app.botToken
appToken: "xapp-test-token", // must match config.app.appToken
socketMode: true,
clientOptions: { slackApiUrl: "http://localhost:3000/api/" },
});
// Events API (HTTP)
new App({
token: "xoxb-test-token",
signingSecret: "test-signing-secret", // must match config.app.signingSecret
clientOptions: { slackApiUrl: "http://localhost:3000/api/" },
}).start(4000); // config.app.requestUrl must point here, e.g. http://localhost:4000/slack/eventsclientOptions.slackApiUrl is passed through to both the main WebClient and the Socket Mode
client, so apps.connections.open hits the mock too.
For Python's slack_bolt, pass a WebClient with base_url set — both to the App and, for
Socket Mode, explicitly to SocketModeHandler (its client defaults to app.client, which is
authenticated with the bot token, not the app-level token Socket Mode needs):
from slack_sdk import WebClient
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
API_URL = "http://localhost:3000/api/"
app = App(client=WebClient(token="xoxb-test-token", base_url=API_URL))
handler = SocketModeHandler(app, "xapp-test-token", web_client=WebClient(token="xapp-test-token", base_url=API_URL))
handler.start()See examples/echo-bot-py for a complete, verified example.
See examples/config.yaml. Run with --config <path>.
workspace: { name: Test Workspace, domain: test-workspace, teamId: T01TEST }
app:
appId: A01APP
botUserId: U0BOT
botName: testbot
botToken: xoxb-test-token
appToken: xapp-test-token
signingSecret: test-signing-secret
mode: socket # "socket" | "events"
requestUrl: http://localhost:4000/slack/events # used when mode: events
users:
- { id: U01ALICE, name: alice, real_name: Alice Anderson, email: alice@example.com }
channels:
- { id: C01GEN, name: general, members: [U01ALICE, U0BOT] }A user's optional email is returned as profile.email from users.info / users.list, and is what
users.lookupByEmail matches on.
Declare a name -> image mapping under emojis:; paths are relative to the config file:
emojis:
party_parrot: party_parrot.png
super_sad: sad.pngThe server validates each image exists at startup and serves it at /emoji/<name>. Use
:party_parrot: in message text or as a reaction name — both render the actual image, and
emoji.list returns each name resolved to a fetchable URL for bots that enumerate a workspace's
custom emoji.
Replace the singular app: with an apps: list to run more than one app against the same
workspace at once — e.g. testing bot-to-bot interaction, or exercising two separate apps together.
Every app needs a unique appId, botUserId and botToken. See
examples/config.multiapp.yaml (paired with
examples/echo-bot and examples/shout-bot).
apps:
- appId: A01APP
botUserId: U0BOT
botName: echobot
botToken: xoxb-echobot-token
appToken: xapp-echobot-token
mode: socket
- appId: A02APP
botUserId: U0BOT2
botName: shoutbot
botToken: xoxb-shoutbot-token
appToken: xapp-shoutbot-token
mode: socket
channels:
- { id: C01GEN, name: general, members: [U01ALICE, U0BOT, U0BOT2] }Routing rules (matching real Slack as closely as this mock reasonably can):
- Channel events (messages, reactions, edits, deletes) fan out to every app whose bot is a member of the channel.
- Interactive components (buttons, modals) route to whichever app posted the message or opened the view — inferred automatically, no configuration needed.
- Slash commands and opening a Home tab target one specific app, since this mock doesn't
model per-command registration. The UI's "As app" selector (shown once ≥2 apps are configured)
picks the target for slash commands typed in the composer;
/_control/commandand/_control/open-homeaccept an optionalappIdin the body (defaults to the first configured app).
bun run test # unit tests (config/signing/store/interactions/Web API) + integration
# tests that spin up the real server and drive it over HTTP/WebSocketlocal-slack --config <path> [--port <n>] [--base-host <host>] [--open]
| Flag | Description | Default |
|---|---|---|
--config |
Path to the workspace config (YAML/JSON) | config.yaml |
--port |
Port for the UI + API + WebSockets | 3000 |
--base-host |
Hostname clients use to reach this server — baked into the Socket Mode ws:// URL and interactive response_url callbacks. Override this when the bot runs elsewhere (a different pod/container) and can't resolve localhost back to this server |
localhost |
--open |
Open the web UI in the browser on start | — |
-v, --version |
Print the version number and exit | — |
-h, --help |
Show help and exit | — |
Drive the workspace and inspect bot traffic without the UI (base http://localhost:3000/_control):
| Method | Endpoint | Body |
|---|---|---|
| POST | /message |
{ channel, user, text, thread_ts? } |
| POST | /command |
{ channel, user, command, text, appId? } (appId defaults to the first configured app) |
| POST | /interact |
{ channel, messageTs, user, action } (routes to the message's own app automatically) |
| POST | /reaction |
{ channel, ts, user, name, present? } (present defaults true) |
| POST | /edit-message |
{ channel, ts, user, text } (only the message's own author may edit) |
| POST | /delete-message |
{ channel, ts, user } (only the message's own author may delete) |
| POST | /open-home |
{ user, appId? } (appId defaults to the first configured app) |
| POST | /reset |
— (restore config baseline) |
| GET | /log |
ordered record of all bot-facing traffic |
| GET | /state |
workspace / apps / users / channels |
| GET | /messages/:channel |
messages in a channel |
curl -X POST localhost:3000/_control/message \
-H 'content-type: application/json' \
-d '{"channel":"C01GEN","user":"U01ALICE","text":"hello"}'
curl localhost:3000/_control/log # assert on what the bot received / sentPre-built binaries for every release are on the Releases page (or install via Homebrew). This section is about building one yourself:
bun run build:binary # builds the UI, inlines it, and compiles a standalone executable
./local-slack --config examples/config.yamlProduces ./local-slack — a standalone binary (no Bun/Node needed on the target) with the web UI
embedded.
bun run build:binaries # builds for every platform below into dist-bin/Cross-compiles via Bun's --target (see server/scripts/build-binaries.ts),
downloading each target toolchain on first use, ad-hoc code-signs the macOS outputs, and packages
everything as an archive (tar/zip preserve the executable bit — a raw binary uploaded to e.g. a
GitHub release loses it):
| Archive | Platform |
|---|---|
local-slack-darwin-arm64.tar.gz |
macOS, Apple Silicon |
local-slack-darwin-x64.tar.gz |
macOS, Intel |
local-slack-linux-x64.tar.gz |
Linux, x64 |
local-slack-linux-arm64.tar.gz |
Linux, ARM64 |
local-slack-windows-x64.zip |
Windows, x64 |
Each contains a single local-slack/local-slack.exe. To build just one, pass its target to the
script directly: bun scripts/build-binaries.ts bun-darwin-arm64 (run from server/).
These binaries aren't notarized (that needs a paid Apple Developer account) — ad-hoc signing avoids
an outright "is damaged" refusal for a locally-built or curl-downloaded binary, but a browser
download gets an extra com.apple.quarantine tag that Gatekeeper still rejects even when signed.
Two ways around it:
# Recommended: curl never sets the quarantine flag, so this just works.
curl -L -o local-slack.tar.gz https://github.com/parvalabs/local-slack/releases/download/v0.1.0/local-slack-darwin-arm64.tar.gz
tar xzf local-slack.tar.gz
./local-slack --config config.yaml
# If you downloaded via the browser instead, clear the quarantine flag it added:
xattr -d com.apple.quarantine local-slacknpx local-slack doesn't run TypeScript source through Bun — it runs a prebuilt binary, same as
the Homebrew/standalone-binary installs, so it has no Bun dependency at runtime. That's six npm
packages under npm/: local-slack-<platform> for each of the 5 build targets (just the
compiled binary, gated to the right machine via os/cpu fields in its package.json), plus a
thin local-slack wrapper (npm/local-slack/bin.js) that resolves and
execs whichever one npm's optionalDependencies resolution actually installed. server/'s own
package.json is private and is never itself published — it's the workspace's dev/build tooling,
not the distribution artifact.
A release updates all three channels (npm, GitHub Releases, the Homebrew tap) together with real, matching version numbers:
bun run release:prepare 0.2.0 # bumps the version everywhere, rebuilds every binary + npm
# package, prints checksums to review
bun run release:publish 0.2.0 # npm publish x6, git tag + GitHub release, Homebrew tap updateSee server/scripts/prepare-release.ts and
server/scripts/publish-release.ts for exactly what each
step does.
- Runtime: Bun. HTTP: Hono (runtime-agnostic). WebSockets:
Bun.servenative. server/— config loader (zod/yaml), in-memory store + event bus, Web API methods, Socket Mode server, Events API dispatcher (signs deliveries), interactions (trigger_id / response_url / views), UI gateway, control + hooks routers.web/— React + Vite UI (sidebar, message list, composer, Block Kit renderer, modals, App Home, Inspector).examples/echo-bot/— a real Bolt (JS) app used for end-to-end verification.examples/echo-bot-py/— the same bot in Bolt for Python, proving the mock is SDK-agnostic.examples/shout-bot/— a second Bolt app, paired withexamples/config.multiapp.yamlto verify multi-app support.
Not a security boundary: tokens/signatures are validated only enough for realism. Local testing only.