Skip to content

OpenNext + Cloudflare DO ​

This example demonstrates a production-ready Next.js chat application deployed to Cloudflare Workers via OpenNext, with Helix Agents running in Durable Objects for resumable streaming.

Runnable Example: The complete source code is available in the monorepo at examples/opennext-cloudflare-do/.

Deployment Mode: Direct (same-worker DO binding). The Next.js app and Durable Object are deployed together, with direct access via env.AGENTS.

What This Demonstrates ​

  1. Single-Worker Deployment - Next.js and Durable Objects in one Cloudflare Worker
  2. Resumable Streams - Page refresh mid-stream resumes the stream
  3. SSR Hydration - Messages are server-rendered for instant display
  4. Direct DO Access - API routes access DOs via binding, not HTTP
  5. Canonical snapshot resume - snapshotResumeOptions + replayPrefix: a refresh at any point rebuilds the in-flight message char-exact (no loss, no duplication)
  6. Multi-Turn Filtering - Follow-up messages stream only new content
  7. Persistent Sub-Agents on Durable Objects - the /coordinator/[sessionId] route runs a CoordinatorAgent that declares a Researcher under persistentAgents and spawns it in a separate DO via companion__spawnAgent / companion__waitForResult (see src/agent/researcher-agent.ts). This is the production, frontend-backed counterpart to the offline Persistent Sub-Agents example.

Fixed: dropped stream frames on re-attach and replay (RM-51)

Before @helix-agents/runtime-cloudflare 5.20.0, DOStreamManagerClient.createResumableReader yielded only the first SSE frame of each network read, so a resumed, re-attached or replayed assistant message (first observed on this example) could arrive truncated when the DO's writes were coalesced. This is fixed in 5.20.0; upgrade if you see truncated attached or replayed messages. See RM-51 in the conformance findings.

Architecture ​

┌─────────────────────────────────────────────────────────────────────┐
│                    Single Cloudflare Worker                          │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │ OpenNext (Next.js)                                            │   │
│  │  - UI routes: /, /chat/[sessionId], /reasoning/[sessionId]     │   │
│  │  - API routes: /api/chat/[sessionId], /api/reasoning/[sessionId] │
│  └──────────────────────────────────────────────────────────────┘   │
│                              │                                       │
│                              │ env.AGENTS (direct DO binding)        │
│                              ▼                                       │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │ ChatAgentServer (Durable Object)                              │   │
│  │  - Executes agents with unlimited streaming                   │   │
│  │  - SQLite storage for state & streams                         │   │
│  │  - SSE endpoints for real-time updates                        │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Reasoning Chat Demo ​

The example includes a /reasoning/[sessionId] route that demonstrates reasoning/thinking display with OpenAI's gpt-6-luna model. The reasoning agent uses reasoningEffort: 'high' and reasoningSummary: 'detailed' to produce visible thinking content that streams inline alongside the assistant's response.

Important: Reasoning features require @ai-sdk/openai@^3.0.0 (or @ai-sdk/anthropic@^3.0.0 for Anthropic). Earlier v2.x provider packages use specificationVersion: "v2" which triggers compatibility mode in AI SDK v6, stripping reasoning features.

vs. Resumable Streams (Next.js) ​

FeatureResumable StreamsOpenNext + DO
RuntimeJS (Node.js)Cloudflare DO
PersistenceRedisSQLite (DO)
DeploymentTraditional hostingCloudflare edge
Streaming LimitRequest timeoutUnlimited
Cold StartsDepends on hostFast (edge)
Use CaseStandard Next.js appsEdge-first apps

Choose Resumable Streams (Next.js) if you're deploying to Vercel, Railway, or traditional hosting with Redis.

Choose OpenNext + Cloudflare DO if you want edge deployment with unlimited streaming duration.

Key Implementation Details ​

Direct DO Access ​

API routes access the Durable Object directly without HTTP:

typescript
// src/lib/agent-client.ts
import { getCloudflareContext } from '@opennextjs/cloudflare';

export function getDOStub(sessionId: string) {
  const { env } = getCloudflareContext();
  const doId = env.AGENTS.idFromName(`session:${sessionId}`);
  return env.AGENTS.get(doId);
}

// In API route
const stub = getDOStub(sessionId);
const response = await stub.fetch('https://do/start', { ... });

DO Export Injection ​

OpenNext generates a worker from Next.js. A post-build script injects the DO export:

javascript
// scripts/inject-durable-objects.mjs
// Appends to .open-next/worker.js:
export { ChatAgentServer } from '../src/agent/index.ts';

"Last Message Wins" and Server Hooks ​

The agent server sets concurrentEntry: 'interrupt_previous': a new message interrupts the run the DO is executing (reason superseded_by_new_entry), waits for it to settle (concurrentEntryTimeoutMs, default 30 s), then starts. The default, 'reject', answers 409 state_already_running instead. The stop runs only after the new entry passed its type gate, agent materialization and beforeEntry, so a refused entry never interrupts the live run. The policy applies to native /start / /resume / /retry entries: a send through the chat route is refused with the 409 while a run executes (CP-13), until chat sends carry a request id.

typescript
// src/agent/agent-server.ts
concurrentEntry: 'interrupt_previous',

hooks: {
  // Admission only (auth, rate limits, quotas): `{ abort: true, response }` refuses, writing nothing.
  beforeEntry: async (ctx) => {
    console.log('[ChatAgentServer] beforeEntry', { drive: ctx.drive });
  },
  // Once per drive, after it owns its run.
  afterEntry: async ({ sessionId, runId, drive }) => {
    console.log('[ChatAgentServer] afterEntry', { sessionId, runId, drive });
  },
  // At least once per (runId, status), on requests and wakes: deduplicate on `deliveryId`.
  onRunSettled: async ({ runId, status, deliveryId }) => {
    console.log('[ChatAgentServer] onRunSettled', { deliveryId, runId, status });
  },
},

beforeEntry sees the validated start body (after any transformRequest); the raw request stays at ctx.origin.request. /submit-tool-result (client-tool results and approvals) does not run it: authorise those in the route in front of the DO.

API Routes ​

POST /api/chat/[sessionId] ​

Send a message and stream the response.

typescript
// Request
{
  message: 'Hello!';
}
// or AI SDK v6 format
{
  messages: [{ role: 'user', parts: [{ type: 'text', text: 'Hello!' }] }];
}

// Response: SSE stream with AI SDK events

GET /api/chat/[sessionId] ​

Resume an existing stream.

typescript
// Headers (prepareHelixReconnectRequest, from snapshotResumeOptions(snapshot))
X-Resume-From-Sequence: 42     // the snapshot's streamSequence
X-Existing-Message-Id: msg-3   // the snapshot's resumeMessageId
X-Helix-Replay-Prefix: 1       // the client seeded WITHOUT that message

// Response: SSE stream: the message's committed prefix (between data-helix-replay
// markers), then every chunk after the snapshot's cursor

GET /api/chat/[sessionId]/snapshot ​

Get current state snapshot.

typescript
// Response: a FrontendSnapshot read from ONE checkpoint
{
  sessionId: string,
  messages: UIMessage[],          // committed rows up to the checkpoint (never partial)
  state: { ... },                 // custom state at the cursor
  streamSequence: number,         // the checkpoint's cursor
  status: 'active' | 'paused' | 'ended' | 'failed', // decided by the current run
  resumeMessageId?: string,       // when active / paused
  latestTruncateCheckpointId?: string
}
// 404 { error, code: 'state_session_not_found' } for a missing session;
// 503 (retryable) / 409 { error, code, cause? } for a failed read (snapshotErrorResponse)

Error Handling ​

The routes answer typed bodies: snapshotNotFoundResponse() / snapshotErrorResponse(error) for the snapshot, submitToolResultErrorResponse(error) for a tool-result submit (409 state_run_superseded for a superseded run), and handleChatStream's own 409s (state_already_running for a refused send, stream_failed for a failed stream). The client reads them with parseHelixChatError(chat.error), and its Retry button calls useHelixChat().retry(). See Error Handling.

Running Locally ​

bash
# Install dependencies
pnpm install

# Set up environment (symlinks to root .env)
echo "OPENAI_API_KEY=sk-..." >> ../../.env

# Run with Miniflare (full DO access)
pnpm run preview

Note: pnpm run dev (Next.js dev mode) won't have access to Durable Objects.

Deploying ​

bash
# Login to Cloudflare
pnpm exec wrangler login

# Set secrets
pnpm exec wrangler secret put OPENAI_API_KEY

# Deploy
pnpm run deploy

Testing ​

The example includes comprehensive Playwright E2E tests:

bash
pnpm run test:e2e

Tests cover:

  • Basic chat functionality
  • Mid-stream page refresh
  • Follow-up message streaming
  • Tool call rendering
  • Mid-text reload scoring (mid-text-reload-scoring.spec.ts: no loss, no duplication, cursor and id match)

Next Steps ​

Released under the MIT License.