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.

Quick start Read the format

todo.obj 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
  • _obj_meta name, class, live check-in
  • _obj_blobs code, content-addressed
  • _obj_checkins code history
  • _obj_migrations forward-only schema
  • _obj_kv ctx.storage.get / put
  • _obj_alarms the next alarm
  • _obj_changes a changeset per write
  • object code stops here
  • todos id, title, done, created_at, done_at
  • todos_done index (done, done_at)
The to-do example after its first migration. Amber is the runtime’s half; green is yours, reached through 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.

What object code gets today
fetch(request)every HTTP request to pdo run
public methodsRPC, also POST /.pdo/rpc/<method> with a JSON array of args
ctx.storage.sql.execcursor with toArray, one, raw, rowsWritten
ctx.storage.get / put / delete / listthe KV half
setAlarm / getAlarm / deleteAlarm, alarm()fired on schedule by pdo run
transactionSync, blockConcurrencyWhileatomic 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. 1 describe_object what is here
  2. 2 read_code what runs
  3. 3 propose_change code + migrations, on a preview copy
  4. 4 test_call try it, see the rows it touched
  5. 5 release_change one 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_objectread

Name, class, live check-in, tables with columns and row counts, files, next alarm, preview state. Start here.

read_coderead

Source at the live check-in, the preview, or any check-in id (a 4+ character prefix works).

list_checkinsread

History, newest first, with unified diffs and the migrations each check-in added.

query_dataread

Read-only SQL over the object’s own tables, live or preview. At most 500 rows.

propose_changewrite

Code 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_callwrite

fetch, an RPC method or alarm() on the preview. Returns the result, console logs and the rows each table gained, changed or lost.

release_changedestructive

Runs 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_codedestructive

Moves live back to an earlier check-in. Code only, never schema: refused when that code no longer fits the tables.

call_objectdestructive

fetch 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
  1. RPC stats
  2. POST /todos returns a UUIDv7 row
  3. POST without a title is a 400
  4. PATCH marks done
  5. GET ?done=1 lists it
  6. unknown path is a 404
  7. RPC add
  8. RPC stats sees both
  9. constructor is not RPC
  10. alarm fired and removed the done items
  11. 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.