AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Durable Objects

skill-butterbase-ai-butterbase-skills-durable-objects · by butterbase-ai

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

No reviews yet
0 installs
12 views
0.0% view→install

Install

$ agentstack add skill-butterbase-ai-butterbase-skills-durable-objects

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-butterbase-ai-butterbase-skills-durable-objects)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
1mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Durable Objects? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

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://.butterbase.dev/_do/chat-room/
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. ChatRoomchat-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 {
    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: "",
  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 | | 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://.butterbase.dev/_do//
  • `` = kebab-case DO name from deploy
  • ` = 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-room reserves CHAT_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 {
    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 {
    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 = 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/ or /_do/rate-limiter/.

Long-running AI agent

export class Agent {
  constructor(public state: DurableObjectState, public env: any) {}
  async fetch(req: Request): Promise {
    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 ERROR after a deploy → check manage_durable_objects (get) error_message.
  • WebSocket message handler only fires if you called state.acceptWebSocket(ws) — easy to forget.
  • webSocketMessage receives string | ArrayBuffer. Always guard before JSON.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.

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.