Portable Durable Objects
The object
is a file.
A portable durable object is one SQLite file holding a Cloudflare-style Durable Object’s data and its code. Copy the file and you have copied the object: its code history, its schema, its next alarm, its rows.
-
_obj_metaname, class, live check-in -
_obj_blobscode, content-addressed -
_obj_checkinscode history -
_obj_migrationsforward-only schema -
_obj_kvctx.storage.get / put -
_obj_alarmsthe next alarm -
_obj_changesa changeset per write - object code stops here
-
todosid, title, done, created_at, done_at -
todos_doneindex (done, done_at)
ctx.storage.sql.
The runtime
Open the file, run the class.
The pdo binary loads the live check-in’s code into V8 and runs it single-threaded,
one event per SQLite transaction. Every write is recorded as a session changeset in the
same transaction. The class is an ordinary Durable Object: it extends
DurableObject from cloudflare:workers.
A SQLite authorizer draws the line. Object code reads and writes its own tables and nothing
else: no _obj_ tables, no DDL, no transactions, no ATTACH, no pragmas that change
anything. Schema changes arrive only as migrations.
fetch(request) | every HTTP request to pdo run |
|---|---|
public methods | RPC, also POST /.pdo/rpc/<method> with a JSON array of args |
ctx.storage.sql.exec | cursor with toArray, one, raw, rowsWritten |
ctx.storage.get / put / delete / list | the KV half |
setAlarm / getAlarm / deleteAlarm, alarm() | fired on schedule by pdo run |
transactionSync, blockConcurrencyWhile | atomic writes, guarded startup |
Refused when object code tries them, from the end-to-end test:
✕ SELECT * FROM _obj_meta ✕ UPDATE _obj_checkins SET message = 'x' ✕ DROP TABLE todos ✕ CREATE TABLE sneaky (id TEXT PRIMARY KEY) ✕ BEGIN ✕ ATTACH DATABASE '/tmp/x.db' AS x ✕ PRAGMA journal_mode = DELETE
The MCP loop
Agents build objects through nine tools.
pdo mcp todo.obj speaks MCP over stdio. An agent changes the object on a
preview copy of the file, live data included, tries it there, then releases. The
end-to-end test builds the to-do example this way, starting from an empty starter.
- 1
describe_objectwhat is here - 2
read_codewhat runs - 3
propose_changecode + migrations, on a preview copy - 4
test_calltry it, see the rows it touched - 5
release_changeone transaction, live moves
rollback_code moves the live check-in back. It moves code only, and it refuses
when the old code no longer fits the schema (say, a column it reads was renamed).
describe_objectreadName, class, live check-in, tables with columns and row counts, files, next alarm, preview state. Start here.
read_codereadSource at the live check-in, the preview, or any check-in id (a 4+ character prefix works).
list_checkinsreadHistory, newest first, with unified diffs and the migrations each check-in added.
query_datareadRead-only SQL over the object’s own tables, live or preview. At most 500 rows.
propose_changewriteCode edits plus new migrations, applied to a preview copy of the file (live data included) and built there. Proposals stack; restart starts over from live.
test_callwritefetch, an RPC method or alarm() on the preview. Returns the result, console logs and the rows each table gained, changed or lost.
release_changedestructiveRuns the preview’s migrations on the live file and moves live to its code, squashed into one check-in, in one transaction. Refused if the preview did not build or live moved.
rollback_codedestructiveMoves live back to an earlier check-in. Code only, never schema: refused when that code no longer fits the tables.
call_objectdestructivefetch or an RPC method on the live object. Writes change live data.
Carry it
Two ways out.
Copy the file
Each event folds the WAL back into the main file after it commits, so a plain copy is the whole object. Run the copy anywhere and it serves the same check-in and the same rows.
$ cp todo.obj ~/elsewhere/ $ pdo run ~/elsewhere/todo.obj
Export to Cloudflare
pdo export writes a Wrangler project. The class source is copied byte for
byte; a generated wrapper applies the migrations once and gives the same HTTP surface as
pdo run, including POST /.pdo/rpc/<method>.
$ pdo export todo.obj out/ $ cd out && bunx wrangler deploy
The same 11 HTTP calls pass on pdo run and on Cloudflare
- RPC stats
- POST /todos returns a UUIDv7 row
- POST without a title is a 400
- PATCH marks done
- GET ?done=1 lists it
- unknown path is a 404
- RPC add
- RPC stats sees both
- constructor is not RPC
- alarm fired and removed the done items
- DELETE /todos/:id
Quick start
From a checkout.
pdo is a single Rust binary. Build it, make an object, serve it, then hand the same file to your agent.
Every command is listed in /llms.txt, along with the file format and the tool schemas, for agents.
# build $ cd pdo && cargo build --release $ export PATH="$PWD/target/release:$PATH" # a starter object that counts hits in KV $ pdo new hello.obj $ pdo run hello.obj $ curl localhost:8787/ # or import a directory with src/ and migrations/ $ pdo new todo.obj --from examples/todo $ pdo call todo.obj add '["buy milk"]' $ pdo fetch todo.obj /todos # give it to an agent (Claude Code shown) $ claude mcp add todo -- pdo mcp todo.obj # look inside $ pdo describe todo.obj $ pdo checkins todo.obj --diffs
v0
Not yet.
One object per file, one file per process, run locally. These are out of scope for v0:
- WebSockets
- env bindings and outbound fetch
- npm imports (relative imports between the object’s own files work)
- a server mode hosting many object files
- sync between copies of a file
- moving state to or from Cloudflare (export moves code and migrations, not data)
Related work: celld, and Rivet Actors.