/durable-objects
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 -y skills add butterbase-ai/butterbase-skills --skill durable-objects --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/durable-objects
Context preview
The summary Claude sees to decide when to auto-load this skill.
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
SKILL.md
durable-objects.SKILL.mdname: durable-objects
description: 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
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.storage` keys/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* 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`.
---
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).
Read more
name: durable-objects description: 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
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.storage` keys/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* 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`.
---
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).
Claude Code plugin for Butterbase — the AI-Native Backend-as-a-Service. This plugin gives Claude deep knowledge of Butterbase's 42+ MCP tools, guides you through common workflows, and auto-configures the MCP server connection.
Repo: butterbase-ai/butterbase-skills
Other skills on butterbase-skills.
- /agents
Use when designing, deploying, or debugging a Butterbase Agent (declarative LLM/tool graph), registering an MCP server for tool use, or wiring access controls and rate limits. Agents are first-class app resources defined by a `graph_spec` and invoked over
Open skill - /ai
Use when calling the app's AI gateway from agent tools — chat completions, embeddings, listing models, configuring defaults or BYOK, reading token/cost usage
Open skill - /auth-setup
Use when configuring OAuth providers (Google/GitHub/Apple/X/etc.), setting up post-login auth hooks, tuning JWT lifetimes, or generating service API keys
Open skill - /build-app
Use when building a new Butterbase app from scratch, creating a full-stack application, or when the user asks to set up a complete backend with database, auth, and deployment
Open skill - /contributing
Use when contributing to the Butterbase codebase, adding new MCP tools, creating API routes, writing migrations, or understanding the monorepo architecture
Open skill - /debug-rls
Use when users report access denied errors, see wrong data, RLS policies are not working, or when troubleshooting Row-Level Security issues in Butterbase
Open skill

