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
- Single-Worker Deployment - Next.js and Durable Objects in one Cloudflare Worker
- Resumable Streams - Page refresh mid-stream resumes the stream
- SSR Hydration - Messages are server-rendered for instant display
- Direct DO Access - API routes access DOs via binding, not HTTP
- Canonical snapshot resume -
snapshotResumeOptions+replayPrefix: a refresh at any point rebuilds the in-flight message char-exact (no loss, no duplication) - Multi-Turn Filtering - Follow-up messages stream only new content
- Persistent Sub-Agents on Durable Objects - the
/coordinator/[sessionId]route runs aCoordinatorAgentthat declares aResearcherunderpersistentAgentsand spawns it in a separate DO viacompanion__spawnAgent/companion__waitForResult(seesrc/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.0for Anthropic). Earlier v2.x provider packages usespecificationVersion: "v2"which triggers compatibility mode in AI SDK v6, stripping reasoning features.
vs. Resumable Streams (Next.js)
| Feature | Resumable Streams | OpenNext + DO |
|---|---|---|
| Runtime | JS (Node.js) | Cloudflare DO |
| Persistence | Redis | SQLite (DO) |
| Deployment | Traditional hosting | Cloudflare edge |
| Streaming Limit | Request timeout | Unlimited |
| Cold Starts | Depends on host | Fast (edge) |
| Use Case | Standard Next.js apps | Edge-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:
// 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:
// 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.
// 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.
// Request
{
message: 'Hello!';
}
// or AI SDK v6 format
{
messages: [{ role: 'user', parts: [{ type: 'text', text: 'Hello!' }] }];
}
// Response: SSE stream with AI SDK eventsGET /api/chat/[sessionId]
Resume an existing stream.
// 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 cursorGET /api/chat/[sessionId]/snapshot
Get current state snapshot.
// 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
# 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 previewNote: pnpm run dev (Next.js dev mode) won't have access to Durable Objects.
Deploying
# Login to Cloudflare
pnpm exec wrangler login
# Set secrets
pnpm exec wrangler secret put OPENAI_API_KEY
# Deploy
pnpm run deployTesting
The example includes comprehensive Playwright E2E tests:
pnpm run test:e2eTests 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
- Resumable Streams Guide - Deep dive into resumability
- AI SDK Integration -
handleChatStream, the canonical client and DO client wrappers - Cloudflare DO Runtime - AgentServer reference