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:
- implements an existing SPI and/or exports a preset
(opts) => Partial<LoomConfig>; - is statically imported and passed to
defineConfig— nonode_modulesscanning, no config-string resolution (P5); - 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);
- is byte-identical to hand-wiring after boot (zero runtime overhead);
- ships conformance tests for each SPI it implements (
@loom/conformance); - 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/verifyrule, 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 (orloom-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) | |
|---|---|---|
| Home | this 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 on | the workspace SPI packages | the published @loom/* SPI packages |
| Conformance | gated in Loom CI | run the published vectors in your own CI |
| Trust | vetted by core | your 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/sandboxdirectly 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 SQLiteEvent/Snapshot/Branch/Timer/DurableState/ReadModel/Contentstores + thesqliteStorage()preset.@loom/plugin-postgres— network Postgres stores (LISTEN/NOTIFY) +postgresStorage(). Bring your own pool: the stores accept any object satisfyingPoolLike—query,connect,end, and optionallyoptions— so a pool from a different copy ofpg(the normal state for alink:/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 neverend()ed by a store’sclose().postgresPool()is still exported for a caller who wants a realpg.Poolfrom this package — the sizing surface (totalCountand friends) thatPoolLikedeliberately omits. Withoutoptions, the dedicatedLISTENsession 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) +KeyStoreSPI (the optionalKeyStore.wasDestroyedis the destruction proof erasure asks for). AlsoEncryptingContentStore, which seals each claim-checked blob under its owning subject’s key at a subject-scoped address (ADR 0143), andEncryptingArchiveStore, which seals the compaction/inbox archive under the sameKeyStore.EncryptingEventStoreseals each message’s sender under an opaque handle by default (senderSubjectOf: falseopts out, ADR 0139).@loom/plugin-content— content-store seam:ContentResolvingEventStoreexternalizes marked payload fields to a content-addressed side store +ContentStoreSPI (ADR 0046). SPI 1.5.0 adds an optionalContentAccess({ executionId, workflowId }) argument toput/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 thesentinelState()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 liveonEventtail + 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-chainedEventStorewrapper. Wrap it outsideContentResolvingEventStoreif 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 theloom new sentinelscaffold.@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/scriptErrorare 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 theSandboxBackendSPI 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 registeredmcp.invokeeffect; pinnedjsonSchemaHashverified per call → typedschemaDrift/toolNotFoundchannels; principals ridectx.principal/secretRef, never the log;MCP_DISPATCHED+mcpDispatchIndexprojection for the “which executions called tool X at schema H” query (ADR 0063). An in-bandisErrorthrows rather than returning a failure as a value. A step yields the convenience projection (structuredContent, else joined-and-parsed text) by default and the wholeCallToolResultunderresultFidelity: "full"— the only shape in which_metaand non-text content blocks survive, and therefore the only one in which a UI declaration is visible to a flow. Server:createMcpServerexposes Loom tools/resources to any MCP host (thecreateA2AServeranalogue) with audience-scoped listing/dispatch, andservedEffectroutes a call by its declared seam. Secure by default (R12): it refuses to be created withoutauthenticate/authorize(or an explicitinsecure: true), binds loopback, and takesauthorizeToolfor 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 oninitializerather 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 andui://resources with content-hash provenance — carry, never render — writing both the nested and the deprecated flat_metaUI form on egress and accepting either on ingress, because the reference SDK emits both (ADR 0072). A dispatch whose result declares a UI recordsMCP_UI_OBSERVEDwith the resource URI and an explicitpin—embedded(inlined, hashed for free),resolved(resolveUifetched it),not-requested, orunresolvable— 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, loggedstructuredContent). The extension has no server push, so anambient/app-visibilityloom_surface_sincecursor overEventStore.readRangeis the View’s refresh channel;seenSequence+isStalecarry 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.A2APeerClientdiscovers a remote agent’s card and registers it as an ordinary Loom tool;createA2AServerpublishes your flows as an A2A agent (/.well-known/agent.json,/tasks/send) withLoomAgentExecutormapping execution status onto A2A task state. Secure by default (R12): it refuses to be created withoutauthenticate/authorize(or an explicitinsecure: 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 fromLast-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-inreasoningoption (ADR 0133 D6) serves the model’s reasoning (reasoning/reasoning-partialevents and a durableGET …/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 +callFunctionbidirectional RPC — the client’s result re-enters as a recorded fact under a deterministiccallId, 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/sdkbarrel. Placed grid cells get explicitgrid-column/grid-row, titled layouts a<section>with anh2(ADR 0098). A layout’sroleis ignored — it draws the plain container (ADR 0099). Anemptybinding status draws arole="status"“no data” marker in place of the widget (ADR 0100). A layout’sidis 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 —defaultTerminalRegistrycovers 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, whensource.degradedFromis 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’sroleoridchanges nothing in the output (ADR 0099, ADR 0101). A widget whosesource.redactedistrue(IR 2.5.0, ADR 0097 amendment) is the single line⊘ redactedon 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, livemountBlueprint), 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 allowlistingh()(noinnerHTML,hrefthroughsafeUrl), ships no CSS and no runtime dependency beyond blueprint-runtime and projection-runtime, and is not in the@loom/sdkbarrel (ADR 0127).
Compute
@loom/plugin-worker-remote—RemoteWorkerRuntime: dispatches anExecutionPackageto a stateless worker’sPOST /executeand collects theExecutionResult.@loom/plugin-worker-server—createWorkerServer: the other half, and the container entrypoint for remote/Cloud Run execution — runs oneExecutionPackageon an injectedWorkerRuntime.@loom/plugin-cloudrun— Cloud Run adapter:CloudRunWorkerRuntimemapsDispatchMetadataonto a sizing config and resolves a service endpoint, with cloud specifics behind theCloudRunPlatformSPI (StaticCloudRunPlatformruns 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.
- 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 withSensitiveStateError(model, execution, event, state path; never the value) and the watermark does not advance.onErrornever 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. - Keep
versionhonest. It is required (non-blank) and a changedversionrebuilds the model. The declared sensitive map is part of the fold version, so editing it rebuilds too. refreshcosts a log-tail read. Use it on the one execution a user action touched, by id (getMany(..., { refreshFrom })orrefresh).getManyByKey(..., { refreshFrom })refreshes only executions that already have a row for the key, so it cannot discover an execution that was just started.liveSourceis optional. The engine is shown every event of an execution it follows from its first event, so events no handler matches are not holes. ConfigureliveSource(normally the node’s own event store) so a node that restarts mid-execution fills the hole instead of reporting aFoldGapErroruntil the nextcatchUp. It is recommended for any app that resumes executions: a resumed execution’s first event on a fresh node is not sequence 0.- The state cache serves the live write path only. Default 1024 entries; reads always hit the store;
foldCache: false(or{ maxEntries: 0 }) disables it. - Erasure.
purgeFolded(model, { executionId } | { key } | { scope })tombstones.rebuildun-tombstones, so an erasure that must survive a rebuild needs a shredding-tolerant source and handlers that skip{ $shredded: true }. - Postgres row-level security. Construct the store with
manageSchema: false, putfoldedStateDdl({ schema, tables })in the app’s own migration, and add a policy on thescopecolumn. The schema must already exist (Loom never runsCREATE SCHEMA); names are quoted, so quote mixed-case schema or table names in the policy. - 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). - 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. - Rebuild across nodes. A same-version rebuild needs the model quiescent on the other nodes; for a multi-node rebuild bump
versioninstead (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 closedSandboxOutcome(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, thescriptError/limitExceeded/shapeMismatchchannels, bounded storable error text, cancellation precedence and the JSON replay form of the result. - Declare
capabilitieshonestly.networkother than"none"andfreshPerRun: falseare refused at construction unless the operator passesallowNetwork: true/allowReusedEnvironment: true; the effect isidempotentonly fornetwork: "none"withfreshPerRun: 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: falsemean 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 thefresh per runcase. A non-JavaScript backend passes its ownfixtures. The scaffold’s remote-evaluatecomment shows the shape: passrequest.signaltofetch, cap the response bytes, map any unexpected response toinfrastructurewith your own message, never the body. - A backend that never settles loses: the effect races it against the caller’s abort and a
wallClockMs + backendGraceMsdeadline (backendGraceMsdefaults to 60 s, on top of the 1 s defaultwallClockMs) and hands it a combined signal; whatever it throws is untyped and bounded, never a channel. - Prove it:
runSandboxBackendConformance(() => backend, { fixtures? })from@loom/conformanceruns 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 ownfixtures. - 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/isolateis 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-sandboxin theloom-pluginsrepo (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:
-
nameisloom-plugin-<name>(or@scope/loom-plugin-<name>); keywordloom-pluginpresent -
@loom/*SPI packages are peerDependencies, not bundled - a test wires
@loom/conformanceand passes (run<Spi>Conformance) -
versionbumped, plusdescription,license,repository, andexports/main+types -
loom plugin trysucceeds (for a store plugin) andloom plugin check --publishis 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.