Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Guides

Plugins

A plugin is an ordinary package that contributes a concrete implementation of an existing Loom seam (a store, dispatcher, worker, telemetry adapter, renderer, LLM provider, protocol client) and/or a preset — a pure function returning a Partial<LoomConfig> (or a singleton value like a StorageConfig). It is statically imported and passed to defineConfig. The runtime never learns plugins exist; after boot the wiring is byte-identical to hand-wiring.

There is no plugin runtime, no discovery, no hook bus, no setup() lifecycle — those were rejected (see ADR 0006, ADR 0043). “Using a plugin” is always an explicit import plus one line in your config.

The contract

A Loom plugin:

  1. implements an existing SPI and/or exports a preset (opts) => Partial<LoomConfig>;
  2. is statically imported and passed to defineConfig — no node_modules scanning, no config-string resolution (P5);
  3. contributes only to existing seams — it never adds a kernel hook and never injects a reducer into the replay fold (durable custom facts are ordinary app events folded by user-space projections);
  4. is byte-identical to hand-wiring after boot (zero runtime overhead);
  5. ships conformance tests for each SPI it implements (@loom/conformance);
  6. is sugar, never a gate — everything it does, you can also hand-wire into defineConfig.

Should it exist at all?

The contract above says what a plugin is; ADR 0043’s litmus says whether a thing is a plugin or belongs in the kernel. Neither asks whether it should be first-party surface, which is a separate question with its own rule:

The second-consumer rule (ADR 0083) — before a workload-derived requirement becomes a 1P package, a new @loom/verify rule, a new seam, or an exported vocabulary, name a second unrelated consumer of the same mechanism. If you cannot, it is application code: land it in the consuming app (or loom-examples) and say what would change the verdict.

It gates 1P surface only — a 3P plugin is its author’s business — and it is applied in review, against a claim written in the proposing ADR, not by the author’s own judgement. “Obviously generic” is a missing answer, not a passing one.

First-party (1P) vs third-party (3P)

The split is organisational, not mechanical — both use the identical contract and the kernel treats them the same.

First-party (1P)Third-party (3P)
Homethis repo, plugins/the loom-plugins community repo — or any repo you like
Name@loom/plugin-<name>loom-plugin-<name> / @scope/loom-plugin-<name> (npm keyword loom-plugin)
Depends onthe workspace SPI packagesthe published @loom/* SPI packages
Conformancegated in Loom CIrun the published vectors in your own CI
Trustvetted by coreyour supply-chain responsibility (see Security)

A 3P plugin can be promoted to 1P when it meets the 1P bar (maintained by/with core, conformance-gated, security-reviewed).

The public barrel (@loom/sdk) re-exports 1P plugins

Because 1P plugins are the curated set, @loom/sdk re-exports them — curation, not a runtime privilege (a 3P plugin wires through the identical seams, it just isn’t ours to vouch for). To keep a bare import "@loom/sdk" lean and browser-safe, native/heavy plugins are re-exported via a subpath, not the main barrel:

  • Light 1P plugins (protocols, renderers) → import { … } from "@loom/sdk" — today @loom/plugin-a2a, @loom/plugin-mcp, @loom/plugin-renderer-terminal.
  • Native/heavy 1P plugins → their own subpath: import { SqliteEventStore } from "@loom/sdk/sqlite", import { postgresStorage } from "@loom/sdk/postgres", import { sandboxEval$ } from "@loom/sdk/sandbox". The sandbox subpath also carries the backend SPI (SandboxBackend, SandboxEffect, registerSandboxBackend, ADR 0144); a third-party backend package depends on @loom/sandbox directly instead, since the subpath loads QuickJS too.

Plugins outside the barrel (renderers with a framework peer dependency, the compute adapters, the UI-protocol plugins) are imported by their own package name — being outside the barrel is a packaging call about weight and peer deps, not a tier.

The barrel also re-exports a few non-plugin packages that are part of the public surface — notably @loom/registry (AgentRegistry/globalAgentRegistry, the agent-card registry the A2A peer client reads), which has no plugin of its own but is imported through @loom/sdk.

3P plugins are never in the barrel; you import them by their own name. Both remain explicit imports, so the 1P/3P distinction stays purely organisational.

Current first-party plugins

Twenty, grouped by the seam each one fills. Storage and data first, then protocols, surfaces, and compute.

Storage & data

  • @loom/plugin-sqlite — embedded SQLite Event/Snapshot/Branch/Timer/DurableState/ ReadModel/Content stores + the sqliteStorage() preset.
  • @loom/plugin-postgres — network Postgres stores (LISTEN/NOTIFY) + postgresStorage(). Bring your own pool: the stores accept any object satisfying PoolLike — query, connect, end, and optionally options — so a pool from a different copy of pg (the normal state for a link:/file: consumer, where each side resolves its own), or an API-compatible one from a serverless driver or an instrumented wrapper, is borrowed rather than refused. A borrowed pool is never end()ed by a store’s close(). postgresPool() is still exported for a caller who wants a real pg.Pool from this package — the sizing surface (totalCount and friends) that PoolLike deliberately omits. Without options, the dedicated LISTEN session cannot be opened and cross-node delivery degrades; the log stays authoritative and same-process subscribers are unaffected.
  • @loom/plugin-crypto — field-level crypto-shredding store wrappers (K10) + KeyStore SPI (the optional KeyStore.wasDestroyed is the destruction proof erasure asks for). Also EncryptingContentStore, which seals each claim-checked blob under its owning subject’s key at a subject-scoped address (ADR 0143), and EncryptingArchiveStore, which seals the compaction/inbox archive under the same KeyStore. EncryptingEventStore seals each message’s sender under an opaque handle by default (senderSubjectOf: false opts out, ADR 0139).
  • @loom/plugin-content — content-store seam: ContentResolvingEventStore externalizes marked payload fields to a content-addressed side store + ContentStore SPI (ADR 0046). SPI 1.5.0 adds an optional ContentAccess ({ executionId, workflowId }) argument to put/putStream/get/getMany, so a store can seal per owner (ADR 0143); a plain store ignores it.
  • @loom/plugin-durable-state — typed, versioned, contract-migratable keyed carry state as RECORDED effects (replay folds the recorded value, never the store) + CAS revisions and the sentinelState() schedule-driven convenience (ADR 0053). SQLite/Postgres backends ship in their storage plugins.
  • @loom/plugin-read-model — durable, checkpointed cross-execution read models: app-declared extractors index domain events into a persisted, queryable store (kind + field-equality, keyset-paged); convergent ingestion from the live onEvent tail + watermark-incremental catch-up; a cache of the log, never an authority (ADR 0058). Also durable per-execution folded-state models, see “Folded-state models” below (ADR 0132).
  • @loom/plugin-audit-chain — tamper-evident hash-chained EventStore wrapper. Wrap it outside ContentResolvingEventStore if you use both — inverted, the chain hashes only the content ref and a rewritten blob verifies clean (see the plugin README).

Composition patterns

  • @loom/plugin-sentinel — sentinelPreset(): the perpetual-agent pattern (schedule + carry state + claim-check + K10.6 pin policy) as one composition-time declaration, plus the loom new sentinel scaffold.
  • @loom/plugin-sandbox — hardened QuickJS-in-WASM eval of content-addressed, LLM-authored reshaping scripts: in-engine memory/CPU/wall-clock limits, no ambient I/O by construction, output shape-hash validation; scriptMissing/limitExceeded/shapeMismatch/scriptError are typed channels so a planner re-plans and replay never re-burns CPU. scriptError { name?, message } is the script’s own throw (incl. a SyntaxError or a non-Error value), bounded and stack-free; worker-pool, integrity and cancellation failures stay untyped (ADR 0064). It is the reference backend of the SandboxBackend SPI in @loom/sandbox (QuickJsSandboxBackend, in-process or worker pool; QuickJS stays the default engine): to run model-written code in a different isolation boundary, see Write your own sandbox (ADR 0144).

Protocols

  • @loom/plugin-mcp — MCP client + server. Client: dynamic dispatch — mcpInvoke$ mints steps whose tool name/args/schema-pin are runtime data through ONE registered mcp.invoke effect; pinned jsonSchemaHash verified per call → typed schemaDrift/toolNotFound channels; principals ride ctx.principal/secretRef, never the log; MCP_DISPATCHED + mcpDispatchIndex projection for the “which executions called tool X at schema H” query (ADR 0063). An in-band isError throws rather than returning a failure as a value. A step yields the convenience projection (structuredContent, else joined-and-parsed text) by default and the whole CallToolResult under resultFidelity: "full" — the only shape in which _meta and non-text content blocks survive, and therefore the only one in which a UI declaration is visible to a flow. Server: createMcpServer exposes Loom tools/resources to any MCP host (the createA2AServer analogue) with audience-scoped listing/dispatch, and servedEffect routes a call by its declared seam. Secure by default (R12): it refuses to be created without authenticate/authorize (or an explicit insecure: true), binds loopback, and takes authorizeTool for per-tool exposure; on the client side a grant can name ONE tool (mcp.invoke:<server>/<tool>). The transport answers OAuth-discovery probes under /.well-known/ with 404 above the authorize gate, and negotiates the protocol version on initialize rather than asserting it — the two things a hosted client settles before it will talk to a remote server at all. Also carries MCP Apps (io.modelcontextprotocol/ui) declarations and ui:// resources with content-hash provenance — carry, never render — writing both the nested and the deprecated flat _meta UI form on egress and accepting either on ingress, because the reference SDK emits both (ADR 0072). A dispatch whose result declares a UI records MCP_UI_OBSERVED with the resource URI and an explicit pin — embedded (inlined, hashed for free), resolved (resolveUi fetched it), not-requested, or unresolvable — so “no pin” is never ambiguous between not asked for and asked for and unavailable.
  • @loom/plugin-mcp-app — MCP Apps (SEP-1865) egress: projects a Loom agent surface onto the extension as ONE static, content-pinned surface-host template whose payload is the existing A2UI catalog model — so MCP Apps is a second transport binding for a surface protocol Loom already ships, not a second UI protocol, and the rendered UI is a pure function of (pinned template hash, logged structuredContent). The extension has no server push, so an ambient/app-visibility loom_surface_since cursor over EventStore.readRange is the View’s refresh channel; seenSequence + isStale carry decision provenance across a long suspension. Egress redaction is required-by-configuration, never defaulted. The host role (iframe, CSP, postMessage) is deliberately out of scope (ADR 0072).
  • @loom/plugin-a2a — Agent-to-Agent protocol, client + server. A2APeerClient discovers a remote agent’s card and registers it as an ordinary Loom tool; createA2AServer publishes your flows as an A2A agent (/.well-known/agent.json, /tasks/send) with LoomAgentExecutor mapping execution status onto A2A task state. Secure by default (R12): it refuses to be created without authenticate/authorize (or an explicit insecure: true), binds loopback, and scopes every task id to the authenticated caller, so one caller cannot read or re-drive another’s task.

Conversations

  • @loom/plugin-conversation-sse — the authenticated end-user edge of @loom/conversation (ADR 0122): start a conversation, send turns, and stream the durable messages and live reply deltas over Server-Sent Events, resuming from Last-Event-ID (the SSE id is the history cursor, so it works across rotations). Fails closed: authentication is required to create the server, a conversation the caller may not touch is indistinguishable from one that does not exist, loopback by default, Origin-checked, bounded per connection. Opt-in reasoning option (ADR 0133 D6) serves the model’s reasoning (reasoning / reasoning-partial events and a durable GET …/reasoning) to the turn’s asker and the conversation’s owner only.

Surfaces

  • @loom/plugin-a2ui — projects a Loom agent surface onto the Google A2UI wire protocol v1.0 (createSurface/updateDataModel/deleteSurface), so any A2UI-compliant client renders a Loom-driven UI. Egress + inbound actions + callFunction bidirectional RPC — the client’s result re-enters as a recorded fact under a deterministic callId, which is what keeps a client-invoked function inside Loom’s replay grain (ADRs 0069–0070). Continuous two-way binding sync is out of scope.
  • @loom/plugin-renderer-react — React blueprint renderer (defaultRegistry); React is a peer dependency, so it stays out of the @loom/sdk barrel. Placed grid cells get explicit grid-column/grid-row, titled layouts a <section> with an h2 (ADR 0098). A layout’s role is ignored — it draws the plain container (ADR 0099). An empty binding status draws a role="status" “no data” marker in place of the widget (ADR 0100). A layout’s id is its container’s React key, so a container inserted or reordered around it keeps its widgets’ DOM; the markup is unchanged (ADR 0101).
  • @loom/plugin-renderer-terminal — string/terminal blueprint renderer (renderBlueprintToString), the second surface that proves blueprint delivery is renderer-agnostic rather than React-shaped. Kind-aware when given a registry — defaultTerminalRegistry covers the eight built-in kinds and an unregistered kind is marked [kind] on the surface rather than flattened silently (ADR 0096). A widget with a declared binding status renders the canonical ⋯/~/✗/○ glyph (○ no data, ADR 0100) plus, when source.degradedFrom is set, a trailing (degraded from …) suffix — absent either, the line is unchanged (ADR 0097). Placed grid cells print in reading order and a titled layout under an underlined heading (ADR 0098); a layout’s role or id changes nothing in the output (ADR 0099, ADR 0101). A widget whose source.redacted is true (IR 2.5.0, ADR 0097 amendment) is the single line ⊘ redacted on this surface and the React and DOM ones: no title, kind or value, whatever the widget carries.
  • @loom/plugin-renderer-dom — framework-free DOM renderer (renderBlueprintToDom, live mountBlueprint), the web surface for an app that wants neither React nor loom-ui. Covers the same eight kinds as the other two (asserted by a three-way parity test); builds every element through an allowlisting h() (no innerHTML, href through safeUrl), ships no CSS and no runtime dependency beyond blueprint-runtime and projection-runtime, and is not in the @loom/sdk barrel (ADR 0127).

Compute

  • @loom/plugin-worker-remote — RemoteWorkerRuntime: dispatches an ExecutionPackage to a stateless worker’s POST /execute and collects the ExecutionResult.
  • @loom/plugin-worker-server — createWorkerServer: the other half, and the container entrypoint for remote/Cloud Run execution — runs one ExecutionPackage on an injected WorkerRuntime.
  • @loom/plugin-cloudrun — Cloud Run adapter: CloudRunWorkerRuntime maps DispatchMetadata onto a sizing config and resolves a service endpoint, with cloud specifics behind the CloudRunPlatform SPI (StaticCloudRunPlatform runs the same path offline) (ADRs 0001–0005 (loom/docs/adr/)).

Folded-state models (@loom/plugin-read-model, ADR 0132)

A folded model keeps one durable state per execution, folded with an ordinary projection$ reducer. Like every read model it is a cache of the log: rebuild equals incremental, and nothing reads it on replay. Wiring (the DocMind shape):

const service = new ReadModelService(new PostgresReadModelStore(ingestPool, { schema: "docmind" }), {
  liveSource: eventStore,
  sensitive: SENSITIVE,            // sensitivePathsFromSteps(steps) UNION sensitiveMapFromCovers(enc.sealingCapability().covers), built after defineConfig
  foldCache: { maxEntries: 1024 }, // the default; `false` disables
  onError: (err) => logger.error(err),
});
await service.registerFolded({
  projection: ingestStatus, // projection$({ id, version: "1", initialState, handlers })
  keyOf: (executionId) => /ingest-(.+?)(?:\.\d+)?$/.exec(executionId)?.[1],
  // DocMind execution ids are `u_<userId>:<name>`: scope = userId. The app's RLS policy maps scope -> tenant
  // through its own table; Loom stays generic and `scopeOf` stays synchronous.
  scopeOf: (executionId) => /^u_([^:]+):/.exec(executionId)?.[1],
});
const app = defineConfig({ /* … */ observability: { onEvent: (e) => service.handleEvent(e) } });
await service.catchUp([eventStore]);                       // boot: heals + re-folds after a version or sensitive-map change

// request path: a read-only RLS role reads through the STORE (no registerFolded, no writes)
const readerStore = new PostgresReadModelStore(readerPool, { schema: "docmind", manageSchema: false });
const records = await readerStore.getFoldedByKey("ingest-status", pageOfDocumentIds); // FoldedRecord[]: { executionId, key, scope, sequence, state, erased }
const states = foldedStates(records); // pure: drops erased and null-state rows, like service.getMany
const one = await readerStore.getFolded("ingest-status", [executionId]);              // by execution id
// on the writer service, right after a step the user just caused (one log-tail read, only for the execution that action touched):
const fresh = await service.getMany("ingest-status", [executionId], { refreshFrom: [eventStore] });

ingestPool, readerPool, eventStore, SENSITIVE, logger, ingestStatus, pageOfDocumentIds and executionId are the app’s own. The service path (registerFolded, getMany, getManyByKey, refresh and refreshFrom read-repair) writes the fold header and states, so it needs write rights on the folded tables; a read-only role uses the store methods above.

SENSITIVE here is the union of sensitivePathsFromSteps(steps) and sensitiveMapFromCovers(enc.sealingCapability().covers), computed after defineConfig: defineConfig also registers the framework-sealed paths (EXECUTION_STARTED principal.identity.*, OUTCOME_RECORDED.recordedBy, inbox payload), which only the store’s covers list.

Boot order: await registerFolded(...) (may rebuild) -> wire onEvent -> catchUp([store]). getMany / getManyByKey accept at most MAX_GET_MANY (1000) keys per call.

  1. Sensitive state. Handlers receive a copy of each event in which every declared or sealed-marked path is replaced by "[redacted]". A state that holds a sealed envelope, or a value copied from a sensitive path of the event being folded, is refused with SensitiveStateError (model, execution, event, state path; never the value) and the watermark does not advance. onError never receives the payload or metadata, only ids, type and sequence. The guard cannot catch: sensitive data in an undeclared field, a transformed copy (hash, truncation, concatenation), a copy of a value from an earlier event or another execution, a value too short to compare, data a handler obtains from outside the event (handlers are pure by contract, not sandboxed), or free text. State should still hold identifiers, enums, counters and timestamps. Lifted-copy detection needs the decrypted value, so prefer decrypted sources (the store’s decrypting read path) for catch-up and rebuild; on a live feed with no declared map, a sealed field without a seal marker reaches the handler as delivered, so declare the map.
  2. Keep version honest. It is required (non-blank) and a changed version rebuilds the model. The declared sensitive map is part of the fold version, so editing it rebuilds too.
  3. refresh costs a log-tail read. Use it on the one execution a user action touched, by id (getMany(..., { refreshFrom }) or refresh). getManyByKey(..., { refreshFrom }) refreshes only executions that already have a row for the key, so it cannot discover an execution that was just started.
  4. liveSource is optional. The engine is shown every event of an execution it follows from its first event, so events no handler matches are not holes. Configure liveSource (normally the node’s own event store) so a node that restarts mid-execution fills the hole instead of reporting a FoldGapError until the next catchUp. It is recommended for any app that resumes executions: a resumed execution’s first event on a fresh node is not sequence 0.
  5. The state cache serves the live write path only. Default 1024 entries; reads always hit the store; foldCache: false (or { maxEntries: 0 }) disables it.
  6. Erasure. purgeFolded(model, { executionId } | { key } | { scope }) tombstones. rebuild un-tombstones, so an erasure that must survive a rebuild needs a shredding-tolerant source and handlers that skip { $shredded: true }.
  7. Postgres row-level security. Construct the store with manageSchema: false, put foldedStateDdl({ schema, tables }) in the app’s own migration, and add a policy on the scope column. The schema must already exist (Loom never runs CREATE SCHEMA); names are quoted, so quote mixed-case schema or table names in the policy.
  8. Folded models need uncompacted executions. A fold reads the log contiguously from its watermark, so an execution whose prefix was compacted cannot be re-folded. Exempt such executions with retention.compactionExempt (ADR 0106).
  9. State is canonicalised. Object keys of a folded state are stored in Postgres JSONB order (shorter key first, then bytewise) and strings holding NUL or a lone surrogate are refused with FoldedStateValueError, so every backend returns the same state.
  10. Rebuild across nodes. A same-version rebuild needs the model quiescent on the other nodes; for a multi-node rebuild bump version instead (the other nodes’ writes are then refused until they redeploy).

Scaffolding a plugin

The CLI generates a ready-to-fill package for a seam, in first- or third-party shape, with the preset stub, an SPI implementation stub, and a conformance-wired test:

loom plugin seams                              # list the seams you can target
loom plugin new store my-backend               # third-party: ./loom-plugin-my-backend
loom plugin new store my-backend --first-party # first-party: ./plugins/my-backend (@loom/plugin-*)

Seams: store, dispatcher, telemetry, middleware, timer, lease, content, durable-state, read-model, keystore, memory, projection, worker, llm, authorizer, execution-authorizer, resume-authenticator, timer-dispatcher, policy, sandbox. Then implement the SPI, un-comment the conformance test (where it ships commented out; sandbox ships it live), and run your formatter.

policy is the one seam that is not a defineConfig contributor. Every other seam is passed to the composition root; a Policy (ADR 0080) is consumed from flow code via decideWithPolicy(ctx, id, policy, context, actions). That is deliberate — the runtime never learns a policy exists, so a learned choice can only reach a run through ctx.decide, where it is recorded, replayable and auditable. Everything else about authoring one is identical: same naming, same peer-dependency rule, same conformance requirement (runPolicyConformance).

sandbox is the second flow-code seam. A SandboxBackend (ADR 0144) is wired with registerSandboxBackend(effects, scripts, backend, spec?) from @loom/sandbox, next to the content store that holds the scripts, not through defineConfig.

Write your own sandbox

Any production deployment that runs model-written code brings its own isolation boundary. Implement SandboxBackend for it and keep loom’s guarantees without copying them:

loom plugin new sandbox my-box
  • A backend is only the engine: evaluate({ script, inputJson, limits, signal }) returns a closed SandboxOutcome (ok / limit / threw / inputTooLarge / infrastructure). It never throws a typed failure. SandboxEffect (in @loom/sandbox) owns the input and output ceilings, the content-address check, the scriptError / limitExceeded / shapeMismatch channels, bounded storable error text, cancellation precedence and the JSON replay form of the result.
  • Declare capabilities honestly. network other than "none" and freshPerRun: false are refused at construction unless the operator passes allowNetwork: true / allowReusedEnvironment: true; the effect is idempotent only for network: "none" with freshPerRun: true. The declaration is snapshotted at construction. The scaffold declares the worst case (network: "open", freshPerRun: false, soft clock, memory: false, cpu: false) and each stronger value must be earned by the kit. memory: false / cpu: false mean an in-process backend can exhaust the host.
  • The kit’s timeoutMs (default 60 s) applies to every case; a backend that cold-starts an instance per run needs a larger one (e.g. 300_000) for the fresh per run case. A non-JavaScript backend passes its own fixtures. The scaffold’s remote-evaluate comment shows the shape: pass request.signal to fetch, cap the response bytes, map any unexpected response to infrastructure with your own message, never the body.
  • A backend that never settles loses: the effect races it against the caller’s abort and a wallClockMs + backendGraceMs deadline (backendGraceMs defaults to 60 s, on top of the 1 s default wallClockMs) and hands it a combined signal; whatever it throws is untyped and bounded, never a channel.
  • Prove it: runSandboxBackendConformance(() => backend, { fixtures? }) from @loom/conformance runs ADR 0064’s security list through the effect. QuickJS (@loom/plugin-sandbox) passes it in both in-process and worker mode. A non-JavaScript backend supplies its own fixtures.
  • A remote backend (Cloud Run, Firecracker, E2B): no service account or credentials, egress denied at the network layer, one request per instance, the request timeout as the hard kill, memory and CPU caps, no shared writable state. The scaffolded README carries this checklist.
  • @loom/isolate is a different thing: it runs TRUSTED tool code (yours, not model-written) with brokered egress, as hardening rather than OS confinement; model-written scripts belong behind this seam (ADR 0144).
  • A third-party example exists: loom-plugin-cloudrun-sandbox in the loom-plugins repo (a Python script on Cloud Run). It has not been verified on a real deployment and its memory cap is untested; treat it as a worked example of the checklist above, not a vouched backend.

Validate it against the contract at any time:

loom plugin check --dir ./loom-plugin-my-backend

check verifies the naming convention, that the package does not depend on the replay fold engine (@loom/replay-vm — the “no reducer into the fold” rule), that a 3P plugin peer-depends on the @loom/* SPI (one kernel copy) and carries the loom-plugin keyword, and that a test wires @loom/conformance. Non-zero exit on any error.

And run a storage plugin end-to-end — wire its preset into a real app, run a durable flow, and confirm it persisted and replays (what check and conformance can’t tell you):

loom plugin try --module ./loom-plugin-my-backend/dist/index.js \
  --preset myStorage --options '{"url":"…"}'

Authoring a plugin (worked example: a storage backend)

// 1. implement the SPI (or reuse an existing store). Each SPI lives in its OWN package.
import type { EventStore } from "@loom/event-runtime";
import type { CustomStorage } from "@loom/plugin-kit";
import type { SnapshotStore } from "@loom/snapshot-runtime";

// 2. export a preset returning the shape defineConfig accepts. Name the CONTRACT TYPE
//    rather than re-declaring its shape: a copied shape silently stops matching when the
//    contract evolves, whereas the imported type turns that into a compile error.
export function myStorage(opts: { url: string }): CustomStorage {
  return { kind: "custom", eventStore: new MyEventStore(opts.url), snapshotStore: new MySnapshotStore(opts.url) };
}
// 3. the app wires it exactly like a first-party backend — no privileged `kind:`
import { defineConfig } from "@loom/app";
import { myStorage } from "loom-plugin-mystore";

export const app = defineConfig({ storage: myStorage({ url: process.env.DB_URL! }) });
// 4. prove conformance in your own CI
import { runEventStoreConformance } from "@loom/conformance";
runEventStoreConformance(() => new MyEventStore(":memory:"));

A storage plugin should also supply CustomStorage.archiveStore (an ArchiveStore, prove it with runArchiveStoreConformance from @loom/conformance). Without an archive, compaction REFUSES (it moves the events it removes there) and the inbox cannot trim (ADR 0138, ADR 0139 D5); durability.retention.compactionArchive supplies one from the app side.

Presets that contribute middleware/telemetry/surfaces return a Partial<LoomConfig>; defineConfig concatenates arrays with the app’s explicit entries (order = composition order). Middleware order is behavioural — declare where in the chain your middleware belongs.

Discovering & publishing

Find published plugins by the loom-plugin npm keyword:

loom plugin search redis

Before you publish, run the readiness check — it adds npm-metadata checks (version bumped, description, license, repository, entry point + types) on top of the contract check:

loom plugin check --dir . --publish

Pre-publish checklist:

  • name is loom-plugin-<name> (or @scope/loom-plugin-<name>); keyword loom-plugin present
  • @loom/* SPI packages are peerDependencies, not bundled
  • a test wires @loom/conformance and passes (run<Spi>Conformance)
  • version bumped, plus description, license, repository, and exports/main+types
  • loom plugin try succeeds (for a store plugin) and loom plugin check --publish is clean

Security (read this)

A preset and any adapter constructor run at composition time with full host authority, and cannot be sandboxed — K7 data-plane isolation governs tool effects at runtime, not the code that builds your runtime. Importing a plugin grants it boot-time authority, exactly like any dependency you import.

  • 1P plugins are vetted by core — that is the trust basis for the @loom/ scope.
  • 3P plugins are the app author’s supply-chain call. Vet them as you would any dependency.

Keeping the kernel minimal

A CI guard (@loom/spec findLayeringViolations) fails the build if any kernel or SPI package gains a dependency on a plugins/* adapter. The kernel carries mechanism; adapters live outside it. See ADR 0043.

Source: docs/plugins.md in the loom repository.