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

> Discover how Cloudflare Durable Objects power OpenSEO's in-app chat. Learn how DOs manage state, store transcripts, and handle authorization for a seamless user experience.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: deep-dive
- Published: 2026-08-19

---

**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`](https://github.com/every-app/open-seo/blob/main/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:

```typescript
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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/SamChatAgent.ts), the `fetch` hook persists the request origin so that deep-link tools remain contextual:

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

```typescript
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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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.