Durable Objects logo

Durable Objects

Organization
butterbase-ai
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

Overview

Publisherbutterbase-ai
Repositorybutterbase-skills
Skill namedurable-objects
Stars
535
Forks
42
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by butterbase-ai on GitHub. Read the source before you install it.

Installation

Install the Durable Objects AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/butterbase-ai/butterbase-skills/tree/main/skills/durable-objects
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/butterbase-ai/butterbase-skills.git /tmp/butterbase-skills
mkdir -p .claude/skills
cp -r /tmp/butterbase-skills/skills/durable-objects .claude/skills/durable-objects
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Durable Objects in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Durable Objects on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Durable Objects is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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

typescript
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:

APIPurpose
state.storage.get/put/delete/deleteAll/listAsync 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.KEYRead DO env vars (set via set_env)

4. Deploy

js
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

ModeAuth required
publicNone — validate tokens inside fetch() if you need any
authenticated (default)End-user JWT in Authorization: Bearer <token>
service_keyButterbase 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.

js
// 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.

js
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

js
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)

typescript
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

typescript
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

typescript
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):

CodeCause
NO_EXPORTED_CLASSSource doesn't export a class
MULTIPLE_EXPORTSMore than one export, or export of non-class
INVALID_IMPORTImported anything other than cloudflare:workers
CLASS_NAME_PARSE_ERRORTS AST couldn't extract the class name
NAME_REGEX_VIOLATIONname doesn't match ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$
QUOTA_DO_LIMITAlready 5 classes for this app
BUNDLE_SIZE_EXCEEDED / SOURCE_SIZE_EXCEEDEDOver 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'tDo
Try to share state between instances directlyPick a single "registry" instance and have others fetch into it
Use a DO for stateless HTTP workUse a function — DOs cost more and have stricter constraints
Rely on dispatcher access_mode for real authValidate JWTs inside fetch() for production
Use access_mode: "authenticated" for browser WebSocketsUse public + token-in-query-string + manual validation; browsers can't set headers
Stuff > 128 KB blobs into state.storageUse Butterbase Storage and store the object_id in DO state
Update env vars in tight loopsEach set_env redeploys the Worker — drops connections
Forget redeploy semanticsCode 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.

Frequently asked questions

What does the Durable Objects AI skill do?

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

Why use Durable Objects on TypingMind?

Because you install it once and use it with any model. Durable Objects is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Durable Objects in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/butterbase-ai/butterbase-skills/tree/main/skills/durable-objects. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Durable Objects?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Durable Objects?

As many as you like. As long as a model supports skills, you can use Durable Objects with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Durable Objects AI skill free?

Yes. It is published on GitHub by butterbase-ai under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇