Assemble localhost.config.ts and look up discovery, defaults, service identity, environment, seed, and persistent-world behavior.
localhost.config.ts is executable project configuration and the template used by every instance in
one runtime. This complete config is checked by the getting-started example:
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"] }],
},
}),
},
});defineConfig() preserves service and operation types. Runtime validation still runs before storage
opens or the server binds.
Terminal 1:
pnpm exec localhost devTerminal 2:
pnpm exec localhost seed
pnpm exec localhost env --instance dev --format jsondev creates an empty persistent dev world when needed. seed applies the configured Slack
users and channels once. env prints the application-facing connection values computed for that
specific instance; the runtime control token is never included.
| Field | Accepted value | Default and consequence |
|---|---|---|
services | Required map of service keys to plugin-factory results; {} is valid | No default. Every instance mounts the same map. |
host | "127.0.0.1", "localhost", or "::1" | "127.0.0.1"; non-loopback hosts are rejected. |
port | Integer from 1 through 65535 | 2137 |
storage | { dir: string }, non-empty after trimming | { dir: ".localhost2137" }; relative to the selected config file. |
clock | { mode: "real" } or { mode: "pinned", startAt } with an RFC 3339 offset | { mode: "real" }; see Virtual time. |
seed | Function receiving the typed scenario facade | No scenario seed. Plugin seed data is independent. |
The port: 0 used by createTestRuntime({ port: 0, storage: "temporary", config }) is an option on
the test-runtime owner, not a valid localhost.config.ts port. It requests an OS-assigned port;
leave config.port omitted or between 1 and 65535.
Top-level fields and plugin envelopes are strict. Unknown keys and invalid values produce
CONFIG_INVALID, possibly with several paths such as $.services.slack.config.botToken.
Without --config, the CLI walks upward from its working directory and selects the first supported
name in this order:
localhost.config.ts
localhost.config.mts
localhost.config.cts
localhost.config.js
localhost.config.mjs
localhost.config.cjspnpm exec localhost --config ./config/local.ts doctor --json
pnpm exec localhost --config ./config/local.ts dev| Input | Resolution rule |
|---|---|
| Discovered config | First supported name found while walking upward |
--config ./config/local.ts | Relative to the command's working directory; discovery is skipped |
storage: { dir: ".state" } | Relative to the selected config file, not the command's working directory |
The module must default-export a config. TypeScript, ESM, CommonJS, local imports, and top-level
await follow the selected file's module form. Import failure is CONFIG_IMPORT_FAILED; a missing
default export is CONFIG_DEFAULT_EXPORT_MISSING.
Config loads once when the daemon starts. Saving it does not update the running process. Restart after any change; control commands reject a daemon whose stored config fingerprint differs from the currently resolved project config.
In the complete file above, slack defines all four of these names:
| Surface | Result |
|---|---|
| Public route | /{instance}/slack/* |
| CLI | localhost describe slack, localhost exec slack ... |
| Typed test handle | instance.slack |
| Durable namespace | The slack service state below that instance |
Keys start with a lowercase letter, contain only lowercase letters, digits, or hyphens, and are at
most 63 characters. _, clock, destroy, env, idle, reset, and seed are reserved. Access
a hyphenated key with brackets: instance["primary-api"].
Mounting one plugin under two keys creates two independent service states in every instance. Renaming a key is therefore not a migration: the new route and state appear, the old route and operations disappear, and old stored data remains. Move data only through a deliberate migration the plugin supports.
Each plugin owns schemas for its config and optional declarative seed. Defaults and transforms
run during config resolution. Accepted data is copied, deeply frozen, and must end as JSON-compatible
plain data: null, booleans, finite numbers, strings, dense arrays, and plain objects. Date, Map,
class instances, undefined, sparse arrays, and cycles are rejected.
The runtime's received diagnostic records the value's type, not its value. Plugin-authored schema
messages remain plugin-authored text, so use the issue path instead of logging a config object that
may contain credentials.
For each instance, a plugin computes typed connection values and string-valued env. Environment
names must be uppercase shell names such as SLACK_API_URL. Duplicate exported names fail
connection resolution and name both owners. exportEnv: false suppresses only that mount's merged
environment entries; its routes, operations, and typed connection stay available.
The daemon writes the persistent dev projection to .env below storage.dir—by default,
.localhost2137/.env. localhost env renders any selected instance; localhost run -- <command>
injects the same projection into one child. The runtime control token is stored separately.
Callback destinations are ordinary plugin config and do not vary per instance. Parallel callback receivers therefore need an application routing rule; see Callbacks and webhooks.
Plugin seed data creates each service's baseline. The optional top-level function runs afterward and can connect those services through typed operations. This complete checked config sends one message after the Slack users and channel exist:
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-seeding-guide",
eventsUrl: null,
signingSecret: "local-seeding-guide-signing-secret",
workspaceName: "Seeded workspace",
},
seed: {
users: [{ id: "U_ADA", name: "Ada" }],
channels: [{ id: "C_GENERAL", name: "general", members: ["U_ADA"] }],
},
}),
},
seed: async (world) => {
await world.slack.sendMessage({
channel: "general",
from: "Ada",
text: "baseline ready",
});
},
});Seed runs only when requested by localhost seed, instance create --seed, instance reset --seed,
or the equivalent test API. A successful or failed in-place seed cannot run again until reset.
Seed test worlds covers ordering and recovery.
| Config change after daemon restart | Existing persistent world |
|---|---|
host or port | Rebind endpoint; recompute connection values. |
Plugin config under the same key | Start with new validated config; retain durable service state. |
| Add a service key | Create its state before the world becomes ready. |
| Remove a service key | Remove route, operations, and connection; retain its stored data. |
| Change plugin ID under an existing key | Fail startup instead of opening another plugin's state. |
Increase plugin stateVersion | Run the plugin's update migration before start; fail if unavailable. |
Decrease plugin stateVersion | Fail instead of downgrading stored state. |
Change clock | New/reset worlds use it; existing worlds retain current clock state. |
| Change plugin or scenario seed | No automatic mutation; the next eligible explicit seed uses it. |
Change storage.dir | Select a different storage root; old data is not moved. |
Removing or renaming a service while it still owes an acknowledgement for a committed time window prevents that world from completing startup. Restore the prior service config and finish reconciliation; committed time recovery shows the exact failure contract.