First-party plugins

Slack plugin

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

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: true
pnpm add -D localhost2137 @localhost2137/slack hono@^4.13.4 zod@^4.4.3
pnpm add @slack/bolt@5.0.0

Omit the second command when the application already has that exact tested client version.

Configure and seed the workspace

localhost.config.ts
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 fieldContract
workspaceNameRequired non-empty name. It appears as the team name in auth.test.
botTokenRequired token beginning with xoxb-. It is the only Web API credential the plugin creates.
signingSecretRequired non-empty secret used for Events API signatures.
eventsUrlAbsolute 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.

Connection values

Each instance exposes these typed values under instance[serviceKey].connection:

ValueEnvironment projectionUse
apiUrlSLACK_API_URLInstance-scoped URL ending in /api/; pass it to Bolt as clientOptions.slackApiUrl.
botTokenSLACK_BOT_TOKENBearer token for the local installed bot.
signingSecretSLACK_SIGNING_SECRETSecret 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.

Supported Web API

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.

MethodSupported inputs and behavior
auth.testReturns the configured workspace and installed bot identity.
users.listReturns 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.listReturns public channels, membership, and member counts; accepts types=public_channel, exclude_archived, limit, and cursor. All local channels are unarchived.
conversations.membersRequires an exact channel ID; returns member IDs with limit and cursor.
conversations.historyRequires an exact channel ID and bot membership; returns newest-first messages. Supports oldest, latest, inclusive, limit, and cursor.
chat.postMessageRequires 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.

Control operations

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.

OperationInput and effect
createUsername, optional admin; returns the allocated user. Names are case-sensitive, trimmed, and limited to 80 characters.
createChannelname; 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.
addUserToChannelchannel and user, each an ID or exact name; returns added: false when membership already exists.
sendMessagechannel, from, text, optional threadTs; requires membership and returns the persisted message plus eventId, which is null when delivery is disabled.
listMessageschannel, 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 --json

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

Bolt wiring

src/bot.ts
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:

test/ping-pong.test.ts
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.ts

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

Events and retries

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.

Persistence and ordering

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.

Deliberate differences

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.