durable-objects
Skill535 starsChanged 5 months agoMITView on GitHub
Use when building stateful per-key actors — chat rooms, multiplayer rooms, rate limiters, long-running agents, leaderboards — that need persistent in-memory + storage state across requests
- Reads credentials
- Sends data out
We split this skill into the rules it gives an agent
3 rules. 0 are common, 0 are distinctive, and 3 are very specific to this project.
0
Common
Most of the 33 edited versions we read keep these.
0
Distinctive
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 33
One TypeScript file per class: No npm imports. Only import { ...
- Very specific: line 154
Read in code as this.env.KEY_NAME.
- Very specific: line 288
If a docs/butterbase/00-state.md exists in the working directory, prefer invoking via /butterbase-skills:journey-durable so the journey …
“Common” is measured: kept by at least 6 in 10 of the 33 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Butterbase Durable Objects
Durable Objects (DOs) are stateful per-key actors running on Cloudflare Workers. Each instance has its own in-memory state and a built-in transactional KV store. Use one when state must survive across requests for a single room/user/agent. For stateless work, use a serverless function instead (butterbase-skills:function-dev).
One tool: manage_durable_objects.
1. The mental model
Class: ChatRoom (deployed once)
│
├── instance "lobby" ─► in-memory state + state.storage + WebSockets
├── instance "general" ─► separate state, separate sockets
└── instance "user-123" ─► separate again
Each URL https://<app>.butterbase.dev/_do/chat-room/<instance-id>
gets routed to the instance with that id. State is isolated per id.A class is shared code; an instance is a unique key (/lobby, /general, /user-123). Different ids = different state. There is no shared cross-instance state.
2. Constraints (read these first)
- One TypeScript file per class. No npm imports. Only
import { ... } from 'cloudflare:workers'is allowed. - Exactly one exported class.
export class Foo { ... }— no extra exports, no helpers re-exported. - PascalCase class name in source; kebab-case for the URL name (e.g.
ChatRoom↔chat-room). - File size: ≤ 5 MB. Total of all DO classes per app: ≤ 10 MB compressed.
- ≤ 5 DO classes per app (v1).
- No service bindings yet. Functions reach DOs over HTTP, not via env binding.
state.storagekeys/values capped at 128 KB. Larger blobs → Butterbase Storage.- Browser WebSockets need
access_mode: "public". Browsers can't set custom headers on WS upgrade, and the_do/dispatcher reads auth only fromAuthorization—?token=andSec-WebSocket-Protocolare silently ignored at the dispatcher (unlike the/realtimeroute, which does accept?token=). Set the DO public, then read the token from?token=(orSec-WebSocket-Protocol) insidefetch()and verify it yourself before accepting the upgrade. Server-to-server callers can still useauthenticated/service_key.
3. The class skeleton
export class ChatRoom {
constructor(public state: DurableObjectState, public env: Env) {}
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]);
return new Response(null, { status: 101, webSocket: pair[0] });
}
if (req.method === "POST") {
// handle plain HTTP
}
return Response.json({ ok: true });
}
// Optional WebSocket lifecycle hooks — called by the runtime
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (typeof msg !== "string") return; // guard binary
for (const peer of this.state.getWebSockets()) {
try { peer.send(msg); } catch {}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {}
async webSocketError(ws: WebSocket, err: Error) {}
}Key APIs:
| API | Purpose |
state.storage.get/put/delete/deleteAll/list | Async transactional KV store |
state.acceptWebSocket(ws) | Hold a WS connection; runtime routes messages to webSocketMessage |
state.getWebSockets() | All active WS connections for this instance |
new WebSocketPair() | Returns [client, server] — return client to browser, accept server |
this.env.KEY | Read DO env vars (set via set_env) |
4. Deploy
manage_durable_objects({
app_id: "app_abc123",
action: "deploy",
name: "chat-room", // kebab-case URL name
code: "<single TypeScript file>",
access_mode: "authenticated" // "public" | "authenticated" (default) | "service_key"
})
// → { id, name, class_name, status: "READY", access_mode, last_deployed_at }Re-deploying with the same name updates the class; old in-memory state is evicted on next request. Storage persists across redeploys (same instance id = same state.storage).
Access modes
| Mode | Auth required |
public | None — validate tokens inside fetch() if you need any |
authenticated (default) | End-user JWT in Authorization: Bearer <token> |
service_key | Butterbase service key — backend-to-backend |
The dispatcher only checks header shape, not validity. For real auth on production DOs, validate the token inside
fetch().
5. Address an instance
https://<your-subdomain>.butterbase.dev/_do/<name>/<instance-id><name>= kebab-case DO name from deploy<instance-id>= anything you choose (/lobby,/user-123,/main)
Both HTTP and WebSocket upgrade work on the same URL.
// HTTP
fetch("https://app.butterbase.dev/_do/chat-room/lobby", {
method: "POST",
body: JSON.stringify({ user: "alice", text: "hi" })
});
// WebSocket
const ws = new WebSocket("wss://app.butterbase.dev/_do/chat-room/lobby");Different instance ids → completely separate state. There is no shared global view; if you need one, build it yourself (e.g. a /registry instance that other instances report into).
6. Env vars
Env vars are app-wide across all DO classes. Setting one redeploys the DO Worker — existing in-memory state is evicted, active WS connections drop.
manage_durable_objects({ app_id, action: "list_env" }) // keys only, never values
manage_durable_objects({ app_id, action: "set_env", key: "AI_API_KEY", value: "sk-..." })
manage_durable_objects({ app_id, action: "delete_env", key: "AI_API_KEY" })- Keys must match
^[A-Z_][A-Z0-9_]*$(UPPER_SNAKE). - A key can't collide with a DO class binding (e.g.
chat-roomreservesCHAT_ROOM). - Read in code as
this.env.KEY_NAME.
7. Lifecycle, listing, deletion
manage_durable_objects({ app_id, action: "list" })
manage_durable_objects({ app_id, action: "get", name: "chat-room" }) // includes full source + status + error_message
manage_durable_objects({ app_id, action: "delete", name: "chat-room" }) // IRREVERSIBLE: purges all instances + storage
manage_durable_objects({ app_id, action: "usage", name: "chat-room" }) // do_requests, do_cpu_ms (refreshed every 15 min)Status transitions: PENDING → BUILDING → READY or ERROR (with error_message).
8. Patterns
Chat room (broadcast)
export class ChatRoom {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]);
const history = (await this.state.storage.get("messages")) ?? [];
pair[1].send(JSON.stringify({ type: "init", messages: history }));
return new Response(null, { status: 101, webSocket: pair[0] });
}
return Response.json(await this.state.storage.get("messages") ?? []);
}
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (typeof msg !== "string") return;
const history: any[] = (await this.state.storage.get("messages")) ?? [];
const parsed = JSON.parse(msg);
history.push(parsed);
await this.state.storage.put("messages", history.slice(-200));
for (const peer of this.state.getWebSockets()) {
try { peer.send(msg); } catch {}
}
}
}Sliding-window rate limiter
export class RateLimiter {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
const now = Date.now();
const window = 60_000;
const limit = 100;
const requests: number[] = (await this.state.storage.get("requests")) ?? [];
const recent = requests.filter(t => now - t < window);
if (recent.length >= limit) return new Response("rate limit", { status: 429 });
recent.push(now);
await this.state.storage.put("requests", recent);
return Response.json({ ok: true, remaining: limit - recent.length });
}
}Address one instance per actor: /_do/rate-limiter/<user-id> or /_do/rate-limiter/<api-key-hash>.
Long-running AI agent
export class Agent {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
const { prompt } = await req.json();
const r = await fetch(this.env.AI_API_ENDPOINT, {
method: "POST",
headers: { Authorization: `Bearer ${this.env.AI_API_KEY}` },
body: JSON.stringify({ prompt })
});
const data = await r.json();
await this.state.storage.put("last_response", data);
return Response.json(data);
}
}One DO instance per conversation; storage holds the rolling history.
Counter / leaderboard
Each instance is its own counter. /_do/leaderboard/main and /_do/leaderboard/season-2 have independent state — no coordination needed in v1.
9. Errors
Build-time (rejected on deploy):
| Code | Cause |
NO_EXPORTED_CLASS | Source doesn't export a class |
MULTIPLE_EXPORTS | More than one export, or export of non-class |
INVALID_IMPORT | Imported anything other than cloudflare:workers |
CLASS_NAME_PARSE_ERROR | TS AST couldn't extract the class name |
NAME_REGEX_VIOLATION | name doesn't match ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ |
QUOTA_DO_LIMIT | Already 5 classes for this app |
BUNDLE_SIZE_EXCEEDED / SOURCE_SIZE_EXCEEDED | Over the 10 MB / 5 MB limits |
Runtime / async-deploy:
- Status
ERRORafter a deploy → checkmanage_durable_objects(get)error_message. - WebSocket message handler only fires if you called
state.acceptWebSocket(ws)— easy to forget. webSocketMessagereceivesstring | ArrayBuffer. Always guard beforeJSON.parse.
10. Anti-patterns
| Don't | Do |
| Try to share state between instances directly | Pick a single "registry" instance and have others fetch into it |
| Use a DO for stateless HTTP work | Use a function — DOs cost more and have stricter constraints |
| Rely on dispatcher access_mode for real auth | Validate JWTs inside fetch() for production |
Use access_mode: "authenticated" for browser WebSockets | Use public + token-in-query-string + manual validation; browsers can't set headers |
Stuff > 128 KB blobs into state.storage | Use Butterbase Storage and store the object_id in DO state |
| Update env vars in tight loops | Each set_env redeploys the Worker — drops connections |
| Forget redeploy semantics | Code change or env change evicts all instances; storage survives but in-memory caches don't |
If a docs/butterbase/00-state.md exists in the working directory, prefer invoking via /butterbase-skills:journey-durable so the journey orchestrator stays in sync.
Other versions34 projects use a version of this.
Word for word · 1
- butterbase-ai/butterbase-plugin★ 534100% same
Edited · 0
None found.
Wrote their own version · 33
- openai/plugins★ 7.4ksame name
- cloudflare/skills★ 3.2ksame name
- delta-comic/delta-comic★ 269same name
- swyxio/skills★ 189same name
- delorenj/skills★ 18same name
- d-padmanabhan/agent-engineering-handbook★ 17same name
- Kilo-Org/cloud★ 17same name
- kevyder/banrepco_mcp★ 9same name
- PanPanFR/oh-my-openkilo★ 9same name
- zllovesuki/cloudflare-agent-skills★ 9same name
23 more not listed
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
Used alongsideProjects with it also use nextjs-on-cloudflare, cloudflare-one and 8 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
CloudflareSkillnextjs-on-cloudflare
Build, migrate, and deploy Next.js apps on Cloudflare Workers with vinext. Use when starting a Next.js project on Cloudflare, moving an existing app to Workers, choosing between vinext and OpenNext, or setting up vinext for Workers. For setup, migration, or deployment, install vinext's upstream skills with `npx skills add cloudflare/vinext` if missing, then read and follow the applicable skill and docs.
Also in 4 projects that use this
CloudflareSkillcloudflare-one
Design, configure, troubleshoot, or review Cloudflare One Zero Trust and SASE deployments. Use cloudflare-one-migrations for migration planning from other vendors.
Also in 18 projects that use this
HK-hubSkillcomputer-use
Use Orca's computer-use CLI to inspect and operate local desktop app windows through accessibility trees, screenshots, and safe UI actions. Use for desktop app interaction: list apps/windows, get app state, read visible UI, click controls, type, press keys, scroll, drag, set values, or perform accessibility actions. Also use for browser windows, webviews, Orca app UI, or other desktop UI. Triggers include "computer use", "orca computer", "read Spotify", "read Slack", "control/click/read in a desktop app", and "get app state".
Also in 3 projects that use this
- +7 more from cloudflare
In this repository40 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- butterbase-skillsSkill
- agentsSkill
- aiSkill
- auth-setupSkill
- build-appSkill
- contributingSkill
- debug-rlsSkill
- deploy-frontendSkill
- function-devSkill
- integrationsSkill
- journey-agentsSkill
- journey-aiSkill
- journey-authSkill
- journey-deploySkill
- journey-docsSkill
- journey-durableSkill
- journey-frontendSkill
- journey-functionsSkill
- journey-ideaSkill
- journey-planSkill
- journey-preflightSkill
- journey-ragSkill
- journey-realtimeSkill
- journey-rlsSkill
- journey-schemaSkill
- journeySkill
- journey-storageSkill
- journey-submitSkill
- journey-substrateSkill
- journey-templatesSkill
- meetingsSkill
- migrationsSkill
- paymentsSkill
- rag-devSkill
- realtimeSkill
- schema-designSkill
- storageSkill
- substrateSkill
- templatesSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

