Loom
Concurrent I/O in ordinary Rust functions. An effect runtime and REPL built with WebAssembly fibers.
#Loom
Loom executes Rust on WebAssembly and JavaScript in V8 isolates. Ordinary Rust functions perform algebraic effects
(loom::sleep, filesystem reads, model calls) on WebAssembly fibers: a call suspends
the fiber, a host handler does the work, the fiber resumes with the result, and
signatures stay ordinary Rust throughout. Actors (loom-actor) add durable,
supervised state: one Turso (SQLite-in-Rust) file per actor, one message per
transaction, OTP-parity supervision trees.
#Try it
nix run --builders '' .#repl -- --bind 127.0.0.1:8793
The launcher prints Token file: <path> and opens http://127.0.0.1:8793/#token=<token>. It also prints CLI: <store path>/bin/loom. In another terminal, use that CLI and token:
export LOOM_TOKEN="$(cat /path/printed/by/launcher)" export LOOM_URL=http://127.0.0.1:8793 export PATH="/store/path/printed/by/launcher/bin:$PATH" loom --url "$LOOM_URL" add examples/unison/greet.rs --lang rust --name greet # Copy the returned definition hash into OLD. OLD='paste-definition-hash' loom --url "$LOOM_URL" view "$OLD" # stored source and two items loom --url "$LOOM_URL" run greet '"loom"' # "hello, loom"; effects [] loom --url "$LOOM_URL" update greet examples/unison/greet-v2.rs # same hash: local renamed loom --url "$LOOM_URL" update greet examples/unison/greet-v3.rs # new hash: constant changed loom --url "$LOOM_URL" run "$OLD" '"loom"' # still "hello, loom" loom --url "$LOOM_URL" history greet # both hashes loom --url "$LOOM_URL" add examples/unison/sleeper.rs --lang rust --name sleeper # inferred effects ["sleep"] loom --url "$LOOM_URL" add examples/unison/counter.rs --lang rust --name counter loom --url "$LOOM_URL" spawn counter ID='paste-actor-id' loom --url "$LOOM_URL" send "$ID" 1 loom --url "$LOOM_URL" send "$ID" 1 loom --url "$LOOM_URL" send "$ID" 1 loom --url "$LOOM_URL" info "$ID" # cursor 3 loom --url "$LOOM_URL" add examples/unison/counter-v2.rs --lang rust --name counter-v2 V2='paste-counter-v2-definition-hash' loom --url "$LOOM_URL" validate "$ID" "$V2" 3 # Differs, with table hashes loom --url "$LOOM_URL" promote "$ID" "$V2" --rationale e2e --author e2e loom --url "$LOOM_URL" lineage "$ID" # both behavior hashes loom --url "$LOOM_URL" send "$ID" 1 loom --url "$LOOM_URL" info "$ID" # cursor 4
The guests use plain root-level pub fn entries, no macros or effect declarations. The counter declares its SQL schema with pub const LOOM_SCHEMA: &str; add infers effect rows, including the sleeper's generic trait call.
For the executable proof, stop the daemon on the test port, ensure nix, bun, and curl are on PATH, commit your changes, then run:
LOOM_E2E_PORT=8793 scripts/e2e-unison.sh
The script starts nix run --builders '' .#repl -- --bind 127.0.0.1:8793 with a fresh state directory on this machine and takes the CLI from the launcher’s CLI: line. LOOM_E2E_PORT defaults to 8787; the command above uses 8793 to leave an existing REPL on 8787 alone. It checks the sequence above, moves its disposable source file before view, then repeats the workflow through authenticated HTTP MCP. Each check prints ok <n> <name> or FAIL <n> <name>: <reason>. MCP prints its own N/9; the final line is the overall N/9. A full pass is 9/9 with exit status zero. State and logs remain at the printed directory for inspection.
See the guide's Try it section for transport details and response shapes.
The package includes nightly-2026-08-24 and the prebuilt content-hashing rustc driver. Guest compilation uses those tools through the launcher’s RUSTC and LOOM_HASH_RUSTC settings.
#TypeScript and JavaScript actors
This release changes the V8 execution ABI. Existing running JS or TypeScript actors admitted under an older ABI can prevent daemon startup; see backend upgrade checks and limits. The native POC uses fresh databases.
TypeScript and JavaScript use the same durable actors, SQL transactions,
capabilities, and message delivery as Rust. TypeScript is the default language
for add. Save this as counter.ts:
const LOOM_SCHEMA = "CREATE TABLE increments(amount INTEGER)"; const main = loom.messages.json(async (message: {amount: number} | null) => { if (message === null) return; await loom.sql("INSERT INTO increments VALUES (?)", [message.amount]); });
loom add counter.ts --name counter loom spawn counter ID='paste-actor-id' loom send "$ID" '{"amount":1}' loom info "$ID"
loom.messages.json decodes each message as UTF-8 JSON. A plain main receives
an array of bytes. Each invocation starts with fresh JavaScript state; keep
durable state in SQL. LOOM_SCHEMA runs when an actor is created or promoted.
Ordinary functions can also use loom run; its JSON array supplies positional
arguments to main(...args).
Use const main = loom.actor({onStart, onMessage, onStop}) when an actor needs
lifecycle hooks. onMessage receives decoded JSON and is required; the other
hooks are optional. The host calls onStart after schema setup on activation,
before ordinary messages, and onStop("node_shutdown") during graceful node
shutdown. An abrupt host exit cannot run onStop. To resume a container-backed
workflow, persist its desired configuration in SQL and create a fresh container
in onStart; container process memory is not restored. See
actor lifecycle.
Loom transpiles source-only TypeScript with deno_ast and bundles modules
with pinned Deno tooling at admission; neither path performs semantic type
checking. The runtime provides the Loom APIs below. It does not install
Deno globals or operating-system access. Use --lang javascript for JavaScript
source and --lang rust for Rust.
Static imports support npm:, jsr:, and approved HTTPS origins. Admission
stores the bundle, dependency lock, and dependency sources; runtime reopen
uses that stored artifact offline. Define main locally or export it by name.
Dynamic imports and default-only exports are unsupported. See
module admission for the host policy.
Pass capabilities in messages to connect actors. Inside a JSON handler:
const peer = await loom.actors.accept(message.peer); await peer.send({type: "hello"});
accept verifies and saves the capability. Actor references encode messages
as UTF-8 JSON and expose send, call, reply, timers, and stop.
loom.actors.spawn(spec) returns a child reference; serializing a reference
passes its capability token. Knowing an actor ID alone does not grant access.
Calls return a request reference; replies arrive as later actor messages.
Use await loom.actors.named("worker") for a host-published actor in the
current tenant and await loom.actors.sender() for the current message's
sender. Names resolve within the tenant selected by the authenticated host;
guest code cannot select another tenant. loom.actors.spawn takes a behavior
hash and ordinary JSON initialization:
const child = await loom.actors.spawn({behavior: message.behaviorHash, init: {count: 0}}); await child.send({type: "increment", amount: 1});
Host-configured processes use the same capability and message system.
await loom.processes.named("claude") accesses the registered process actor;
await loom.processes.spawn("claude", {subscriber: await loom.actors.self()})
starts a new instance of that preset and subscribes before it starts.
Process handles provide write(text), closeStdin(), cancel(), and
subscribe(actor). Output arrives in later messages. Processes do not
automatically restart after exit or host restart.
For a temporary container, select an image through the tenant's host-configured Docker connection:
const sandbox = await loom.containers.spawn({ image: "alpine:3.22", command: "cat", network: "none", limits: {memoryMb: 128, cpus: 1, pids: 32}, ttlMs: 60000, subscriber: await loom.actors.self(), }); await sandbox.write("hello\n"); await sandbox.closeStdin();
Containers return the same process handles and output messages as process
presets. Persist and restore their capabilities with loom.processes.get(cap).
See temporary containers for
host configuration and lifetime.
#CAS and Linux VMs
loom.cas.put(bytes) stores raw bytes and returns a serializable {$ref: CID}.
loom.cas.get(ref) reads them from the current tenant's store. Use
putJson(value) and getJson(ref) for JSON documents. Guest CAS operations
have a 128 KiB object limit; the rootfs importer
streams larger files into the tenant's CAS.
loom.vms.spawn boots a Linux rootfs manifest from CAS and returns the same
process handle as containers. Inside an actor handler receiving message.image:
const vm = await loom.vms.spawn({ image: message.image, command: "/bin/sh", args: ["-i"], env: {PATH: "/bin"}, network: "none", limits: {memoryMb: 256, cpus: 1, rootfsMb: 64}, ttlMs: 60000, subscriber: await loom.actors.self(), }); await vm.write("uname -a\n");
The backend requires x86_64 Linux with KVM. VM networking currently supports
only "none". Shutdown destroys the temporary VM and its writable rootfs;
an actor's next onStart can boot a fresh VM from its saved image reference.
SQL state resumes; VM memory snapshots are not restored. See the
VM actor example,
native smoke, and
CAS and VM setup.
#WebSockets and effects
await loom.websockets.listen() attaches a native WebSocket listener driver
to an actor. On websocket.message, use await loom.websockets.sender() to
get the connection handle, then send(text), sendBytes(bytes), or close().
The native connection survives fresh V8 isolates between messages. Host
restart closes it; the actor must listen again and the client must reconnect.
See the JavaScript API guide for an
authenticated browser example and editor declarations.
loom.sql(query, params) accepts plain JavaScript values and returns an array
of row objects. The raw loom.perform("sql", ...) contract uses tagged SQL
cells. loom.now and loom.random use the recorded effect path.
await loom.perform(op, args) exposes the complete
actor effect API.
Actor SQL and messaging require an actor transaction; a standalone run uses
the runtime's host effects.
Use --allowed_effects '["sql","actor.send"]' to restrict a definition's
escaped effects. JavaScript effects are checked at execution because their
names can be computed dynamically. Loom definition dependencies are currently
rejected for TypeScript and JavaScript.
#Native container and lifecycle smoke
The standalone container actor persists its image configuration and recreates its container after daemon restart. The native smoke script loads this same source, checks stdin/stdout before and after restart, and requires different container IDs.
On Linux, use a prebuilt loomd, a static BusyBox executable, and a running
Docker daemon. The packaged daemon embeds the pinned import compiler wrapper
and bubblewrap paths. For an ad-hoc Cargo binary, set LOOM_DENO to the
confined Deno compiler wrapper and
LOOM_BWRAP to an absolute bubblewrap executable, or put bwrap on the
supervisor's absolute PATH. The compiler needs network access during package
admission; the smoke then disables it to verify offline reopen.
Build the real Claude Code image with the pinned image builder:
./examples/claude-container/build.sh LOOM_STATIC_BUSYBOX=/absolute/path/to/busybox python3 tools/smoke-loomd-v8.py \ /absolute/path/to/loomd \ --npm \ --docker-executable /absolute/path/to/docker \ --docker-image busybox@sha256:9db7b59979c38555a39def84a31fb98b5296952f9e3afd4f6f11f05b07adfab0 \ --claude-image loom-claude:2.1.272
Preload the BusyBox image into that Docker daemon. Set DOCKER and
DOCKER_HOST for the builder and add --docker-host ENDPOINT to the smoke
command when using a custom Docker connection. This native POC passed 9/9;
the script reports "passed": 9, "total": 9. It covers durable SQL,
tenant-scoped actor messaging, process I/O, WebSockets, external package
admission and offline reopen, container stdin/cancellation/removal, actual
Claude Code startup, and graceful lifecycle restart with a fresh container
and no replayed stdin. The Claude check runs the actual binary's --version
without credentials or an inference request.
#Updating functions and callers
update rebuilds affected definitions in dependency order and publishes their names
in one transaction. Historical hashes remain executable. If a caller stops
typechecking, the response contains update.status: "needs_repair", a durable
session ID and revision, and the affected sources with compiler diagnostics. Live
names retain their previous definitions until the entire update succeeds.
Agents should pass expected_hash from the source they edited and a unique
request_id that can recover the session after a timeout, then use
update_view and update_repair with the latest session revision. Repairs are a
JSON map of names to source edits, so a script can submit several fixes together.
update_rebase retains repairs across disjoint namespace changes and refuses to
overwrite a concurrently edited definition. add requires a new name.
The CLI, HTTP, MCP and browser use these same commands. See the scripting example for a runnable update and repair client. The native regression command is:
LOOM_URL=http://127.0.0.1:8817 LOOM_TOKEN_FILE=/path/to/disposable/token bun scripts/e2e-evolution.ts
It creates test definitions in the supplied daemon and reports N/8; use a
disposable state directory. A pass requires 8/8 and exit status zero.
#Effects
Effect rows are inferred from resolved calls, including concrete trait and generic calls. A runtime-selected perform label is rejected with its call site; use a literal or Rust constant label.
Calling an effect performs it. A handler can supply a value, forward to an outer handler, or keep a one-shot continuation to resume later:
use loom::sleep;
pub fn main() {
loom::scope(|s| {
let a = s.spawn(|| sleep(100)).expect("spawn");
let b = s.spawn(|| sleep(200)).expect("spawn");
a.join().expect("sleep");
b.join().expect("sleep");
});
}
The two scoped children run concurrently, so their sleeps overlap; scope waits for
both before returning. Guest code has no macros and no effect declarations: an entry is
any pub fn at the crate root, and the set of host effects a definition can reach (its
effect row, here ["sleep"]) is inferred from the resolved call graph by the same rustc
driver that computes its content hash, shown by add and view, and enforced by the
host at run time. A perform whose label is not a literal or a const is a compile error
at that line. The guest-handler round trip (install, dispatch, resume,
remove) measured 13.811 µs median, 24.356 µs p99 over 10,000 warm calls on Linux,
September 10, 2026. Reproduce with bun scripts/bench/effects-handlers.ts. See
docs/guide.md for handler installation and
content-addressed handlers.
#Actors
An actor is one file: a mailbox (inbox), a behavior (content-hashed, in
code_changes), and domain tables the behavior owns. Handling one inbox message
runs inside one transaction that commits domain writes, effect records, and outbox
rows together; nothing is delivered until that transaction commits.
sequenceDiagram
participant A as A.handle
participant FA as A.db
participant Pump
participant FB as B.db
A->>FA: sql() writes + cx.send(B, msg)
FA->>FA: COMMIT (domain rows, outbox row, cursor)
Pump->>FA: read undelivered outbox
Pump->>FB: INSERT OR IGNORE inbox (keyed, exactly once)
FB->>FB: COMMIT, mark outbox row delivered
Note over FB: B.handle runs on the next pass
A handler error (or panic) is a Trap: the message rolls back, a dead_letters row
is written, and the actor parks. A supervisor restarts it by resume (retry the same
message, after a code change), skip, or reset (fresh file, same id):
graph TD
S[Supervisor] --> A[Worker A running]
S --> B0[Worker B running]
B0 -->|trap at seq 5| B1[Worker B parked]
B1 -->|promote new_hash| B2[Worker B parked, new code]
B2 -->|restart resume| B3[Worker B running, retries seq 5]
Actor behaviors are stored Rust definitions. Add one, then spawn it by its returned
hash or name on the running node. Every crate-root pub fn is an entry; effect
rows are inferred by the driver.
pub const LOOM_SCHEMA: &str = "CREATE TABLE arrivals(value INTEGER)";
pub fn handle(_message: Vec<u8>) {
let args: loom::serde_json::Value = loom::serde_json::from_str(
"{\"sql\":\"INSERT INTO arrivals VALUES (42)\",\"params\":[]}"
).unwrap();
let _: loom::serde_json::Value = loom::perform("sql", args).unwrap();
}
Strategies (one_for_one, one_for_all, rest_for_one, dynamic), links, monitors,
forking a live actor at a past seq for validation, and node.promote_where for a
hot-load sweep across every actor on a hash are in
docs/actors-turso.md.
#Hook in over MCP
loomd also serves actors as MCP tools, so a coding agent can inspect and drive the
supervision tree directly. CLI, HTTP, and MCP use the same verb and argument names
and return {ok, seq, result, diagnostics}, including failures. Omitted spawn
init defaults to null; promotions require author and rationale:
| tool | does |
|---|---|
add(source, name?) / update(name, source, expected_hash?) |
add a new name or atomically update it and its callers |
update_view(id) / update_repair(id, revision, changes) |
inspect a durable update and submit a batch of source repairs |
update_rebase(id, revision) / update_abort(id, revision) |
retry after disjoint namespace changes or abort pending work |
view(target) / history(name) |
stored source and definition history |
diff(old, new) / dependents(hash) |
item differences and pinned callers |
run(target, args?) / find(text) |
execute or search definitions |
actors(cluster?) |
actors with their owner; cluster: true lists the shared store (loom actors --cluster) |
nodes() |
cluster nodes: node id, advertised address, start time, and liveness |
whereis(name) |
local name lookup, or placement and owner address when given an actor id |
move(id, node_id) |
ship and release an actor on its owner, then restore it on the target node |
tree(root?) |
nested tree from a root (default the node's root supervisor) |
info(id) |
status, reason, cursor, deferred/inbox length, links, monitors, children |
send(id, key?, msg) |
inject a keyed message; return cursor or a failed envelope with id, sequence, and trap cause |
spawn(def, init?, parent?, spec?, durability?) |
spawn under a parent; durability accepts local, remote, or ephemeral |
view(actor, table, template, order_by) |
spawn an ephemeral view and return its id and opaque INSPECT cap string; CLI uses view --actor ID --table TABLE --template HASH --order_by '["column"]' |
subscriptions(id) |
list the actor's subscribers and their CDC cursors |
stop(id, reason) |
stop with a reason |
restart(id, verb) |
resume | skip | reset |
promote(id, hash, author, rationale) |
append a code_changes row |
promote_where(old, new, author, rationale) |
promote every actor on old_hash |
lineage(id) |
the actor's code_changes rows |
dead_letters(id) |
its trapped messages |
fork(id, seq) |
copy the actor's state as of seq into a new, undeliverable fork |
validate(id, candidate, k, assertions?) |
replay the last k messages under candidate_hash on a fork, report Matched/DivergedAt/Differs/Trapped |
sql(id, query, params?) |
read-only inspection; a write statement is refused |
register / members |
name registration and group lookup |
behaviors() |
stored definition hashes, one line each |
drain() |
run every actor to idle, return messages processed |
Resources: actor://<id>/inbox, .../effects, .../outbox, .../lineage, and
actor://tree.
bun scripts/configure-codex-mcp.ts --token-file /path/to/loom/token
#Content-addressed code
Loom can identify Rust definitions by resolved HIR content with tools/hash-rustc.
The driver uses the pinned nightly-2026-08-24 compiler and its normal pipeline.
The Nix package ships it prebuilt against that compiler and points the daemon at
both through LOOM_HASH_RUSTC and RUSTC.
Set LOOM_ITEM_HASHES and LOOM_ITEM_PREIMAGES for JSON identities and checkable bytes.
Formatting, local renaming, and item reordering leave ordinary entry hashes unchanged.
Changing a reachable helper changes the entry; changing an unrelated helper does not.
Mutually recursive items share one cycle hash and receive indexed member hashes.
Inherent method calls resolve to one method; trait calls retain the trait method identity.
Generic bodies hash once, and monomorphized implementations are outside that identity.
Wasm and toolchain digests must separately identify each executable realization.
The build pipeline verifies compiler identities and stores item preimages alongside executable identities; see docs/content-addressed-code.md.
Details: docs/content-addressed-code.md.
#Layout
| crate | is |
|---|---|
loom-actor |
one Turso file per actor, the pump, supervision |
loom-api |
HTTP/WebSocket service: auth, CAS browsing, the REPL API |
loom-build |
core wasm builder |
loom-check |
effect/language checking before a definition becomes executable |
loom-cli |
command-line client for a running loomd |
loom-guest-rs |
synchronous guest interface to the host, for Rust definitions |
loom-maintenance |
garbage collection over derived indexes only; CAS and event log untouched |
loom-mcp |
MCP server exposing the API as tools |
loom-model |
OpenAI-compatible model provider boundary |
loom-process |
durable process lifecycle for sandboxed machine commands |
loom-proto |
shared protocol types |
loom-rt |
the effect host runtime: filesystem, machine execution, shared core |
loom-store |
SQLite-backed event log and content-addressed store |
loomd |
the daemon binary: UI, API, and MCP endpoints |
Top level: crates/ (above), docs/, examples/, scripts/, deploy/, nix/,
ui/ (Svelte browser REPL), rustc/ (Rust
guest toolchain build).
#Docs
docs/guide.md covers the HTTP API, non-Nix setup, and isolation model. docs/actors-turso.md is the actor spec in full, including OTP parity. docs/content-addressed-handlers.md covers stored handler definitions. scripts/bench/README.md reproduces the largest-file-scan benchmark described there.