Supported Slack Web API, Bolt wiring, workspace operations, signed events, retry behavior, and deliberate differences.
@localhost2137/slack models one stateful Slack workspace. It persists users, public channels,
membership, messages, threads, and Events API delivery in SQLite. The supported surface is a coherent
bot workflow, not a general Slack replacement: arrange a workspace, receive a signed message event,
reply through Slack Bolt or the Web API, and inspect the resulting history.
Use this page as a compatibility boundary. A method or field not listed here should be treated as unsupported even if another Slack client happens to accept the local response.
Install the runtime, plugin, and runtime host peers as development dependencies. Install Bolt as an application dependency:
The plugin uses better-sqlite3. Before installing, add its project-scoped build permission to
pnpm-workspace.yaml at the project root, or merge it into the existing allowBuilds map:
allowBuilds:
better-sqlite3: truepnpm add -D localhost2137 @localhost2137/slack hono@^4.13.4 zod@^4.4.3
pnpm add @slack/bolt@5.0.0Omit the second command when the application already has that exact tested client version.
import { slack } from "@localhost2137/slack";
import { defineConfig } from "localhost2137";
export default defineConfig({
clock: { mode: "pinned", startAt: "2026-01-01T00:00:00.000Z" },
services: {
slack: slack({
config: {
botToken: "xoxb-local-crash-course",
eventsUrl: null,
signingSecret: "local-crash-course-signing-secret",
workspaceName: "Local workspace",
},
seed: {
users: [{ id: "U_ADA", name: "Ada" }],
channels: [{ id: "C_GENERAL", name: "general", members: ["U_ADA"] }],
},
}),
},
});This is the checked crash-course config. Set eventsUrl to the application's actual receiver when
the scenario needs callbacks; the checked Bolt test below builds that URL from an available port.
| Config field | Contract |
|---|---|
workspaceName | Required non-empty name. It appears as the team name in auth.test. |
botToken | Required token beginning with xoxb-. It is the only Web API credential the plugin creates. |
signingSecret | Required non-empty secret used for Events API signatures. |
eventsUrl | Absolute callback URL or null; defaults to null. No event delivery is created when it is null. |
Seed users accept name, optional id, and admin (default false). Seed channels accept name,
optional id, and members (default empty). Users are created before channels, so each member can
refer to a seeded user by ID or exact name. The configured bot is created independently and joins
every channel automatically. Messages and pending deliveries cannot be seeded.
The seed is used only when instance creation requests it. It is applied as one plugin transaction; an invalid member, duplicate name, or invalid channel rolls back the Slack seed. See Seeding for the instance-level seed lifecycle and recovery rules.
Each instance exposes these typed values under instance[serviceKey].connection:
| Value | Environment projection | Use |
|---|---|---|
apiUrl | SLACK_API_URL | Instance-scoped URL ending in /api/; pass it to Bolt as clientOptions.slackApiUrl. |
botToken | SLACK_BOT_TOKEN | Bearer token for the local installed bot. |
signingSecret | SLACK_SIGNING_SECRET | Secret shared with the application's Events receiver. |
The callback destination is not a connection value. eventsUrl belongs to mount configuration, so
all instances of this mount deliver to the same URL. Event IDs are allocated within an instance,
while the workspace ID is a fixed local identity; either value can recur in another instance. They
do not route a shared receiver by themselves. Serialize such scenarios, use application data that
genuinely distinguishes them, or configure separate runtimes. localhost2137 does not add an
instance header.
All supported methods accept GET or POST. POST bodies may be JSON or
application/x-www-form-urlencoded; query parameters are merged with the body. JSON values must be
scalar. Authenticate with Authorization: Bearer <bot token> or a token field in a form body.
| Method | Supported inputs and behavior |
|---|---|
auth.test | Returns the configured workspace and installed bot identity. |
users.list | Returns the bot and local users in ascending stored-ID order; accepts limit and cursor. Explicit seeded IDs therefore affect both order and cursor boundaries. |
conversations.list | Returns public channels, membership, and member counts; accepts types=public_channel, exclude_archived, limit, and cursor. All local channels are unarchived. |
conversations.members | Requires an exact channel ID; returns member IDs with limit and cursor. |
conversations.history | Requires an exact channel ID and bot membership; returns newest-first messages. Supports oldest, latest, inclusive, limit, and cursor. |
chat.postMessage | Requires an exact channel ID, bot membership, non-blank text, and optional valid thread_ts; persists a bot message and emits an event when eventsUrl is configured. |
Limits are integers from 1 through 999 and default to 100. Cursors are opaque and bound to the
method and filters that produced them; do not reuse one across a different list or history window.
History bounds use Slack seconds.microseconds timestamps and are exclusive unless inclusive is
true.
Public Web API channel arguments deliberately require stored IDs. Control operations accept IDs or exact names because they are a privileged test interface; production-style HTTP does not get that shortcut. This difference catches application code that sends a channel name where Slack expects an ID.
Recognized Slack failures use HTTP 200 with { ok: false, error }. Supported codes include
authentication, missing argument, invalid cursor or limit, missing channel, and membership errors.
Unknown routes are not evidence of Slack compatibility; only the methods above have response and
state-transition coverage.
Use operations to arrange an external actor, trigger inbound application behavior, and inspect the world. Use the Web API through the application for behavior the application itself owns.
| Operation | Input and effect |
|---|---|
createUser | name, optional admin; returns the allocated user. Names are case-sensitive, trimmed, and limited to 80 characters. |
createChannel | name; normalizes it to lowercase, creates one public channel, and joins the bot. The first character must be an ASCII lowercase letter or digit. Remaining characters may be ASCII lowercase letters, digits, _, or -; total length is 1–80. |
addUserToChannel | channel and user, each an ID or exact name; returns added: false when membership already exists. |
sendMessage | channel, from, text, optional threadTs; requires membership and returns the persisted message plus eventId, which is null when delivery is disabled. |
listMessages | channel, optional limit from 1 through 999; returns newest-first persisted messages without delivery metadata. |
The generated CLI uses kebab-case operation names. Discover the installed schemas before scripting them:
pnpm exec localhost exec slack --help
pnpm exec localhost exec slack create-user --name Grace --json
pnpm exec localhost exec slack create-channel --name alerts --json
pnpm exec localhost exec slack add-user-to-channel --channel alerts --user Grace --json
pnpm exec localhost exec slack send-message --channel alerts --from Grace --text ping --jsonControl failures use localhost2137 error codes such as SLACK_CHANNEL_NOT_FOUND,
SLACK_NOT_IN_CHANNEL, and SLACK_NAME_TAKEN. The same domain rule reached through the Web API uses
Slack's { ok: false, error } response instead.
import { App, LogLevel } from "@slack/bolt";
export interface PingPongBotOptions {
readonly apiUrl: string;
readonly botToken: string;
readonly port: number;
readonly signingSecret: string;
}
export interface PingPongBot {
start(): Promise<void>;
stop(): Promise<void>;
}
/** Builds an ordinary Bolt app wired only through localhost2137 connection metadata. */
export function buildPingPongBot(options: PingPongBotOptions): PingPongBot {
const app = new App({
clientOptions: { slackApiUrl: options.apiUrl },
endpoints: "/slack/events",
logLevel: LogLevel.ERROR,
signingSecret: options.signingSecret,
token: options.botToken,
});
app.message(/^ping$/, async ({ say }) => {
await say("pong");
});
let phase: "new" | "running" | "stopped" = "new";
return Object.freeze({
async start() {
if (phase !== "new") throw new Error("Ping-pong bot may be started exactly once.");
await app.start({ host: "127.0.0.1", port: options.port });
phase = "running";
},
async stop() {
if (phase === "stopped") return;
if (phase === "running") await app.stop();
phase = "stopped";
},
});
}The plugin's eventsUrl must point to the listening endpoints path before the test triggers a
message. This checked test owns the emulator runtime, the callback port, the Bolt process, and one
instance:
import { createServer } from "node:net";
import { slack } from "@localhost2137/slack";
import { defineConfig } from "localhost2137";
import { createTestRuntime } from "localhost2137/testing";
import { afterEach, describe, expect, it } from "vitest";
import { buildPingPongBot, type PingPongBot } from "../src/bot.js";
const runtimes: Array<Awaited<ReturnType<typeof createTestRuntime>>> = [];
const bots: PingPongBot[] = [];
afterEach(async () => {
await Promise.all(bots.splice(0).map((bot) => bot.stop()));
await Promise.all(runtimes.splice(0).map((runtime) => runtime.close()));
});
describe("official Slack Bolt ping-pong bot", () => {
it("receives ping and posts pong without Slack credentials or a workspace", async () => {
const botPort = await availablePort();
const config = defineConfig({
services: {
slack: slack({
config: {
botToken: "xoxb-local-ping-pong",
eventsUrl: `http://127.0.0.1:${botPort}/slack/events`,
signingSecret: "local-ping-pong-signing-secret",
workspaceName: "Ping Pong Local",
},
}),
},
});
const runtime = await createTestRuntime({ config, port: 0, storage: "temporary" });
runtimes.push(runtime);
const instance = await runtime.createInstance();
try {
const ada = await instance.slack.createUser({ name: "Ada" });
const channel = await instance.slack.createChannel({ name: "general" });
await instance.slack.addUserToChannel({ channel: channel.id, user: ada.id });
const bot = buildPingPongBot({
apiUrl: instance.slack.connection.apiUrl,
botToken: instance.slack.connection.botToken,
port: botPort,
signingSecret: instance.slack.connection.signingSecret,
});
bots.push(bot);
await bot.start();
await instance.slack.sendMessage({ channel: channel.id, from: ada.id, text: "ping" });
await instance.idle();
const messages = await instance.slack.listMessages({ channel: channel.id });
expect(messages.map(({ text }) => text)).toEqual(["pong", "ping"]);
expect(messages[0]).toMatchObject({ userId: "U000000" });
expect(messages[0]).not.toHaveProperty("eventId");
} finally {
await instance.destroy();
}
});
});
async function availablePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen({ host: "127.0.0.1", port: 0 }, resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Bot port reservation did not return an address.");
}
await new Promise<void>((resolve, reject) =>
server.close((cause) => (cause ? reject(cause) : resolve())),
);
return address.port;
}pnpm exec vitest run test/ping-pong.test.tsThe tested client is @slack/bolt 5.0.0. This establishes the signed message callback,
chat.postMessage reply, tracked delivery, and final history for that version—not every Bolt
feature or Slack method.
sendMessage and chat.postMessage each enqueue one event_callback when eventsUrl is configured.
The body contains a stable event_id, workspace and authorization metadata, and a compact message
event with channel, user, text, and timestamp. Thread replies include thread_ts; bot-authored
messages also include bot_id and subtype: "bot_message".
Every attempt includes X-Slack-Request-Timestamp and an X-Slack-Signature computed from the exact
body. The default attempt timeout is three seconds, and any 2xx response counts as success. The
plugin persists the attempt outcome and logs its event ID, attempt number, outcome, status, and next
deadline where applicable.
There are at most four attempts: the initial attempt, one retry due immediately, then retries due
after one minute and five minutes. There is no wall-clock scheduler. A due retry runs only during a
later positive virtual-time advance; any positive advance can run the immediately due retry. Retry
requests include X-Slack-Retry-Num and X-Slack-Retry-Reason. A non-2xx receiver response can stop
the sequence with X-Slack-No-Retry: 1.
Call await instance.idle() after the trigger to wait for the initial tracked delivery. A failed
attempt is still a failed test signal even though its retry was recorded; inspect plugin logs, fix or
advance according to the scenario, and do not replace that boundary with a sleep. Later retry
deadlines are not part of the current idle set.
Bolt checks callback timestamps against the real clock by default. Use real clock mode for an ordinary Bolt integration test. A pinned historical instant is useful for exact signature fixtures, but the receiver must then verify the supplied virtual timestamp under an explicit test policy.
Users, channels, membership, messages, and delivery attempts survive a persistent runtime restart. Local IDs are allocated in durable creation order. Message timestamps start from the instance clock and increase by at least one microsecond, so several messages created at one pinned instant remain unique and newest-first pagination remains stable.
Changing workspaceName or botToken takes effect when the runtime restarts: the stored bot and
workspace identities remain, while their configured name or credential is updated. Changing
eventsUrl does not retroactively create deliveries for messages written while it was null.
The plugin supports public channels and message events only. It does not implement HTTPS, OAuth, scopes, rate limits, enterprise/grid, direct messages, private or shared channels, files, reactions, blocks or attachments, presence, search, message editing or deletion, interactive components, slash commands, or socket mode.
Only the configured bot has a token. Control operations can act as local members so a test can trigger an inbound event, but those users cannot authenticate to the Web API. The bot identity is fixed, other IDs are allocated locally, and message timestamps come from the instance clock with monotonic microsecond tie-breaking. These are not provider-issued identities or timestamps. Treat behavior outside the tables above as unsupported and keep a provider-facing check when it matters to the application.