The same three named Durable Object lessons, implemented twice: once for Cloudflare Durable Objects and once for celld (self-hosted Workers + Durable Objects).
| Package | Runtime | How the Worker calls a named instance |
|---|---|---|
examples/cloudflare |
Cloudflare | getByName() + RPC |
examples/celld |
celld | idFromName() → get() → stub.fetch() |
Read the four source files in either package top to bottom (index.ts, counter.ts, tickets.ts, agent.ts). There is no shared library. HTTP and storage stay paired; only the call style differs.
A Worker is the public HTTP boundary: parse the path, validate the name, dispatch.
A Durable Object class defines behavior. Each lowercase name selects one isolated instance of that class with its own SQLite storage. alice and bob never share a counter.
Storage is how an instance remembers anything after the request ends:
| Class | Stored key | Unseen read |
|---|---|---|
Counter |
value |
0 |
Tickets |
sold |
0 of a fixed capacity 5 |
Agent |
messages, optional dataset |
empty history, no file |
The runtime serializes requests for one name. Concurrent increments and ticket reserves cannot interleave mid-update. That is the lesson, not a lock you write yourself.
Cloudflare classes extend DurableObject and are called over RPC:
const stub = env.COUNTERS.getByName(name);
if (request.method === "GET") return Response.json({ value: await stub.get() });
if (request.method === "POST") return Response.json({ value: await stub.increment() });celld classes are plain (state, env) objects. The Worker forwards the original request:
const id = namespace.idFromName(name);
const stub = namespace.get(id);
return stub.fetch(request);Names match [a-z0-9][a-z0-9-]{0,63}. Unknown path or method returns 404 { error: "NOT_FOUND" }. There is no health route and no Agent read route.
| Route | Behavior |
|---|---|
GET /counters/:name |
200 { value }. Unseen is 0. |
POST /counters/:name |
Increment. 200 { value }. |
GET /tickets/:name |
200 { capacity: 5, sold, remaining }. Unseen sold is 0. |
POST /tickets/:name |
Reserve one seat. Same success body, or 409 { error: "SOLD_OUT" }. |
POST /agents/:name with { "prompt", "csv"?, "filename"? } |
Optional CSV is stored in this instance’s SQLite (dataset) before the model runs. Prompt-only still works. Tools may search, read, or run JS on that file. 200 { reply }. Missing prompt is 400 { error: "PROMPT_REQUIRED" }. |
Counter and Tickets POSTs have no body. Agent uses the Vercel AI SDK ToolLoopAgent with OpenAI. Secret is Worker env.OPENAI_API_KEY; optional env.OPENAI_MODEL defaults to gpt-5.6-sol. There is no filesystem: one optional CSV lives in SQLite next to messages.
Requires Node.js and pnpm:
pnpm install --frozen-lockfile
pnpm checkpnpm check formats/lints the workspace and typechecks/builds both packages. There is no automated test suite.
# examples/cloudflare/.dev.vars (gitignored)
OPENAI_API_KEY=...
OPENAI_MODEL=gpt-5.6-sol
pnpm --filter cloudflare-do-lab devThen GET/POST http://127.0.0.1:8787/counters/alice (same shape for /tickets/:name and POST /agents/:name).
celld has no wrangler dev. You deploy the bundle to a fleet bucket and run a celld node against that bucket. See examples/celld/DEPLOYMENT.md and the celld docs.
Default listen is 127.0.0.1:8080. Worker secrets are node env overrides (CELLD_VAR_OPENAI_API_KEY), not wrangler secret.
sales.csv is dummy merch orders. Send it as csv so the instance stores it; later prompts on the same name reuse that file.
curl -s http://127.0.0.1:8787/agents/sales \
-H 'content-type: application/json' \
-d "{\"prompt\":\"Which SKU sold the most units?\",\"csv\":$(jq -Rs . sales.csv),\"filename\":\"sales.csv\"}"
curl -s http://127.0.0.1:8787/agents/sales \
-H 'content-type: application/json' \
-d '{"prompt":"What can you do for me?"}'Use http://127.0.0.1:8080 the same way against a celld node. Tools: search_files, read_file, run_code (standard JS in a Worker Loader isolate).
- Cloudflare:
examples/cloudflare/DEPLOYMENT.md - celld:
examples/celld/DEPLOYMENT.md
This is a teaching reset, not an upgrade path. Use a fresh Worker/class set or an empty celld bucket. Existing stored Counter, Tickets, or Agent data from an earlier schema is unsupported.