Getting started

Define one local service world, run it, connect an application, control isolated instances, and turn the same boundary into a test.

This is the whole shape before the details. You need Node.js 24 or later and pnpm. Start by creating the runtime template your project will run:

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"] }],
			},
		}),
	},
});

services can contain any installed emulator plugin. Slack makes this crash course concrete; the runtime itself is service-agnostic. The config is one world template, not a list of instances.

Install the runtime and one plugin

The Slack plugin uses better-sqlite3. In a new project, create this project-scoped pnpm policy. In an existing workspace, merge allowBuilds into the existing top-level file:

pnpm-workspace.yaml
allowBuilds:
  better-sqlite3: true

Install the runtime, plugin, runtime host peers, and the test runner used below. Add a development script for the small application file from the next section:

pnpm add -D localhost2137 @localhost2137/slack hono@^4.13.4 zod@^4.4.3 vitest
pnpm pkg set scripts.dev="node ./src/read-workspace.ts"

Nothing is activated by installation. localhost.config.ts imports the plugin and gives its mount the key slack; that key becomes the URL, CLI target, storage namespace, and typed test property.

Ignore the runtime's project-local state before starting it:

.gitignore
.localhost2137/

Run localhost.config.ts

Keep the runtime in its own terminal:

# Terminal 1
pnpm exec localhost dev

dev discovers localhost.config.ts, starts its configured emulators on loopback, and creates the persistent dev instance when it does not exist. Configured seed data is never automatic. Apply it from a second terminal:

# Terminal 2
pnpm exec localhost seed

The local Slack API now lives below http://127.0.0.1:2137/dev/slack. Stopping and restarting the daemon preserves this world; reset or destroy it only when you mean to replace it.

Point normal application traffic at the world

Create the application file referenced by the dev script:

src/read-workspace.ts
type UsersListResponse =
	| { members: Array<{ id: string; name: string }>; ok: true }
	| { error: string; ok: false };

const apiUrl = requiredEnvironment("SLACK_API_URL");
const botToken = requiredEnvironment("SLACK_BOT_TOKEN");
const response = await fetch(new URL("users.list", apiUrl), {
	headers: { authorization: `Bearer ${botToken}` },
});
const payload = (await response.json()) as UsersListResponse;

if (!response.ok || !payload.ok) {
	throw new Error(
		`Local Slack request failed: ${"error" in payload ? payload.error : response.status}`,
	);
}

console.log(JSON.stringify(payload.members.map(({ id, name }) => ({ id, name }))));

function requiredEnvironment(name: string): string {
	const value = process.env[name];
	if (!value) throw new Error(`Missing ${name}. Run this command through localhost run.`);
	return value;
}

Inspect the selected instance's app-facing projection, then run the application with it:

pnpm exec localhost env --format json
pnpm exec localhost run -- pnpm dev

The application uses ordinary provider-shaped HTTP. SLACK_BOT_TOKEN is a local credential from the config, not a provider-issued API key. localhost run overlays plugin connection values on the child process's inherited environment. localhost2137 does not add the runtime control token, and it does not remove values already present in the parent environment.

For an existing SDK or application adapter, replace only its endpoint and credential inputs with the installed plugin's connection values. Do not move control operations into application code. See Run an existing application for the longer migration path and Provider compatibility before relying on a plugin's supported surface.

Control the world outside the application

Discover the operations exposed by the installed plugin, trigger one local actor, then inspect the result:

pnpm exec localhost describe slack --json
pnpm exec localhost exec slack --help
pnpm exec localhost exec slack send-message \
  --channel general --from Ada --text "hello from a local user" --json
pnpm exec localhost exec slack list-messages --channel general --json

The CLI is generated from plugin operation schemas. Here send-message acts as Ada; it is not a fake Slack endpoint for application code. The app remains on Slack-shaped HTTP while a developer, test, or coding agent can arrange, trigger, and inspect the same state. Operations and emulated APIs develops that boundary.

Create another isolated world

One runtime can host several path-addressed instances from the same config:

pnpm exec localhost instance create review --seed
pnpm exec localhost exec slack send-message \
  --instance review --channel general --from Ada --text "review world" --json
pnpm exec localhost env --instance review --format json
pnpm exec localhost run --instance review -- pnpm dev
pnpm exec localhost clock status --instance review --json
pnpm exec localhost clock advance 1h --instance review --json
pnpm exec localhost instance destroy review

The review route, plugin state, clock, logs, and storage are independent from dev. Both still use the same plugin config and callback destination. The Slack setup above has no time-driven work, so advancing its clock only demonstrates instance-scoped time control. The Stripe plugin has a concrete 30-day renewal example; Virtual time and asynchronous work covers effects, completion, and recovery.

Own the same boundary in a test

Create a temporary runtime and destroy every resource from the scope that owns it:

test/owned-runtime.test.ts
import { createTestRuntime } from "localhost2137/testing";
import { expect, it } from "vitest";
import config from "../localhost.config.js";

it("arranges a user with an operation and reads it through Slack-shaped HTTP", async () => {
	const runtime = await createTestRuntime({
		config,
		port: 0,
		storage: "temporary",
	});

	try {
		const instance = await runtime.createInstance({ seed: true });
		try {
			const grace = await instance.slack.createUser({ name: "Grace" });
			const response = await fetch(new URL("users.list", instance.slack.connection.apiUrl), {
				headers: {
					authorization: `Bearer ${instance.slack.connection.botToken}`,
				},
			});

			expect(response.status).toBe(200);
			await expect(response.json()).resolves.toMatchObject({
				members: [
					{ id: "U000000", name: "localhost2137-bot" },
					{ id: grace.id, name: "Grace" },
					{ id: "U_ADA", name: "Ada" },
				],
				ok: true,
			});
		} finally {
			await instance.destroy();
		}
	} finally {
		await runtime.close();
	}
});
pnpm exec vitest run test/owned-runtime.test.ts

This test creates its own server on an OS-assigned port, requests configured seed explicitly, and crosses Slack-shaped HTTP before asserting. Typed operations are available as instance.slack.<operation>() for test setup and inspection; they do not replace the application request the test claims to prove. Integration testing covers reuse, parallel workers, asynchronous work, and cleanup failures.

Continue with callbacks

The crash course keeps callbacks disabled. The checked Slack Bolt workflow contains the complete application adapter and end-to-end test for signed Events API delivery, an SDK response, instance.idle(), and guaranteed cleanup.

Read Callbacks and webhooks before parallel callback tests, or continue to Integration testing to reuse runtimes safely. Use Seed test worlds before making a shared baseline larger.