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:
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.
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:
allowBuilds:
better-sqlite3: trueInstall 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:
.localhost2137/localhost.config.tsKeep the runtime in its own terminal:
# Terminal 1
pnpm exec localhost devdev 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 seedThe 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.
Create the application file referenced by the dev script:
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 devThe 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.
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 --jsonThe 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.
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 reviewThe 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.
Create a temporary runtime and destroy every resource from the scope that owns it:
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.tsThis 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.
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.