Plugins

Using plugins

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.

Install the runtime and plugin

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:

pnpm-workspace.yaml
allowBuilds:
  better-sqlite3: true
pnpm add -D localhost2137 @localhost2137/slack hono@^4.13.4 zod@^4.4.3 vitest

This only installs packages. The plugin remains inactive until its factory is mounted under a service key in localhost.config.ts.

Trust the package before importing it

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.

Translate the application path into requirements

Evaluate one application behavior, not a provider logo. Write down the path the application needs:

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

Mount it explicitly

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:

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

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 --json

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

Inspect the live control contract

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 --help

The two describe forms return different projections:

  • Unscoped localhost describe --json returns one summary per configured service: name, description, pluginId, stateVersion, and operation keys. It does not include lifecycle status.
  • Scoped 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 --help

Live description is deliberately narrower than plugin documentation:

It establishesIt 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.

Preserve the application interface

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 json

Environment 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:

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;
}
pnpm exec localhost run -- pnpm dev

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

Prove the behavior, not the installation

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:

test/read-workspace.test.ts
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.ts
  1. Create an isolated world owned by the test.
  2. Arrange only the surrounding state through documented operations or seed data.
  3. Exercise the application through its normal SDK, HTTP client, callback handler, or public input.
  4. Await plugin work with instance.idle() and advance time only when the documented policy requires it.
  5. Assert application behavior first, then inspect emulator state when the claim includes it.
  6. Destroy the test-owned world and close its runtime in guaranteed cleanup.

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.

Mounting and environment values

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.

Upgrade without confusing storage and compatibility

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.

Report a precise gap

When the scenario fails, separate four possible gaps:

  • Wiring: the application used the wrong instance URL, mount key, credential, or callback destination.
  • Control: the plugin lacks an operation or seed field needed to create a useful local world.
  • Compatibility: a provider route, encoding, response, SDK behavior, callback, or retry rule is absent or inaccurate.
  • Runtime: generic mounting, isolation, lifecycle, operation adaptation, tracked work, or control behavior fails independently of one provider rule.

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.