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
| Feature | Without compiler | With compiler |
|---|---|---|
handler$() bodies | Called eagerly at startup | Extracted to lazy chunks, loaded on demand |
skill$ instructions$ bodies | Inlined in the bundle | Extracted to lazy chunks, loaded when the skill is selected |
| Step effects | Must register manually | Auto-registered by registerFlow() |
| Flow graph validation | Runtime errors only | Warnings printed at build time |
| Pre-flight verification | Rules you must hand-feed your own source to | loopIdentity/nonDeterministic/runnerReach emitted into the manifest, auto-injected by loom verify |
| Client state restoration | Round-trip to EventStore | Seed 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
| Check | Action on failure |
|---|---|
Step in flow$() but not defined | Warning in manifest + console |
Step has no handler$ or toolId | Warning — must be registered manually |
flow$() has no steps | Warning |
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 field | Analyzer | Feeds |
|---|---|---|
loopIdentity | analyzeLoopIdentity | determinism (ADR 0061) |
nonDeterministic | analyzeNonDeterminism | determinism |
runnerReach | analyzeRunnerReach | capability-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:
- Walks all
.ts/.tsx~ files in the source directory (skippingnode_modulesanddist). - Runs the same AST transform as the Vite plugin.
- Writes transformed files and
.mapfiles to the output directory. - Writes extracted handler chunks to
<outDir>/chunks/. - Writes
loom-manifest.jsonto<outDir>/. - Prints per-flow summaries and warnings.
- 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,HumanEffectin 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
| Environment | Behaviour |
|---|---|
| Vite dev server | Plugin active; virtual chunks served in-memory; HMR works for source files |
| Vite build | handler$ extracted, chunks emitted, manifest generated |
| ts-node / tsx | handler$(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 Vite | CLI mode: loom-compile src/ dist-loom/ |
Source: docs/usage-compiler.org in the loom repository.