Skip to content
Development
Skill

/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

From plugin
butterbase-skills
53239 skills33 commands1 MCP
Install
$ npx -y skills add butterbase-ai/butterbase-skills --skill durable-objects --agent claude-code

How 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.md
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).
Read more
Ships withbutterbase-skills

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.

Get the whole plugin