Loom

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

↑↓ navigate ↵ open esc close Pagefind

Guides

Compiler

@loom/compiler is a Vite plugin and standalone CLI that applies three build-time optimizations to Loom applications. This guide covers installation, configuration, and each feature in detail.

What the Compiler Does

FeatureWithout compilerWith compiler
handler$() bodiesCalled eagerly at startupExtracted to lazy chunks, loaded on demand
skill$ instructions$ bodiesInlined in the bundleExtracted to lazy chunks, loaded when the skill is selected
Step effectsMust register manuallyAuto-registered by registerFlow()
Flow graph validationRuntime errors onlyWarnings printed at build time
Pre-flight verificationRules you must hand-feed your own source toloopIdentity/nonDeterministic/runnerReach emitted into the manifest, auto-injected by loom verify
Client state restorationRound-trip to EventStoreSeed from serialized snapshot, zero network

The compiler is a dev dependency — no compiler code is included in the application bundle.

Installation

pnpm add -D @loom/compiler

vite must be installed as a peer dependency (≥ 5.0.0).

Vite Plugin Setup

Add loomPlugin() as the first plugin in your vite.config.ts, before @vitejs/plugin-react:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { loomPlugin } from "@loom/compiler";

export default defineConfig({
  plugins: [
    loomPlugin(),   // must be before react()
    react(),
  ],
  resolve: {
    conditions: ["require", "default"],
  },
  optimizeDeps: {
    include: [
      "@loom/core",
      "@loom/effect-runtime",
      "@loom/workflow-runtime",
      // ... other @loom/* packages
    ],
  },
});

The plugin runs on every .ts and .tsx file in your project (excluding node_modules).

Feature 1 — handler$() Co-location

handler$() is the core authoring primitive. Write the implementation directly inside step$():

import { step$, handler$ } from "@loom/core";
import { z } from "zod";

export const FetchMetricsStep = step$({
  id: "fetch-metrics",
  inputSchema: z.object({ incidentId: z.string() }),
  outputSchema: z.object({
    cpu: z.object({ current: z.number(), threshold: z.number() }),
    memory: z.object({ usedMb: z.number(), totalMb: z.number() }),
  }),
  handler: handler$(async (input, ctx) => {
    const data = await fetch(`/api/metrics/${input.incidentId}`).then(r => r.json());
    ctx.emit("METRICS_FETCHED", data);
    return data;
  }),
});

What the compiler does to this

At build time, the handler$(fn) call is replaced with a dynamic import thunk:

// Compiled output (in-memory transform — not written to disk)
handler: () => import("\0loom:chunk:fetch-metrics_2f936a0b"),

A separate virtual chunk is generated:

// \0loom:chunk:fetch-metrics_2f936a0b (served by Vite)
import { z } from "zod";   // all top-level imports re-emitted

export default async function(input, ctx) {
  const data = await fetch(`/api/metrics/${input.incidentId}`).then(r => r.json());
  ctx.emit("METRICS_FETCHED", data);
  return data;
};

Runtime path (no compiler)

Without the Vite plugin (unit tests, ts-node, etc.), handler$(fn) returns a HandlerRef:

{ _loomHandlerRef: true, fn: async (input, ctx) => { ... } }

LazyEffect detects the HandlerRef and calls fn directly. No dynamic import, no overhead.

Removing manual effect registration

With handler$(), you no longer need to call effectRuntime.register() for step effects. WorkflowRuntime.registerFlow() auto-registers a LazyEffect for every step that carries a handler:

// Before: manual registration required
effectRuntime.register("fetch-metrics", new ToolEffect(fetchMetricsFn));
effectRuntime.register("fetch-logs",    new ToolEffect(fetchLogsFn));

// After: registerFlow handles it
workflowRuntime.registerFlow(MyFlow.definition, MyFlow.runner);
// ↑ automatically wires LazyEffect("fetch-metrics") and LazyEffect("fetch-logs")

skill$ / instructions$ extraction (progressive disclosure)

The same extraction applies to a skill’s instructions$() body — the progressive-disclosure analog of a step handler. An agent bundle ships lean; each skill’s (often large) instruction text is a lazy chunk loaded only when that skill is selected:

import { skill$, instructions$ } from "@loom/core";

export const DiagnoseCpu = skill$({
  id: "diagnose-cpu",
  // extracted to \0loom:chunk:diagnose-cpu_<hash>, loaded on first use
  instructions: instructions$(`You are a CPU diagnostics expert. Given the metrics…`),
});

The compiler tracks skills in state.skills / state.skillsByVarName and records each in the manifest’s skills map (see Feature 2). A skill$ authored with a plain inline string (no instructions$ marker) stays inline — its manifest chunk is empty. Because skill$ triggers the transform, it is part of the fast-path check alongside step$ and flow$.

Feature 2 — Flow Graph Analysis + loom-manifest.json

At the end of every build, the plugin emits a loom-manifest.json file into your dist/ directory.

Example manifest

{
  "version": "1",
  "flows": {
    "investigate-incident": {
      "version": "1.0.0",
      "steps": ["fetch-metrics", "fetch-logs", "apply-remediation"],
      "chunkMap": {
        "fetch-metrics": "dist/chunks/fetch-metrics_2f936a0b.js",
        "fetch-logs":    "dist/chunks/fetch-logs_4a1b3c2d.js"
      },
      "suspendPoints": [],
      "warnings": [
        "Step \"apply-remediation\" in flow \"investigate-incident\" has neither a handler$ nor a toolId — it must be registered manually."
      ],
      "nonDeterministic": [
        { "callee": "Date.now", "insideDecide": false, "location": "src/flows/investigate-incident.ts:31" }
      ],
      "runnerReach": {
        "steps": [{ "stepId": "apply-remediation", "toolId": "remediation-tool" }],
        "unresolved": [
          { "expr": "planStep", "kind": "step", "location": "src/flows/investigate-incident.ts:44" }
        ],
        "suspends": [{ "id": "approval", "timed": true, "wakeAt": false }]
      },
      "module": "src/flows/investigate-incident.ts"
    }
  },
  "skills": {
    "diagnose-cpu": { "chunk": "dist/chunks/diagnose-cpu_9c1f0a7e.js" }
  },
  "flowModules": ["src/flows/investigate-incident.ts"]
}

Each flow carries a module — its source path relative to the project root — and the top-level flowModules is the deduplicated, sorted list of those paths. Both are metadata: @loom/app’s registerFlowsFrom still registers flows by static import (never a dynamic-import target), so wiring stays deterministic and replay-safe (P5).

The skills map mirrors a flow’s chunkMap for skill$ instruction bodies: each entry points at the lazy chunk holding that skill’s instructions$ text (empty chunk when the skill uses inline instructions with no instructions$ marker).

Console output during build

[loom-compiler] ⚠  Step "apply-remediation" in flow "investigate-incident" has neither a handler$ nor a toolId
[loom-compiler] ✓  flow "investigate-incident" — 3 step(s), 2 lazy chunk(s)

What is validated

CheckAction on failure
Step in flow$() but not definedWarning in manifest + console
Step has no handler$ or toolIdWarning — must be registered manually
flow$() has no stepsWarning

Warnings never break the build. They are advisory — the intent is to catch configuration mistakes early rather than at runtime.

Dynamic steps & the identity rule (ADR 0061)

The static steps: [...] list is the compiler’s world; steps minted at runtime inside a runner body — the interpreter pattern, ctx.run in a loop over runtime-loaded plan data — are deliberately outside it (and outside flowContentHash). They are valid and fully replayable under one contract: *the sequence of durable-step identities must be a pure function of logged inputs*, so ids are minted deterministically (${id}#${index}) and each binds a static toolId to a registered effect (the effect registry, not the chunkMap, is their load-bearing map).

The analyzeLoopIdentity export (a sibling of analyzeNonDeterminism; same source-region contract) flags ctx.run/ctx.execute inside loop bodies whose step id cannot vary per iteration — the silent-corruption case where iteration 2+ replays iteration 1’s memoized output. They surface as determinism-rule warnings.

The three source analyses the transform emits

You do not feed these to @loom/verify by hand. The transform already has the parsed source (and memoizes per module), so it runs all three and writes them into each flow’s manifest entry; loom verify auto-injects them from a discovered loom-manifest.json, and defineConfig accepts them directly.

Manifest fieldAnalyzerFeeds
loopIdentityanalyzeLoopIdentitydeterminism (ADR 0061)
nonDeterministicanalyzeNonDeterminismdeterminism
runnerReachanalyzeRunnerReachcapability-compat, effect-class, suspension-safety (ADR 0086)

analyzeRunnerReach(source) returns { steps, unresolved, suspends }. It resolves a ctx.run target by shape — a call whose first argument is an object literal with a string-literal toolId — so a project wrapper or a locally curried factory resolves too. A target it cannot resolve (an import from another module, a minted id like mcpInvoke$("hydrate#0")) is reported in unresolved, never dropped: the three rules turn each into a warn so a partial analysis never reads as a complete one. ctx.sleep/sleepUntil are deliberately not recorded as suspend points — the kernel authors their condition, so treating them as user-authored would manufacture findings about code nobody wrote.

Decider scopes (L31). scope.invokeTransient("tool.id", …) inside a ctx.decide decider counts as a reached effect (viaDecider: true on the step ref), so the same three rules and reaches consistency see it. The scope is tracked by binding: a parameter ((scope) =>, async (s) =>) whose only permitted use is as the receiver of a called .invokeTransient, or a destructured ({ invokeTransient }) / ({ invokeTransient: call }) that is only called. Everything else is reported as unresolved, never dropped: a non-literal toolId (variable, template with expressions, member access), a scope passed to another function, aliased, read as scope["invokeTransient"], or captured by a rest pattern, and a decider passed by reference whose body is not in the analysed region (imported, or returned from a factory; a same-region function / const f = (s) => … is followed). Name matching is textual, so a shadowing nested parameter over-reports rather than under-reports. replay-safety additionally errors on a scope call to an effect declared journaled (read from the real registry by resolveVerifyInput), which the transient seam refuses at runtime.

Code handed undefined (L32). ctx (or ctx.decide) passed to a callee outside the module (planner.compose(input, ctx), kb.retrieve(q, { decide: ctx.decide })) is recorded in escapes as { callee, root, via }. The callee’s code is out of sight, so the library declares its reach (Symbol.for("loom.verify.reach") -> { run?, transient? }, or declareReach from @loom/verify) and the app maps the callee to the real object with checks.verify.reach: { planner }. Declared tools are counted like any reached effect (viaDeclared); an escape with no declaration is reported as unresolved. Passing ctx to a same-module function is followed (the parameter, whatever its name, becomes a ctx name); the first parameter of every runner is ctx. ctx.run / ctx.suspend handed out as values are escapes too (member). ctx stored, aliased, returned, or reaching a parameter that cannot be followed is unresolved.

Step arguments, undefined and constant ids (L34/L35/L37). A library that runs the step its caller passes (fanout$(ctx, step, items)) declares stepArgs: [1]; the hand-off’s args carry the steps that resolved and the verifier counts them (an unresolved position is reported). The [step, input] pairs of ctx.parallel([...]) are read like ctx.run (a non-literal list is unresolved). A step’s effect id resolves from a same-module const string, an all-constant template, a same-module or relative-imported factory (llmStep$(id, LLM_X), found by shape: the returned step$({ toolId })), and the first-party mcpInvoke$ (always mcp.invoke) and sandboxEval$. Bare or aliased import specifiers and re-exports are not followed. See ADR 0086’s 2026-10-10 amendment.

Because the transform feeds whole modules, analyzeNonDeterminism is called with { skipMemoized: true } there: a module holds runner bodies and handler$ bodies, and a Math.random() inside a handler is safe by construction — the handler’s result is written to STEP_COMPLETED and served from the log on replay. Calling the analyzer directly keeps the plain “analyze the region you give me” contract.

Feature 3 — Resumability

The runtime package (@loom/workflow-runtime) exports serialization utilities (serializeExecution, serializeProjections) for the server → client state hand-off. This enables a React app to mount with fully-hydrated projection state and resume a suspended workflow without any EventStore round-trip.

Server side (e.g., Express route, Next.js getServerSideProps)

import {
  serializeExecution,
  serializeProjections,
} from "@loom/workflow-runtime";

app.get("/api/execution/:id", async (req, res) => {
  const { id } = req.params;
  const state = handle.getState();  // from your running WorkflowRuntime

  const projStates = {
    metrics:  projectionRuntime.getState("metrics",  id),
    incident: projectionRuntime.getState("incident", id),
  };

  res.json({
    executionState: serializeExecution(state),
    projectionState: serializeProjections(projStates, id),
  });
});

Client side (React app)

// main.tsx
import { LoomProvider } from "@loom/plugin-renderer-react";

const { executionState, projectionState } = await fetch(`/api/execution/${executionId}`)
  .then(r => r.json());

createRoot(root).render(
  <LoomProvider
    workflowRuntime={workflowRuntime}
    projectionRuntime={projectionRuntime}
    serializedExecutionState={executionState}
    serializedProjectionState={projectionState}
  >
    <App />
  </LoomProvider>
);

LoomProvider seeds projection state synchronously before the first child render — no empty-state flash, no loading indicator needed.

Resuming from pre-loaded state

If the execution is suspended, restore it with restoreFromState:

import { deserializeExecution } from "@loom/workflow-runtime";
import { useLoom } from "@loom/plugin-renderer-react";

function ApprovalPanel({ serializedState }: { serializedState: string }) {
  const { workflowRuntime, preloadedExecutionState } = useLoom();

  const approve = async () => {
    if (!preloadedExecutionState) return;

    const handle = await workflowRuntime.restoreFromState(
      preloadedExecutionState,
      { eventType: "ApprovalGranted", payload: { approved: true } }
    );

    handle.waitForCompletion().then(() => console.log("Done"));
  };

  return <button onClick={approve}>Approve</button>;
}

Standalone CLI

Use the CLI to transform source without Vite:

# Transform src/ → dist-loom/
npx loom-compile src/ dist-loom/

# Or add to package.json scripts
{
  "scripts": {
    "compile:loom": "loom-compile src dist-loom"
  }
}

The CLI:

  1. Walks all .ts/.tsx~ files in the source directory (skipping node_modules and dist).
  2. Runs the same AST transform as the Vite plugin.
  3. Writes transformed files and .map files to the output directory.
  4. Writes extracted handler chunks to <outDir>/chunks/.
  5. Writes loom-manifest.json to <outDir>/.
  6. Prints per-flow summaries and warnings.
  7. Finishes with [loom-compiler] Done. Processed N source files. (exits non-zero on a fatal error).

Programmatic API

Import and use the compiler transforms directly in your own build scripts:

import { transformFile, buildManifest, printWarnings, loomPlugin } from "@loom/compiler";
import type { PluginState } from "@loom/compiler";

// A fresh, mutable state the transform accumulates into (all seven maps required).
const state: PluginState = {
  chunks: new Map(),           // "\0loom:chunk:<id>_<hash>" → ExtractedChunk (step + skill bodies)
  steps: new Map(),            // stepId → StepEntry
  stepsByVarName: new Map(),   // local var name → stepId (for flow$ step resolution)
  flows: new Map(),            // flowId → FlowEntry
  skills: new Map(),           // skillId → SkillEntry
  skillsByVarName: new Map(),  // local var name → skillId
  projections: new Map(),      // projection id → ProjectionEntry
};

const sourceCode = fs.readFileSync("src/flows/MyFlow.ts", "utf-8");

const result = transformFile(sourceCode, "/abs/path/src/flows/MyFlow.ts", state);
if (result) {
  console.log(result.code);   // transformed source
  console.log(result.map);    // source map JSON string
}

// buildManifest(state, outDir?, root?) — outDir prefixes chunkMap paths (default "."),
// root makes each flow's `module` path relative (default process.cwd()).
const manifest = buildManifest(state, "dist", process.cwd());
printWarnings(manifest);      // logs every flow's advisory warnings to the console
console.log(JSON.stringify(manifest, null, 2));

The main exports of @loom/compiler: functions loomPlugin, transformFile, buildManifest, printWarnings, shortHash, fnv1a32; and types LoomManifest, ManifestFlow, ExtractedChunk, FlowEntry, StepEntry, PluginState.

How the AST Transform Works

The compiler uses TypeScript’s compiler API (ts.createSourceFile) for parsing and magic-string for minimal source rewriting (preserves line numbers and produces accurate source maps).

For each .ts/.tsx file:
  1. Fast path: skip if source contains none of "step$", "flow$", "skill$"
  2. Parse: ts.createSourceFile(path, source, ESNext)
  3. Walk top-level statements
     For each VariableStatement:
       If initializer is CallExpression to step$():
         Extract id, toolId, handler property
         If handler is handler$(fn):
           - Collect fn source text via MagicString.original
           - Compute hash = FNV-1a(filePath + stepId).slice(0,8)
           - Build chunk source: all import statements + export default fn
           - Store chunk in state.chunks["\0loom:chunk:<id>_<hash>"]
           - Overwrite handler$(fn) with () => import("virtual-id")
         Register step in state.steps, varName in state.stepsByVarName
       If initializer is CallExpression to skill$():
         If instructions is instructions$(text):
           - Extract to a lazy chunk exactly as handler$ (same hash + import rewrite)
         Register skill in state.skills, varName in state.skillsByVarName
       If initializer is CallExpression to flow$():
         Extract id, version, steps array
         Resolve step variable names → step ids via state.stepsByVarName
         Register flow in state.flows
  4. Return { code: ms.toString(), map: ms.generateMap() }

V1 limitations (documented)

  • Chunks re-emit all top-level import statements from the source file (conservative). Free-variable analysis that emits only imports reachable by the handler function is planned for V2.
  • flow$() step resolution only handles direct variable references (not computed/spread).
  • Suspend point detection is heuristic (scans for ctx.suspend, HumanEffect in source text).

Chunk Hashing

Chunk ids use FNV-1a 32-bit hash of filePath + stepId, truncated to 8 hex characters:

input:  "/workspace/src/flows/MetricsTool.ts" + "fetch-metrics"
hash:   fnv1a32(input) → 0x2f936a0b
chunk:  "\0loom:chunk:fetch-metrics_2f936a0b"
file:   "dist/chunks/fetch-metrics_2f936a0b.js"

The hash is stable across builds as long as the file path and step id do not change. Renaming a step id or moving a file invalidates the hash — existing cached chunks are not loaded.

Both hash primitives are exported for custom tooling: fnv1a32(input): number (the raw 32-bit hash) and shortHash(input): string (the 8-hex-char id used for chunk names).

Environment Compatibility

EnvironmentBehaviour
Vite dev serverPlugin active; virtual chunks served in-memory; HMR works for source files
Vite buildhandler$ extracted, chunks emitted, manifest generated
ts-node / tsxhandler$(fn) → HandlerRef; LazyEffect calls fn directly
Vitest (unit)Same as ts-node; no plugin; tests run against HandlerRef
Vitest (browser)Plugin active; chunks served by Vite’s test server
Node without ViteCLI mode: loom-compile src/ dist-loom/

Source: docs/usage-compiler.org in the loom repository.