How Durable Objects Power OpenSEO's In-App Chat Features

OpenSEO uses Cloudflare Durable Objects as stateful, per-session backends for its SAM assistant and onboarding chat, persisting transcripts in SQLite-backed storage while enforcing authorization and per-turn billing directly inside each DO instance.

OpenSEO, the open-source SEO platform hosted at every-app/open-seo, runs entirely on Cloudflare Workers. It leverages Durable Objects (DOs) to maintain real-time, stateful chat capabilities without relying on external databases. Every chat session—whether the SAM assistant or the onboarding preview—runs inside its own DO instance that survives Worker restarts.

One Durable Object Per Chat Session

OpenSEO isolates chat state by spawning a dedicated Durable Object for each conversation. The front end chooses the DO instance by name, and the Worker routes WebSocket traffic to that specific object.

SAM Assistant Sessions

The SAM assistant is implemented in src/server/features/sam/SamChatAgent.ts as a class SamChatAgent that extends Think. When a user starts a chat, the client passes a unique session ID to the Agents SDK:

import { useAgent } from "agents/chat";

const sam = useAgent({
  name: sessionId,      // becomes the DO instance name
  namespace: "SAM_CHAT",
});

The DO stub is retrieved via env.SAM_CHAT.idFromName(sessionId), ensuring that every message for that session reaches the exact same runtime instance.

Onboarding Preview Chats

The onboarding flow uses a similar pattern in src/server/features/onboarding/OnboardingChatAgent.ts. Here, the DO class OnboardingChatAgent extends AIChatAgent, but the instance name is scoped to the project ID rather than a session ID. This guarantees one persistent onboarding chat per project, with history capped and a free-question quota enforced inside the object.

Routing WebSocket Upgrades to the Correct DO Instance

Incoming chat connections hit src/server.ts, where the Worker authenticates the user and resolves the DO stub before any WebSocket messages flow. The routing logic looks up the namespace binding and creates a stub from the identifier:

// src/server.ts
const samNamespace = env.SAM_CHAT as DurableObjectNamespace<SamChatAgent>;
const samStub = samNamespace.get(samNamespace.idFromName(sessionId));
return samStub.fetch(request);

This indirection enforces authorization at the edge. The Worker validates the user-session token in onBeforeConnect and only then forwards the upgrade to the DO. Because the front end never talks to the DO directly, the instance cannot be reached without passing through the Worker’s auth check.

Persisting State with the Durable Object Storage API

Both chat DOs rely on this.ctx.storage, Cloudflare’s SQLite-backed Durable Object storage API, to keep data alive across isolations. In SamChatAgent.ts, the fetch hook persists the request origin so that deep-link tools remain contextual:

async fetch(request: Request): Promise<Response> {
  await this.ctx.storage.put("sam-public-origin", getPublicOrigin(request));
  return super.fetch(request);
}

For GDPR compliance, the same agent implements a destroyForErasure method that closes every WebSocket, cancels active chats, waits for stability, and wipes storage:

async destroyForErasure(): Promise<void> {
  for (const socket of this.ctx.getWebSockets()) {
    socket.close(1000, "Account erased");
  }
  this.cancelAllChats();
  await this.waitUntilStable({ timeout: 5_000 });
  await this.ctx.storage.deleteAll();
}

Enforcing Per-Turn Billing and Quotas

Because a Durable Object lives for the entire session, OpenSEO accumulates billing state locally instead of round-tripping to a separate database. The SamChatAgent maintains two key fields:

  • private turnCostUsd = 0
  • private turnMonthlyRemaining: number | null = null

During each conversational turn, beforeTurn and onStepFinish hooks update the running cost. When the turn finishes, the agent calls trackUsageCreditSpend against the internal billing service. Keeping this logic inside the DO guarantees accurate metering even if the underlying Worker is recycled between messages.

Reusable DO Patterns Across the Codebase

OpenSEO applies the same Durable Object pattern outside of chat. The AuditScratchpad class in src/server/features/audit/AuditScratchpad.ts extends DurableObject to provide temporary scratchpad storage for site audits. The GDPR erasure utility in src/server/gdpr/storage-erasure.ts coordinates deletion across these namespaces. All implementations follow the identical structure: a class extending DurableObject, state stored in this.ctx.storage, and lookup via a namespace binding declared in src/env.d.ts.

Summary

  • Per-session isolation: SAM chats use a session ID as the DO instance name, while onboarding chats use the project ID, ensuring state is scoped exactly to one conversation or project.
  • Edge authorization: src/server.ts verifies the user token before fetching the DO stub with idFromName, preventing unauthorized WebSocket access.
  • SQLite-backed persistence: Chat history, project memory, and billing counters survive Worker restarts thanks to this.ctx.storage.
  • In-DO billing: Cost accumulation and quota enforcement happen inside SamChatAgent through per-turn hooks, removing the need for external session stores.
  • Cross-feature reuse: The same DO model powers audit scratchpads and GDPR erasure, keeping the architecture consistent across the entire codebase.

Frequently Asked Questions

How does OpenSEO route a chat message to the correct Durable Object?

The front end sends the session or project ID to the Worker over a WebSocket upgrade request. The Worker uses env.SAM_CHAT.idFromName(sessionId) to generate a deterministic Durable Object ID, then calls get() to produce a stub and forwards the request with stub.fetch(request). This logic lives in src/server.ts and guarantees that all messages for a given identifier land on the same runtime instance.

What storage mechanism does OpenSEO use inside Durable Objects?

OpenSEO uses Cloudflare’s built-in Durable Object storage API (this.ctx.storage), which is backed by SQLite. Both SamChatAgent and OnboardingChatAgent call this.ctx.storage.put() to persist chat history, origin metadata, and billing state. Because storage is coupled to the object, the data remains available even when the hosting Worker restarts.

How does OpenSEO handle GDPR data erasure for chat sessions?

The SamChatAgent class implements a destroyForErasure() method that iterates over active WebSockets and closes them, cancels any in-flight chats, waits for the object to reach a stable state, and finally invokes this.ctx.storage.deleteAll(). This process is orchestrated from src/server/gdpr/storage-erasure.ts and applies to all Durable Object namespaces tied to a user account.

Can Durable Objects in OpenSEO enforce usage limits and billing?

Yes. The SamChatAgent tracks turnCostUsd and turnMonthlyRemaining as private class fields. Hooks such as beforeTurn and onStepFinish adjust these values during each conversational turn, and the agent calls trackUsageCreditSpend at the end of the turn. Because the DO maintains this state for the full session lifetime, OpenSEO can meter usage and enforce quotas without external state machines.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →