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
npx skills add https://github.com/butterbase-ai/butterbase-skills --skill 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.
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.
import { ... } from 'cloudflare:workers' is allowed.export class Foo { ... } — no extra exports, no helpers re-exported.ChatRoom ↔ chat-room).state.storage keys/values capped at 128 KB. Larger blobs → Butterbase Storage.access_mode: "public". Browsers can't set custom headers on WS upgrade, and the _do/ dispatcher reads auth *only* from Authorization — ?token= and Sec-WebSocket-Protocol are silently ignored at the dispatcher (unlike the /realtime route, which does accept ?token=). Set the DO public, then read the token from ?token= (or Sec-WebSocket-Protocol) inside fetch() and verify it yourself before accepting the upgrade. Server-to-server callers can still use authenticated/service_key.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) |
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).
| 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().
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).
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" })
^[A-Z_][A-Z0-9_]*$ (UPPER_SNAKE).chat-room reserves CHAT_ROOM).this.env.KEY_NAME.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).
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 {}
}
}
}
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>.
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.
Each instance is its own counter. /_do/leaderboard/main and /_do/leaderboard/season-2 have independent state — no coordination needed in v1.
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:
ERROR after a deploy → check manage_durable_objects (get) error_message.state.acceptWebSocket(ws) — easy to forget.webSocketMessage receives string | ArrayBuffer. Always guard before JSON.parse.| 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.
Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.
Replace with description of the skill and when Claude should use it.
Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
This skill should be used when the user wants to "create a skill", "add a skill to plugin", "write a new skill", "improve skill description", "organize skill content", or needs guidance on skill structure, progressive disclosure, or skill development best practices for Claude Code plugins.
Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
Use when creating new skills, editing existing skills, or verifying skills work before deployment
Take butterbase-ai/durable-objects from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.