Loom

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

↑↓ navigate ↵ open esc close Pagefind

Start here

Cheatsheet — features

Card I of two. The durable-execution kernel: a small syscall table, the builders that declare work, and the subsystems layered on the log. Everything here survives a process dying mid-step. Agents are one workload; the kernel is just as happy running none (see Not only agents). Card II is the developer workflow.

KERNEL ABI 1.3.0 · EVENT SCHEMA 1.26.0 · EventStore SPI 2.6.0 · BranchRegistry SPI 2.0.0 · LeaseStore SPI 2.2.0 · MemoryStore SPI 2.1.0

The syscalls

Ten from 1.0/1.1, four added in 1.3.0 (receive, select, continueAsNew, negotiateBudget). Everything else is built from these; feature-detect against the advertised list, not the version.

ctx.executerun any task to durable completion — the general primitive
ctx.runrun a declared step; the step alias of execute
ctx.deciderecord an advisory choice; served from the log on replay
ctx.parallelrun tasks concurrently, input order preserved; with kernel.features.scopes a cancellation scope (fail-fast aborts siblings)
ctx.suspendpause until an external signal, then resume here
ctx.sleeppause for a duration; the wake is a logged TIMER_FIRED
ctx.sleepUntilpause until an absolute time recorded in the log
ctx.receivewait for the next message on this execution’s durable inbox (runtime.send, ADR 0110); consumption is recorded, delivered tainted; needs kernel.features.inbox
ctx.selectwait for the first of several gates, inbox topics and timers (ADR 0110); the first answered arm settles it; needs kernel.features.inbox
ctx.continueAsNewend this execution and start its successor <chainId>~<n> with a carry; never returns; bounds the fold, the snapshot and the per-turn cost of a long-lived loop (ADR 0109); needs kernel.features.continueAsNew
ctx.emitappend a durable domain event — once per execution; a re-run serves it from the log
ctx.streamemit an incremental delta event (once per execution, like emit)
ctx.uideprecated (ADR 0114): drive durable, replayable UI surfaces — injected accessor; new code uses surface(ctx) from @loom/agent-surface
ctx.negotiateBudgetnegotiate a resource budget against a ceiling, recorded once (ADR 0032)

Nothing else is durable. A bare await fetch() in a runner re-runs on replay and is never recorded. If an outcome matters, it goes through a syscall.

Declaring work — @loom/core

flow$a durable workflow: id, version, schemas, steps
step$one unit of work, bound to a toolId
tool$the effect a step invokes
agent$a model-driven actor with tools and memory
skill$offline, deterministic capability selection
schedule$cron occurrence — fires only if a TimerStore is wired
memory$a scoped, bounded agent memory
projection$fold events into read state
policy$app-global rule, composed under the authorizer
handler$extractable handler body (compiler)
instructions$prompt material as a declared value
agentLoop$ (canonical) / agentStep$ (deprecated)ReAct loop, one frame per turn / single opaque agent step — @loom/agent
conversation$a durable chat over one chained execution — @loom/conversation

Composition — defineConfig

Options are grouped; flat spellings throw, with no deprecation window.

defineConfig({
  storage: { kind: "custom", eventStore, snapshotStore },
  timers:  { store, leases, dispatcher, schedules },
  autostart: false,        // build, don't run
  kernel: { features: { scopes, inbox, inboxTopicStreams, continueAsNew, redrive } },  // opt-in syscalls
  security:      { … },    // the three rings, strict, crypto, compliance, killSwitch
  observability: { … },    // onEvent, onDurableEvent, telemetry, onWarn, onStorageError, reasoning
  durability:    { … },    // snapshotEvery, retention, heartbeat leases (ADR 0130 D11)
  execution:     { … },    // dispatcher, effectMiddleware, registerEffects, envelope, now
  checks:        { … },    // validate, verify (boot-time pre-flight)
})

security.strict makes defineConfig throw unless an authorizer, executionAuthorizer and resumeAuthenticator are supplied, checks.verify stays on and a spend bound is in effect; it also installs a default deadline, the autonomy envelope (spawn/spend caps) and app.killSwitch. Unset, it logs one advisory.

Heartbeat leases are on by default (durability.heartbeat, ADR 0130 D11): the lease store is co-located with the event store, acquired lazily after lazyGraceMs (acquire: "eager" to lease before the first append). A durable event store with no derivable lease store disables it (loom doctor says so). See operations.

autostart: false builds the app without starting its timer service — what an operator command needs so that inspecting a deployment never fires its schedules. app.start() is the other half.

Time and waiting

suspend({ on })wait for a named gate; resume by event type
suspend({ timeout })needs a TimerStore, or it never wakes
resumeSchemasparse a resume payload before it is appended (ADR 0077)
ctx.sleep(ms) · ctx.sleepUntil(t)durable timers; need a TimerStore; the wake is a logged fact
runtime.send → ctx.receive / ctx.selectdurable inbox: queue a message into a running or parked execution (ADR 0110); kernel.features.inbox
runtime.quarantineInbox / releaseInbox / discardInboxoperator verbs on a topic: hold, release in order, drop (principal required; ADR 0110, 0130)
timers.leasesclaim an execution before waking it, so two nodes don’t both pay
timers.dispatcherwake on the lane the execution started on, not the poller’s

A gate name is not its payload. z.object strips unknown keys — what the schema drops is what the log never sees.

Not only agents — long-running workflows

The kernel never calls a model. A flow with no agent$ is a first-class use: approval chains, onboarding, billing runs, ETL steps, order sagas, anything that must outlive its process.

needuse
a unit of work that is not repeated on replaystep$ + ctx.run (retry and timeoutMs on the step, deadlineMs on the flow)
wait hours or daysctx.sleep / ctx.sleepUntil — survives restarts; needs a TimerStore
wait for a human or webhookctx.suspend({ on, timeout? }) + flow$({ resumeSchemas }) to validate the payload
run on a calendarschedule$({ id, cron, timeZone?, flowId }) in timers.schedules; one idempotent start per occurrence
undo on failure@loom/saga — withSaga(ctx, saga => saga.run(step, input, compensator)); compensators run in reverse; a failing compensator does not abort the rollback and withSaga then throws SagaCompensationError (original as cause); a cancellation or other runtime signal propagates unchanged
parent and child workflows@loom/orchestration — awaitChild · awaitChildren · awaitAll (wire superviseChildren)
map one step over many itemsfanout$(ctx, step, items) — bounded concurrency, one step id per item
long-lived loopcontinueAsNew to bound history; the inbox as an event-driven mailbox
import { withSaga } from "@loom/saga";
import { awaitChildren } from "@loom/orchestration";
import type { ExecutionContext } from "@loom/workflow-runtime";

// inside a flow runner; reserve/charge are step$ definitions, release/refund their compensators
async function run(ctx: ExecutionContext, order: { id: string }) {
  await withSaga(ctx, async (saga) => {
    await saga.run(reserve, order, release);
    await ctx.sleep(24 * 60 * 60 * 1000); // a day, durably
    await saga.run(charge, order, refund); // if this throws, refund/release run in reverse
  });
  return awaitChildren(ctx, { key: "ship", children: [{ flowId: "ship", input: order }] });
}

Start from a template: loom new flow <name> --template approval|timer|saga scaffolds each of the rows above (typed gate, durable timer, compensation; echo is the minimal one) with a test that passes on a fresh scaffold. Test time without waiting: createTestApp({ clock: true }) and advanceTime(app, ms) from @loom/testing move the runtime’s clock and fire what became due through the real TimerService.

Honest limits:

  • The flow body must be deterministic. It is re-run from the top on every resume, with syscalls served from the log: no bare Date.now(), Math.random() or fetch() outside a step or ctx.decide. Changing a deployed body mid-flight needs a version, not an edit (ADR 0113).
  • Throughput is log-bound — thousands of durable executions per second per store, not millions; bound a very long history with snapshotEvery and ctx.continueAsNew.
  • At-least-once: an interrupted step may run twice, so side effects need idempotency keys.
  • It is not a query database. The log answers “what happened to execution X”; cross-execution questions go to @loom/plugin-read-model. Timers fire at poll granularity, not to the millisecond.

Branching — git-style, no merge

runtime.fork(id, atSequence)copy the prefix and continue independently
registry.promote(from, to)move a ref; compare-and-set with expect
listRefs / childrenenumerate branches and lineage
  • A fork inherits the recorded wake time, never a re-based one.
  • A live branch blocks its parent’s retention until it is terminal.
  • There is no merge — promote a branch or retire it.

The three authority rings

All optional, all separate.

optiongates
security.authorizereffects — K2 capability ring, allow/deny each effect a step invokes
security.executionAuthorizerlifecycle — start / resume / cancel / query; synchronous, read before anything is appended
security.resumeAuthenticatorre-entry — may this event reopen a suspended execution? A resume arrives from outside, so this is the ring an attacker reaches first

not-applicable is not a denial. A resume verdict of not-applicable means not mine to judge and lets the resume proceed. Only rejected refuses.

Data handling

sensitive() · internal()scrubbed from telemetry egress
secret()redacted from step output in the log
@loom/plugin-contentclaim-check: large or PII fields out of the log
@loom/plugin-cryptoper-subject keys; destroy is crypto-shredding. Keys must declare durable — losing one erases the subject. Seals inbox payloads and, with sealState, whole checkpoints
@loom/compliancesubject index, app.exportSubject / app.eraseSubject (parks while the subject has live executions)
@loom/plugin-audit-chainhash-chained log, optional Ed25519 signatures

Agentic layer

@loom/agentagentLoop$ — ReAct loop over declared tools. needsApproval (true parks; bound to what the approver saw), parallelTools, writeBack (memory), toolSearch / skillActivation for big menus, returnsMedia tools. A tainted input.context is refused (UntrustedContextError)
@loom/conversationconversation$ / createConversations: multi-turn chat on one chained execution; barge-in; token budget; shared mode runs each turn as its asker. SSE: @loom/plugin-conversation-sse
@loom/llmprovider-agnostic completion + streaming; content parts (images, files) claim-checked by address via a ContentResolver (ADR 0120)
@loom/memoryscoped, TTL/size-bounded agent memory
@loom/knowledgedocument corpora over memory, with provenance
@loom/reasoningmodel routing, confidence elevation, critique
@loom/learningtrajectories, off-policy eval (SNIPS, doubly-robust, bootstrap CIs), promotion gate, policy registry (shadow/canary/rollback), JSONL export
@loom/intent · planner · capabilityNL → capability → ordered plan. A configured model’s answer is final: NONE is not overridden by a keyword guess; routeExplained() returns { route, reason } (model-none, model-unparseable, …)

Off-policy eval needs exploration, not history. Usable evidence ≈ explore-rate × window; a long log with no exploration evaluates nothing.

Surfaces and integrations

@loom/agent-surfacesurface(ctx) — durable, replayable UI patches (replaces ctx.ui)
@loom/blueprint-runtime + plugin-renderer-{dom,react,terminal}one Blueprint IR, several renderers
plugin-a2a · plugin-mcp · plugin-a2ui · plugin-mcp-appagent-to-agent, MCP client, A2UI and MCP-App egress
plugin-sandbox (+ the @loom/sandbox backend SPI) · @loom/isolaterun LLM-authored code / tool effects in a separate authority domain
plugin-sentinel · plugin-durable-stateperpetual-agent preset; typed state carried across bounded runs

Scale and placement

@loom/dispatcherwhere an execution runs; placement policy, circuit breaker
@loom/worker-runtimethe worker contract — local, remote HTTP, Cloud Run
@loom/graph-executorfan a node set into per-node executions
@loom/fair-schedulerper-tenant fairness; bounded concurrency
@loom/orchestrationspawn children, awaitChild(ren), awaitAll
@loom/sagacreateSaga / withSaga — compensate in reverse

Reading what happened — no writes

@loom/projection-runtimeper-execution folds, live subscriptions, slice selectors
@loom/live-queryliveQuery over in-flight state
@loom/plugin-read-modeldurable cross-execution queries, keyset paging
@loom/analyticsanalyze, cost, failure, reasoning, model ranking
@loom/replay-inspectorstate at any sequence, off-log

Invariants worth memorising

  • P9 — anything that decides an outcome enters the log once, and is served from it on replay. Never recomputed.
  • P10 — every edge is a pluggable SPI, so a third party never forks the kernel.
  • A recorded decision has a position. Each folded DECISION_RECORDED carries its sequence through checkpoints (ADR 0128), so replay order survives compaction.
  • An owner-resolution fault is retryable. Under an execution authorizer it surfaces as ExecutionStoreUnavailableError, never a false denial of the owner or an admission of a stranger (ADR 0039). A shredded payload is recognised by isPayloadShredded (realm-safe brand; brandPayloadShredded for a custom store), not by error name.
  • A node that cannot open a sealed payload refuses it — never delivers the envelope as if it were the user’s data (ADR 0130); the wake is left for a capable node.
  • Recovery is at-least-once. An interrupted step may run twice; fencing makes that safe, not free.
  • “Not configured” and “configured but empty” are different answers. Loom reports them differently, and so should you.

Source: docs/cheatsheet-features.md in the loom repository.