A live application world that an agent grows by talking to it. The world is a snapshotable WASM heap, so every change is checked, versioned and reversible.
Status: working prototype, running on one celld node at
http://oracle-arm.mist-walleye.ts.net:8787. The web UI, the agent on a Claude subscription, revisions, checks, direct calls, calls between worlds and world pages work there, with each world's calls and changes in an isolate of its own. Pauses, forks, replay, scheduled world code and durable messages between worlds are still designs. Sections below say on the node (seen working on the deployed node), built (code and tests exist), spike (a spike shows it) or designed (a document says it).
The Problem. An agent that builds an application by editing files has no safe way to try a change against the running program. A bad edit breaks the live process, a restart loses in-memory state, and nothing says which change broke what. The agent is also in the loop at runtime, though it is only needed to grow the program.
The Solution. The application is one QuickJS heap compiled to WASM
(quickjs-wasi), driven by
@earendil-works/pi-durable. The agent changes it
with develop(source). Each attempt runs against an in-memory checkpoint; safety invariants decide whether it becomes
a new immutable revision or is restored. Accepted definitions are ordinary functions that people call over REST with
no model request. The target is celld (self-hosted Durable Objects) with GCS buckets.
develop(source)
checkpoint = snapshot of the heap # in memory, a few milliseconds
evaluate source # synchronous, in a function scope, under a time budget
if it threw, touched data, broke a check, or ran out of budget
restore checkpoint
return rejected, with the reason
write the snapshot blob # content-addressed, idempotent
commit the revision manifest and the head pointer
return accepted as revision N+1
It is the fastest implement-and-check loop for one kind of artifact: a program that grows through prompting, where the result is validated and iterated on immediately. It is one station of a larger line, not the line.
| Capability | What it means | Status |
|---|---|---|
| Grow an app by talking | The agent writes develop sources; each accepted one is a revision |
on the node |
| Checkpoint and restore | A failed attempt leaves no trace; snapshot and restore take a few milliseconds | on the node |
| Direct calls without a model | POST /api/worlds/:id/call/:fn runs the function in the world's own isolate; on the node over loopback, p50 3.2 ms and p99 60 ms at 100 calls/s (performance.md, findings 5 and 6) |
on the node |
| Worlds that do not stall each other | Each world's calls and changes run in an isolate of its own. Isolates are not cores: on the 2-core node, a call computing in one world adds 16 to 65 ms to another world's p99, and a flood of 200 cheap calls/s about 90 ms (performance.md, finding 10) |
on the node |
| Trying without touching data | execute is a preview: its data writes are rolled back and listed in its result |
on the node |
| History that stays small | The head snapshot stays local; older ones move to R2, gzipped from 1.4 MB to about 146 KB | on the node |
| Worlds calling worlds | await worlds.call(id, name, ...args) uses another world's service; cycles are refused |
on the node |
| Pages served by the world | define("app", (path, query) => html) is served at /w/:id/ |
on the node |
| Checks the agent cannot weaken | Enrolled only after failing on a counterexample; removed only by you, with a reason | on the node |
| Plain declarations are definitions | function f and class C at the top of a source are rewritten into define calls on the host; the revision keeps both texts |
on the node |
| Checks that cannot go vacuous | Every develop re-runs every check's counterexample; a change after which a check can no longer fail is rejected | built |
| Property checks | A check with a generator must hold for 50 generated inputs; a failure names the smallest input it shrinks to. Every check gets a fresh scratch data store | built |
| Grants per world | You choose which other worlds a world's code may call (Access tab or PUT /api/worlds/:id/grants); the agent cannot change it |
on the node |
| Who calls whom | describe and the Functions tab list what a definition calls and what calls it |
on the node |
| Memory per world, measured | A world that has run a call costs the node about 16 MB; each call it holds open about 2.4 MB more (research.md) |
on the node |
| Failed calls you can fix and retry | A failed call is an incident with its arguments and error; ask the agent to fix the definition, then retry the call at the new revision. A failed call wrote no data, so the retry is safe | on the node |
| Serializable calls | A call's data writes are made together when it succeeds and nothing it read has changed, and dropped when it fails; a call whose reads went stale runs again | on the node |
| Busy worlds refuse, not queue | A call that cannot start within a second of arriving is refused with 503 and Retry-After: 1, and opens no incident |
on the node |
| Rejections kept as records | Every rejected develop and refused check is numbered and kept with its source and reason, so compaction cannot lose it |
on the node |
| Immutable revisions and rollback | Rollback creates a new revision | on the node |
| Runs that survive a crash | A run interrupted by a celld restart continues and finishes, with no revision accepted twice | on the node |
| Claude through your subscription | OAuth as pi does it, or a claude setup-token token, checked with Anthropic before it is saved |
on the node |
| Deterministic snapshots | Same source on the same base gives byte-identical snapshots | built |
| Live redefinition | A redefinition reaches captured references, callbacks, old instances and subclasses | built |
| State that survives re-evaluation | state(name, init, { version, migrate }) keeps its value across re-runs |
built |
| Runaway code stopped | A time budget interrupts a loop; the VM stays usable | built |
| Pauses that survive crashes | await restart(id, question, options) is a pending promise in a stored snapshot |
spike; the durable path is designed |
| Cheap forks | Forks merge by replaying accepted develop sources |
designed |
A develop source is ordinary JavaScript: every top-level function and class declaration becomes a definition,
with the JSDoc comment above it as its doc. The prelude, installed at revision 0, is the rest of how sources are
written:
| Function | What it does |
|---|---|
define(name, impl, { doc, version, migrate }) |
Add or replace a global function or class; every caller reaches the newest impl |
undefine(name) |
Remove a definition |
state(name, init, { version, migrate }) |
A heap object that init creates once and later evaluations keep |
data.get / set / delete / list(prefix) |
The world's persistent key-value store, JSON values; for calls only, never a develop |
worlds.list() / functions(id) / call(id, name, ...args) |
The other worlds on the server; promises, for calls only. See Calls between worlds |
A world built before a prelude change is brought up to date by an upgrade revision when it next opens.
const cache = state("lookup.cache", () => ({ hits: 0 }), { version: 1 });
define("lookup", (key) => {
cache.hits += 1;
return table[key];
});
// A later develop redefines lookup; every caller sees it, and cache.hits is kept.A call always goes through a stub that is created once, so a reference captured before a redefinition still reaches the newest implementation:
lookup("a") # any caller, including one holding an old reference
stub lookup # the global binding; never replaced
registry["lookup"].impl # swapped by each define
A redefinition changes one registry entry and nothing else:
registry
- lookup → (key) => table[key]
+ lookup → (key) => { cache.hits += 1; return table[key]; }
globalThis.lookup # the same stub
state "lookup.cache" # kept: { hits: 41 }A class that changes shape declares a version and a migration. Every live instance is migrated inside the attempt,
so a migration that throws rejects the develop:
define("Point", class { ... }, { version: 2, migrate })
live instance p
- x: 3, y: 4 @1
+ rho: 5, theta: 0.927 @2Every world's definitions are a service to every other world on the server. World code calls one with
await worlds.call(worldId, name, ...args); worlds.list() and worlds.functions(worldId) say what exists. This
is an asynchronous request and response between two worlds, each running in an isolate of its own: the caller awaits
a promise, the other world runs the function on a VM in its own isolate, and the result or the error comes back. No
model is involved at any point.
sequenceDiagram
participant CA as Caller's code<br/>(world A's isolate)
participant HA as WorldHost of A<br/>(host isolate)
participant CB as World B's cell<br/>(host isolate)
participant RB as World B's runtime<br/>(world B's isolate)
CA->>CA: await worlds.call("b", "convert", 40500, "JPY", "USD")
Note over CA: chain [a] checked, the promise waits
CA->>HA: call("b", "convert", args, chain = [a])
HA->>CB: peerCall("convert", args, [a]) over Durable Object RPC
CB->>RB: call("convert", args, [a], head = { revision, blob })
opt the head is not cached in B's isolate
RB->>CB: blob(hash) through B's WorldHost
CB-->>RB: the head's snapshot bytes
end
Note over RB: a pooled VM runs convert on B's data
RB-->>CB: { ok: true, value: 270 } or { ok: false, error }
CB-->>HA: the outcome
HA-->>CA: the outcome
Note over CA: WorldVm resolves the promise, the code resumes
In A's isolate, chainedPeers refuses a call that would loop back into the chain before anything is sent, and
WorldVm.settle waits outside the VM for the answer, then resolves or rejects the promise. Each world has three parts
on the node. Its cell holds the agent, the revisions, the head snapshot and the lock. Its runtime isolate, a
facet of the cell loaded through a Worker Loader, runs its calls, holds its data, and does the VM work of its
develops and checks. Its WorldHost is a capability in the host isolate that the runtime calls back through: for
snapshots by hash, and for every call that leaves the world. The runtime has no other way out.
- It is asynchronous for the code.
worlds.callreturns a promise, so a function that uses it isasync. Calls awaited together run at the same time: two 200 ms calls underPromise.alltake about 200 ms (test/peers.test.ts). - A world answers many calls at once. Each call runs on its own VM in the world's runtime isolate, taken from a
pool and restored from the head revision the call started at. A call that awaits another world keeps its VM, and the
world's other calls run meanwhile on other VMs. A world runs up to 8 calls at once. A call that cannot start within
a second of reaching the world's cell, because no VM is free or the isolate is busy, is refused without running:
HTTP 503 with
Retry-After: 1and{ ok: false, refused: "busy", error }. A refusal is not a failure and opens no incident. - Worlds do not stall each other. Each world's calls and changes run in its own isolate, so a world busy
computing holds up only its own calls. The isolates still share the node's cores: on the 2-core node, a call
computing in one world adds 16 to 65 ms to another world's p99 (
performance.md, finding 10). - Changes do not wait for calls. A develop, rollback or check runs under the world's lock, which calls do not take, on the workbench VM in the world's runtime isolate. A call that started before a new revision finishes on the revision it started at; the next call sees the new one.
executeis a preview. Its data writes are rolled back and listed in the result; other worlds it calls write for real.- Calls are serializable, heap is not kept. Heap changes a call makes vanish with its VM. A call's data writes
are made together, in one transaction, when it succeeds, and dropped when it fails; until then the call reads its
own writes and other calls do not see them. The commit checks that nothing the call read has changed. A call whose
reads went stale runs again from the start, one rerun at a time per world, at most 5 times, and then fails as a
conflictincident. A rerun repeats the calls it made to other worlds, and what those worlds wrote stays. - Errors cross over as rejections. A throw in the other world rejects the promise with an
Errorwhose message names where it came from, hop by hop. - Cycles are refused. A call carries the chain of worlds it has passed through, and a call back into a world already on it is refused at once. A waiting call holds a VM in every world on its chain, so a cycle could use up a world's VMs and then wait for one of its own. A chain is at most four worlds.
- Only calls reach other worlds. A
developor a check that touchesworldsis rejected, as withdata.
A shared service. One world owns a capability and others use it rather than copying it. The agent built a
Currency world, then a Trip budget world that found it with worlds.list() and worlds.functions(id):
// Currency (currency-edcacf)
define("convert", (amount, from, to) => { /* fixed rates kept in state */ }, { doc: "Convert between USD, JPY, EUR and CAD." });
// Trip budget (trip-budget-3a39bb)
define("totals", async (currency) => {
const totalYen = listExpenses().reduce((s, e) => s + e.yen, 0);
const converted = await worlds.call("currency-edcacf", "convert", totalYen, "JPY", currency);
return { yen: totalYen, currency, converted };
});POST /api/worlds/trip-budget-3a39bb/call/totals {"args": ["USD"]} answers
{"yen": 40500, "currency": "USD", "converted": 270} in about 60 ms.
A view over several worlds, asked in parallel. A Dashboard world reads two worlds at once; one of them calls a third:
// Dashboard (dashboard-485394)
define("overview", async (currency = "USD") => {
const [todos, trip] = await Promise.all([
worlds.call("demo-todos-031d08", "stats"),
worlds.call("trip-budget-3a39bb", "totals", currency),
]);
return { openTodos: todos.open, tripSpent: trip.converted, currency };
}, { doc: "Todos and trip spending from two other worlds, asked in parallel." });overview("EUR") answers {"openTodos": 2, "tripSpent": 248.4, "currency": "EUR"} in 70 to 100 ms on the node,
across four worlds: Dashboard, Demo todos, Trip budget and Currency.
Handling another world's failure. A caller decides what a failure means for it:
define("tripIn", async (currency) => {
try {
return await worlds.call("trip-budget-3a39bb", "totals", currency);
} catch (error) {
return { error: error.message };
}
});tripIn("GBP") answers {"error": "trip-budget-3a39bb.totals: Error: currency-edcacf.convert: Error: Unknown currency: GBP"}: the message carries the path the error took.
Discovery. The agent finds services the same way world code does, with execute:
worlds.list() gives [{ id, name }], and worlds.functions(id) gives each definition's name, kind, doc and
parameters.
| Limit | Value | What happens past it |
|---|---|---|
| Calls one world runs at once | 8 VMs (concurrentCalls) |
Further calls wait for a VM; one that cannot start within 1 s of arriving is refused, 503 with Retry-After: 1. The longest wait is 1 s plus one call's service time: 1.25 s on the node (performance.md, finding 7) |
| Requests in flight in one cell | 64 (the node setting CELLD_MAX_CELL_REQUESTS), counting incoming requests and the cell's own calls to other worlds |
celld answers 503 cell request limit reached at once |
| One call's wait on other worlds | 30 s | The call fails |
| CPU | Each world's calls and changes run in an isolate of the world's own; the node has 2 vCPUs | A world busy computing holds its own calls; others see their p99 rise by 16 to 65 ms (performance.md, finding 10) |
| Worlds per node | 255 loaded workers per script and generation (a deploy), one per world, freed only when collected | With no idle eviction on the node, at most 255 worlds that have run something since the last deploy, forgotten worlds included; a world past the limit fails to load. Under celld dev world 239 of a run failed at creation, after 16 worlds of earlier runs (performance.md, finding 2, pw-djz.9) |
On the node, 20 concurrent calls that each call another world finished in about 0.25 s. At 40 concurrent calls, 28 were answered and 12 refused with 503; at 60, 16 and 44. The cell answered again within seconds. These numbers were measured with calls running in the shared cell isolate.
A call is a query or a command that needs its answer now. It is not durable messaging: there is no queue, retry or
idempotency key, a caller that crashes mid-call loses the call while data the other world wrote stays, and a world that
does not answer within 30 s fails the call. A world calls only the worlds its owner granted (any, until restricted;
new worlds will start with none, pw-rxw.8). Whether calls
between worlds should also have durable messages through celld Queues is open decision 14 in
decisions.md, with the trade-offs and the questions to research.
This is the example session from jiti's README, as it runs in pi-world (test/agent.test.ts replays it with a
scripted model). You talk to the agent through the web UI or POST /api/worlds/:id/messages.
The last two steps do not involve the agent or a model at all.
you> Add uppercaseString. Return an uppercased copy of the input.
you> Add reverseString. Return a reversed copy without modifying the input.
you> Uppercase "Hello", then reverse the result using those functions.
you> Save that combination as shoutBackwards.
GET /api/worlds/:id/functions
POST /api/worlds/:id/call/shoutBackwards {"args": ["Hello"]}
| Step | Who acts | What happens | Result |
|---|---|---|---|
| 1 | Agent calls develop |
The source below is evaluated against a checkpoint. It touches no data and the checks pass. | Accepted as revision 1 |
| 2 | Agent calls develop |
Same path. JavaScript strings cannot be modified, so the input is safe by construction. | Accepted as revision 2 |
| 3 | Agent calls execute |
Runs reverseString(uppercaseString("Hello")) in the world. Nothing in the world changes. |
"OLLEH", no revision |
| 4 | Agent calls develop |
Defines the combination from step 3 as a named function. | Accepted as revision 3 |
| 5 | You, no model | The catalogue lists shoutBackwards with its source and the revision that introduced it. The world inspector in the web UI shows the same. |
A catalogue entry |
| 6 | You, no model | The cell calls the accepted function directly, under the same time budget. | "OLLEH", no revision |
The sources the agent writes in steps 1, 2 and 4:
define("uppercaseString", (s) => s.toUpperCase());
define("reverseString", (s) => [...s].reverse().join(""));
define("shoutBackwards", (s) => reverseString(uppercaseString(s)));What differs from jiti:
- Names. jiti's
uppercase-stringbecomesuppercaseString, because a definition is a JavaScript global. - Slash commands. jiti's
/describeand/executeare terminal commands. Here they are aGETof the catalogue and a directPOSTcall with{"args": [...]}. - A failed step. If a
developthrows, touches data, breaks a check or runs out of budget, the checkpoint is restored and the agent gets the reason. The world stays at the last accepted revision. - Later changes.
shoutBackwardscalls the other two through their stubs. If you later ask for a differentreverseString,shoutBackwardsuses the new one without being touched.
- The world is a live image. Code and heap state live in one snapshotable VM. JavaScript is the world language;
a compiled language would invalidate the snapshot at every
develop. - Attempts are transactions. An attempt runs synchronously from checkpoint to accept or restore, so no direct call can observe an uncommitted heap.
- Definitions dispatch through a registry.
definenever replaces a global binding. A stable stub calls the registry entry, and a class keeps one prototype patched in place, so a redefinition reaches every caller. - Data is not heap. What calls store goes through
datainto the SQLite of the world's runtime isolate. A call's heap changes are discarded, so only revisions change the heap. Heap instances stay few, which keeps eager migration affordable. - Only
developsources replay. Merge and rebuild after a runtime upgrade replay accepteddevelopsources; adevelopthat touches data is rejected. - Write blobs first, then commit the pointer. A crash can leave an orphan blob, never a pointer to a missing one.
The full constraint list, each with the fact that forces it, is in design.md.
| pi-world | jiti | |
|---|---|---|
| Medium | QuickJS heap in WASM, snapshotable | The style this project follows |
| Redefinition | Registry with stable stubs; instances migrate by declaration | jiti's README admits active frames can keep earlier definitions |
| Failed change | Restored from a checkpoint | |
| History | Immutable revisions; rollback is a new revision | |
| Runtime without a model | Direct REST calls to accepted definitions |
The remaining hole in pi-world is a running frame: a job paused inside a function keeps that function's bytecode when
it resumes. decisions.md (open decision 8) records the choice still to make.
Open http://oracle-arm.mist-walleye.ts.net:8787 from a machine on the tailnet.
- Connect Claude (the button in the sidebar or on the welcome page). This uses your Pro/Max subscription the way
pi does. Either tap Open claude.ai, approve, and paste the code Anthropic shows; or run
claude setup-tokenon a computer with Claude Code and paste the token, which is checked with Anthropic before it is saved. One login serves every world on the server. - Create a world (top of the sidebar) and ask for something: "Keep a todo list: add, toggle, delete, list. Then make an app page for it."
- Watch the agent's
developandexecutecalls in the chat. Each accepteddevelopis a revision; a rejected one shows its reason and changes nothing. - The inspector on the right has:
- Functions: call any definition with arguments, no model involved.
- Revisions: each change with its source; roll back to any revision.
- Incidents: calls that failed. Ask the agent to fix one, retry it, answer it yourself, or dismiss it. A caller
that sends
Prefer: respond-asyncgets202and the incident to follow instead of the error. - Rejected: every attempt the world turned away, with its source and the reason.
- Data: what calls stored.
- Checks: what every future change must keep true.
- Access: which other worlds this world's code may call.
- Console: preview an expression (its data writes are rolled back), or develop a source by hand.
- App: the world's page.
- Open app opens the page the world serves at
/w/:id/.
The UI works on a phone: the chat and the inspector switch with the Chat and Inspect buttons.
npm install
npm test # vitest: the VM, attempts, the World service, calls, the agent with a scripted model
npm run typecheck # builds the runtime bundle first, then tsc
npm run build # the runtime isolate's bundle (dist/runtime/) and the UI (public/app.js)
npm run dev # build, then `celld dev` on 127.0.0.1:8791 (restart it after a change)
npm run mutate -- --mutate src/world/world.ts # Stryker mutation testing on the files you changed
npm run spike:memory # one VM's snapshot size, heap and restore time at 10, 100, 400 definitions
sh spike/memory/cell.sh # a celld server's memory per world and per held call (after npm run build)
npm run deploy # a clean commit only: typecheck, test, build, `celld deploy` to gs://pi-world-celld, wait for the nodenpm run deploy stamps the git revision into the bundle (/api/version, and build in each world's summary), so you
can see a deploy reach each world. It refuses a working tree with changes, untracked files included, before the gates
and again after them, so a build always names the commit it runs. The deploy returns once the node serves the new
build, or fails after 90 s. It reads the fleet's service-account key from ~/.config/celld/pi-world.json; set
GOOGLE_APPLICATION_CREDENTIALS and CELLD_BUCKET to deploy elsewhere. A running node adopts a new deployment
without a restart. Keep the Worker name (pi-world) stable: celld derives every cell id from it.
pw drives any pi-world server through the same typed client as the web UI (src/api/client.ts). A world is named
by its id, its name, or a unique prefix of its id. The server is --server or PW_SERVER, by default the node.
npm run pw -- worlds # every world's id and name
npm run pw -- show "Reading list" # revision, functions with docs, checks, app URL
npm run pw -- develop kit kit.js -m "add table" # develop a source file, through the agent's attempt
npm run pw -- execute kit 'slugify("Ça va?")' # preview: data writes are rolled back
npm run pw -- call reading searchLinks '"#tla is:unread"' # each argument is JSON, or a string when it is not
npm run pw -- check kit check.json # propose a check: { name, expression, counterexample, generator? }
npm run pw -- ask beads-board "Add a label filter" # talk to the world's agent; follows its turn until it settles
npm run pw -- transcript kit -n 10 # also: rejections, incidents, data <world> [prefix]--json prints the raw responses. A command that fails, a rejected develop, a refused check, a failed call or an
agent turn that ends in an error exits with status 1.
npm run sync-beads -- [world] pushes this repository's beads, with their dependencies, into a Beads board world
(default "Beads board") through its importBeads function, and prints the board's URL.
Agent tools: develop, execute (a preview: data writes rolled back), describe, history, rollback,
propose_check. The system prompt's world section lists the revision, the definitions with their docs, and the
checks. Designed but not built: answer, pauses.
REST API:
| Route | Purpose |
|---|---|
GET /api/worlds, POST /api/worlds {name} |
List worlds, create one |
GET /api/worlds/:id |
Summary: revision, functions with source, checks, model |
GET /api/worlds/:id/ws |
WebSocket: world and transcript frames. Hibernatable: the world's cell can hibernate or move to a new deploy without closing it |
GET /api/version |
The build (git revision) the node runs; each world's summary carries the build of its cell |
POST /api/worlds/:id/messages {content, requestId} |
Submit to the agent; requestId makes a retry a no-op |
POST /api/worlds/:id/abort, POST /api/worlds/:id/reset |
Stop the run; start a new context |
POST /api/worlds/:id/call/:fn {args} |
Call a definition directly, no model; world code reaches other worlds with worlds.call. A call the world cannot start within 1 s is refused: 503, Retry-After: 1, { ok: false, refused: "busy", error } |
POST /api/worlds/:id/execute {expression} |
Preview an expression against the world: data writes are rolled back and listed in rolledBack |
POST /api/worlds/:id/develop {source, summary} |
Develop by hand, through the same attempt |
GET /api/worlds/:id/revisions[/:n], POST /api/worlds/:id/rollback {revision, reason} |
History and rollback |
GET /api/worlds/:id/checks, DELETE /api/worlds/:id/checks/:name {reason} |
Checks; removal needs a reason |
POST /api/worlds/:id/checks {name, expression, counterexample, generator?} |
Propose a check as the operator; the same enrolment rule as the agent's propose_check |
GET /api/worlds/:id/data?prefix=, DELETE /api/worlds/:id/data/:key |
The world's data, held in its runtime isolate |
POST /api/worlds/:id/model {model} |
Choose the Claude model |
GET /api/account, POST /api/account/login, POST /api/account/login/finish {code}, POST /api/account/token {token}, POST /api/account/logout |
Claude login |
GET /w/:id/* |
The world's page |
There is no authentication: anyone on the tailnet can call every route.
flowchart LR
UI["Web UI (Preact)"] --> W["Worker routes"]
REST["REST clients"] --> W
W --> C["WorldCell<br/>one per world"]
W --> A["AccountCell<br/>Claude OAuth"]
W --> DIR["DirectoryCell<br/>world list"]
C --> H["pi-durable harness<br/>tools: develop, execute, ..."]
C --> WS["World service<br/>lock, attempts, revisions, checks"]
H --> WS
WS -- "calls, previews, data" --> RT
WS -- "develops, checks, rollbacks" --> WB
subgraph ISO["The world's own isolate (a facet, loaded through a Worker Loader)"]
RT["RuntimeFacet<br/>VM pool"] --> D[("world_data<br/>the facet's SQLite")]
WB["Workbench<br/>the changes' VM"]
end
RT -. "snapshots, other worlds" .-> HOST["WorldHost<br/>capability"]
HOST --> C
H --> M["Claude, via the subscription"]
C -. access token .-> A
WS --> B[("Head snapshot<br/>cell SQLite")]
B -. "older snapshots, gzipped" .-> R2[("R2<br/>snapshots/<hash>.gz")]
- Changes are decided in the world's cell and computed in its isolate. Develops, rollbacks and checks hold the
world's lock in the cell, which keeps durability, documents and the agent; their VM work runs behind the
Workbenchport in the world's facet. Storing the new snapshot and archiving the previous one still run in the cell isolate that worlds share (pw-djz.7). - Calls run in the world's own isolate. Calls,
executepreviews and the world's data live in a facet of the cell, loaded through a Worker Loader under a loader id of the world's own. CPU work there does not stall other worlds, but it shares the node's two cores with them: a call computing in one world adds 16 to 65 ms to another world's p99 (performance.md, finding 10). The facet keeps the world's data in its own SQLite, asks the cell for snapshots by hash, and reaches other worlds throughWorldHost, a capability the cell lends it. The domain sees all of this as aCallRunnerport; tests use the in-processLocalCallRunner, which the facet also runs. - Snapshots are tiered. The head's snapshot stays in the cell's SQLite, so a world opens from local storage. Every other snapshot moves to R2, gzipped (1.4 MB to about 146 KB), after each revision, and is read back from there for a rollback.
- A direct call never reaches the harness or the model.
| Directory | What it does |
|---|---|
src/world/ |
WorldVm, the prelude, attempts, the World service, the CallRunner and DataAdmin ports and their in-process runner |
src/isolate/runtime/ |
What runs in a world's own isolate: the facet and its SQLite data store. The runtime bundle is built from here alone |
src/isolate/host/ |
The host's side: the loader, the WorldHost capability and the runner that forwards to the facet |
src/revision/ |
Revision manifests, the head, the blob stores (local, R2, tiered), the pi-durable documents |
src/check/ |
The check type |
src/agent/ |
The agent's tools, its standing instructions and the world section |
src/conversation/ |
The transcript the UI shows |
src/cell/ |
The world cell: SQLite adapter, routes, WebSocket fan-out, world pages, the R2 archive |
src/account/, src/directory/ |
The Claude credential, the world list |
src/api/ |
The wire contract shared with the UI |
ui/ |
The web UI |
npm run build bundles the runtime isolate's code (dist/runtime/) and the UI (public/app.js); dev, typecheck
and deploy run it. Revisions are stored as world.head and world.revision documents holding a manifest and a
pointer to a blob.
Each line was seen on the deployed node (celld 0.6.1, one aarch64 node, GCS bucket) unless it says otherwise. Details
are in research.md.
| What | Evidence |
|---|---|
| The stack runs in a cell | quickjs-wasi, pi-durable over the cell's SQLite, and pi-ai's Anthropic provider; one answered input in about 1 s |
| Login | The copy-code OAuth login, completed by a person from a phone; an expired access token refreshed on the next model request; a fake pasted token is refused with 400 |
| An agent session | A todo app with seven functions, a check and a page, built through the UI; a todo added on the page read back by a direct call |
| Direct calls need no model | 62 to 71 ms per call, conversation unchanged; on a celld dev with no credential, calls work while agent messages fail |
| A crash mid-run | celld restarted between tool rounds with nothing sent afterwards; the run finished, no revision accepted twice |
| CPU isolation between worlds | A world computing for 1.75 s; two other worlds answered in 63 to 82 ms over the tailnet. With calls in the shared cell isolate, an unrelated world had waited 1.47 s |
| Load against the objectives | Open-loop runs over loopback, judged against the approved objectives: read calls, writing calls, chains, develops, stalls, overload, lost updates (performance.md) |
| Data moved into each world's isolate | Every world's revision, function count and data identical before and after the deploy that moved them |
| Snapshots in R2 | Seven older snapshots archived, gzipped; a rollback to an archived revision read it back in 0.82 s |
execute is a preview |
An addTodo in execute reported its write as rolled back; the todo count was unchanged afterwards |
| Worlds calling worlds | Trip budget's totals calls Currency's convert over Durable Object RPC: 270 USD for ¥40,500, the same as calling Currency directly |
| Fan-out across worlds | Dashboard's overview asks Demo todos and Trip budget in parallel, and Trip budget asks Currency: 70 to 100 ms across four worlds |
| Prelude upgrade | Demo todos, built on prelude 1, opened on the new version and got revision 6, "prelude 1 to 2" |
| Snapshot rows | A 1.44 MB snapshot round-trips through the cell's SQLite |
The test suite (npm test, 234 tests in 22 files) covers the VM, attempts, checks and property checks, the shrinker,
the VM pool, the World service, the agent with a scripted model, that a direct call makes no model request, calls
between worlds (results, errors, cycles, discovery, grants), concurrent and serializable calls, start deadlines, the
in-place rewind against a fresh restore, incidents, rejections, the
execute preview, the tiered snapshot store, and the upgrade of a heap built by an older prelude. The shrinker and
the generators have property-based tests (Hegel); npm run mutate runs Stryker. The runtime facet and the R2 archive
run only on celld, so they are covered by runs under celld dev and on the node, not by unit tests. There are no
Gherkin scenarios yet.
| Definitions | Raw snapshot | Gzipped |
|---|---|---|
| 0 | 1.38 MB | 109 KB |
| 200 | 1.64 MB | 176 KB |
| 2000 | 4.39 MB | 620 KB |
Growth is linear, about 1.5 KB per definition. The head's snapshot is stored raw in the cell; older snapshots are gzipped in R2. Real worlds may differ.
- A prototype: no authentication, one node, no pauses, forks or replay. The defaults chosen for open questions are
listed in
decisions.mdunder "Prototype assumptions". - celld is in beta. Open checks that remain (CPU per cell; what wakes a cell after a restart; a run moving to another
node; streaming lag) are in
research.md. - A develop's VM work runs in the world's own isolate, where it holds up that world's calls; each evaluation has its
own budget and a develop runs every check, so the delay grows with the number of checks, and a check runs out of
budget at most once per develop. Storing and archiving the snapshot still run in the shared cell isolate: under
celld dev, a develop with 3 property checks raised another world's p99 by 73 ms (performance.md, finding 11,pw-djz.7). - A node holds at most 255 worlds that have run something since the last deploy, forgotten ones included
(
performance.md, findings 2 and 13,pw-djz.9). - World pages are served on the same origin as the operator API, so a page's script can use every route
(
pw-rxw.1). - A call whose reads went stale reruns, and a rerun repeats its calls to other worlds;
data.listreturns at most 1000 rows without saying so (pw-rxw.6). - A busy world refuses calls after 1 s plus one call's service time, not at 1 s exactly (
performance.md, finding 7,pw-djz.3). - Checks cannot read data, so a behaviour that depends on stored data is hard to protect with one.
executerolls back its own world's data writes, but a world it calls throughworlds.callwrites for real.- A snapshot belongs to the exact
quickjs.wasmbuild. After a runtime upgrade, the world is rebuilt by replaying the source log. - Only instances built with
newthrough a class are tracked and migrated. - State values must be objects, not primitives.
- A running frame keeps the bytecode it started with.
- Each durable write waits for the bucket on a single node: writing calls take p50 164 to 170 ms and p99 303 ms on
the node at 50/s (
performance.md, finding 8,pw-djz.4); progress commits are spaced 400 ms apart.
Does the agent run the application? No. Accepted definitions are plain functions behind the REST call path. The agent is needed to grow the application.
Why JavaScript and not a compiled language? Every develop would be a recompile into a module with a new memory
layout, which invalidates the previous snapshot. Compiled code is planned as a tier for stable, hot functions
(plan.md, "Later").
What happens to an agent run when the node dies? It continues from its last committed step when the cell comes back; this was seen on the node.
What happens when a job is waiting for an answer and the node dies? Designed, not built (open decision 8). The
design below keeps the pause as a pending promise in a stored snapshot. A spike on celld Workflows showed the
alternative: the pause as a recorded step that survives a SIGKILL, with the job rerun from its start
(research.md).
sequenceDiagram
participant Job as World job
participant Cell as World cell
participant Store as Cell storage
participant Person
Job->>Cell: await restart(id, question, options)
Cell->>Store: snapshot holding the pending promise
Cell-->>Person: question shown as an open pause
Note over Cell: evicted, crashed or moved. Memory is gone.
Person->>Cell: POST /api/worlds/:id/pauses/:pauseId/answer
Cell->>Store: load the snapshot
Store-->>Cell: restore the VM
Cell->>Job: resolve the promise with the answer
Job-->>Cell: continues with its locals intact
Can the agent weaken its own checks? No. Checks are stored on the host side, outside the heap, and world code has no way to reach them. The agent can propose a check, and it is enrolled only after it has been seen failing on a counterexample. Removing or loosening one is an operator call with a reason. The built-ins a check calls are frozen. A check may call a function the agent defined: every develop re-runs every check's counterexample, and a change after which a check can no longer fail is rejected.
How is the REST API authenticated? In the prototype it is not: the node is reachable only on the tailnet. The real
answer, and where the package lives, are open decisions in decisions.md.
plan.md lists the direction in order, each item tracked as a bead (br list, br show <id>): world
pages on their own origin, a time budget that bounds a whole operation, incidents grouped by kind, a data.list that
never truncates silently, new worlds that start with no grants, replay as the upgrade path, then pauses, forks,
scheduled world code and durable messages.
| File | Content |
|---|---|
guide.md |
How to use pi-world, for people who use it rather than develop it |
intent.md |
What the system is, its position, interfaces |
design.md |
Constraints the implementation obeys |
plan.md |
Where the project is going, in order, with the beads that track it |
research.md |
Spike results, celld facts, open checks, what is not verified |
performance.md |
Measured limits against the approved objectives, with a bead per finding |
perf/ |
The load harness that produced them |
decisions.md |
Operator decisions, what they approve, open decisions |
About Contributions: Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via gh and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.