Evaluate, mount, introspect, test, and upgrade an installed plugin without overstating what the runtime proves.
localhost2137 is a plugin runtime. Installing the runtime does not install a service emulator, and
installing a plugin package does not activate it. A project activates a plugin only by importing its
factory and placing the returned descriptor under a service key in localhost.config.ts.
There is no plugin registry or automatic package discovery. The project config is the complete inventory of configured services.
Use the installed plugin's exact package command. This guide uses the shipped Slack plugin as one concrete adoption path; the same runtime sequence applies to other plugins, but their config, connection values, operations, and compatibility surface come from their own package.
Merge the native dependency permission into the project workspace file before installing this plugin:
allowBuilds:
better-sqlite3: truepnpm add -D localhost2137 @localhost2137/slack hono@^4.13.4 zod@^4.4.3 vitestThis only installs packages. The plugin remains inactive until its factory is mounted under a
service key in localhost.config.ts.
Plugins are trusted local code, not sandboxed extensions. Importing the config imports every plugin it references into the runtime process. A plugin can use the same Node.js and operating-system permissions as that process.
Before mounting a package, check its source and provenance, the exact installed version, its declared runtime version range, and the network destinations its config can contact. Read its lifecycle and migration notes before pointing it at persistent worlds. A support matrix is useful; executable compatibility tests are stronger evidence.
context.storage.path() gives a cooperating plugin the supported isolated storage location. It is
not an operating-system sandbox around arbitrary package code.
Temporary test storage limits what world state survives the test; it does not reduce the privileges
of trusted code. Do not evaluate an untrusted plugin by assuming a temporary runtime makes it safe.
Evaluate one application behavior, not a provider logo. Write down the path the application needs:
| Question | Evidence to look for |
|---|---|
| Which SDK method, HTTP endpoint, or protocol does the application use? | A versioned support matrix and an executable test through that exact client or encoding. |
| Which endpoint and credentials can the application configure? | Package types and documented connection values. |
| What state must a test arrange, trigger, or inspect? | Documented operations and their live schemas. |
| Does the path emit callbacks or delayed work? | Signature, timeout, retry, persistence, idle(), and virtual-time rules. |
| Which data survives restart or changes across a package upgrade? | Persistence and migration tests using real historical state. |
| What is intentionally absent? | Specific unsupported endpoints or semantics, not “partial support.” |
Read documentation for the version actually installed in the project. A current website can describe a newer package. “SDK compatible” without a tested SDK version, method, and request path is not enough to choose an integration strategy.
Provider compatibility breaks a broad compatibility claim into routing, transport, data, state, delivery, client, and version claims. Choose the right test boundary helps decide whether the missing evidence belongs in this emulator at all.
Follow the plugin package's own factory and config types. The runtime does not infer a package from the key. Here is the complete config from the canonical Getting started executable 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"] }],
},
}),
},
});The chosen key becomes the route segment, storage namespace, CLI selector, and typed instance property for that mount. It does not have to equal the plugin ID. Renaming the key creates a new mount rather than moving the old state.
Before starting a long-lived daemon, validate the resulting project config and storage view:
pnpm exec localhost doctor --jsonThis command still imports the executable config, which is why the trust decision comes first. It can report config schema paths, connection environment collisions, stored service identity conflicts, and other project issues without mutating storage. It does not run the plugin lifecycle or prove the provider API.
For a first behavioral evaluation, prefer a focused test runtime with storage: "temporary" and
port: 0. That keeps experimental world state out of the project's persistent dev instance and
gives the test one cleanup owner. It is still a process-level trust boundary, not a sandbox.
Start one typed in-process world shows the owner pattern.
After starting the daemon, inspect the definition it actually loaded:
pnpm exec localhost describe --json
pnpm exec localhost describe slack --json
pnpm exec localhost exec slack --helpThe two describe forms return different projections:
localhost describe --json returns one summary per configured service: name,
description, pluginId, stateVersion, and operation keys. It does not include lifecycle
status.localhost describe slack --json returns name, description, and a
full metadata object for each operation. That metadata contains input and output schemas plus the
generated CLI representation. The scoped CLI output intentionally strips pluginId,
stateVersion, and backend status.Operation-specific help turns the same metadata into the invocation accepted by this installed
version. After discovering send-message in the scoped description:
pnpm exec localhost exec slack send-message --helpLive description is deliberately narrower than plugin documentation:
| It establishes | It does not establish |
|---|---|
| The live daemon accepted the selected instance and, for scoped output, resolved the configured service key. | That the service lifecycle is healthy or its public API can answer requests. |
| Which operation keys exist; scoped output also exposes their adapter schemas and CLI form. | That an operation models provider behavior correctly. |
| The plugin ID and stored-format target shown by the unscoped summary. | Which provider routes, connections, SDK versions, callbacks, or semantic edge cases are supported. |
describe does not enumerate provider routes or connection values. It is control-surface discovery,
not a compatibility manifest or health check. A seed_failed instance remains addressable and can
be described even though it requires recovery before another seed. The daemon holds the definition
loaded at startup, so restart it after changing the config or installed package before treating live
description as current.
Use the plugin's connection values at the application's existing provider boundary. In a typed test,
read them from instance[serviceKey].connection. For a daemon-managed application process, inspect
the actual exported environment for the selected instance:
pnpm exec localhost env --format jsonEnvironment output can include plugin-owned local credentials, so do not paste it into issue reports
or logs without redaction. The localhost dev ready message is safer for routine inspection: it
prints public service URLs and environment variable names, not connection values.
Prefer the application's normal SDK or HTTP client when the plugin documents that interface. Use a
plugin-supplied adapter only for a documented transport problem such as an SDK that fixes its origin
while the local service is path-scoped. Do not add a general if (localhost) branch that changes
business behavior.
This application reads the local world through the same Slack-shaped HTTP request it would put behind its service adapter:
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;
}pnpm exec localhost run -- pnpm devOperations remain outside production application code. They can arrange a local account, trigger an external actor or callback, and inspect state. They should not replace the provider-shaped request the test claims the application can make. Operations and emulated APIs shows how those interfaces share one world without proving the same thing.
A package import, healthy daemon, and valid operation call prove that the basic runtime contract works. They do not prove the application integration. The acceptance test should cross every application boundary named by the behavior:
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { createTestRuntime } from "localhost2137/testing";
import { expect, it } from "vitest";
import config from "../localhost.config.js";
const execFileAsync = promisify(execFile);
it("reads one seeded world through the provider-shaped HTTP API", async () => {
const runtime = await createTestRuntime({ config, port: 0, storage: "temporary" });
try {
const instance = await runtime.createInstance({ seed: true });
try {
const appPath = fileURLToPath(new URL("../src/read-workspace.ts", import.meta.url));
const { stderr, stdout } = await execFileAsync(process.execPath, [appPath], {
env: { ...process.env, ...instance.env },
});
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual([
{ id: "U000000", name: "localhost2137-bot" },
{ id: "U_ADA", name: "Ada" },
]);
} finally {
await instance.destroy();
}
} finally {
await runtime.close();
}
});pnpm exec vitest run test/read-workspace.test.tsinstance.idle() and advance time only when the documented policy requires
it.When persistence matters, add a restart test. When isolation matters, run two simultaneous instances and make their expected states differ. When callbacks matter, test the exact signature, response classification, duplicate identity, and retry boundary the plugin claims. A happy operation result cannot stand in for any of those checks.
Two service keys can mount the same plugin factory with different config. Every instance then owns separate durable state for each key. This is useful for two accounts or regions, but application wiring must remain explicit.
Plugins choose their environment variable names. If two mounts export the same name, config
resolution fails rather than silently choosing one value. Set exportEnv: false on one or both
mounts and pass typed connection values manually. This changes only the merged environment; routes,
operations, and instance[serviceKey].connection remain available.
Do not infer callback isolation from separate storage. If the installed plugin defines one callback destination in mount config, every instance of that mount shares the configured destination. Another plugin may define a different routing or correlation contract; inspect it before running callback scenarios concurrently. The runtime does not add an instance header or rewrite callback URLs on the plugin's behalf. Read Parallel receivers require a routing rule for the resulting choices.
Changing the installed package does nothing to an already running daemon. On restart, every
persistent world is reconciled against the new config before readiness. An increased stateVersion
invokes the plugin's migration for older stored data; a failed migration can be corrected and
retried from the recorded old version. A downgrade below stored state is rejected.
stateVersion describes durable storage only. The same value says nothing about route, operation,
connection, callback, or SDK compatibility. Re-run the application-facing acceptance test after an
upgrade even when no migration runs.
Test an upgrade against temporary or otherwise disposable state before applying it to a persistent world that matters. Do not use reset as a rollback: reset replaces service data. If startup reports a plugin ID or future-version conflict, restore compatible code or config and diagnose it before choosing to discard the world. What changes do to existing worlds lists the exact restart effects.
When the scenario fails, separate four possible gaps:
Report the installed package version, service key, instance ID, documented claim, redacted request or operation, observed result, and relevant correlation ID. Do not add an invented provider route or test-only response around the plugin to make the application pass. A narrow unsupported result is a useful product decision; a hidden compatibility patch is not.
Use Diagnose failures by boundary to gather the runtime evidence, or build your first plugin when no installed plugin owns the behavior you need.