The realtime worker
What apps/realtime is — a standalone Cloudflare Worker that hosts the AI agent and the realtime channel as Durable Objects — why it's separate, and how the web app talks to it.
apps/realtime is a small standalone Cloudflare Worker, separate from the OpenNext app Worker. It is built on the Cloudflare Agents SDK and hosts two Durable Object classes:
AppAgent— one Durable Object per user: it holds the live WebSocket for AI chat, runs the streaming chat loop with tool use, schedules its own future wake-ups, and can push proactive messages. This is the engine behind AI chat.RealtimeChannel— a generic per-entity fan-out channel: any feature can publish an event to a channel and every subscribed socket receives it. This is the engine behind Realtime and notifications.
Its config is apps/realtime/wrangler.toml; its entrypoint is apps/realtime/src/index.ts.
Why it's a separate Worker
Durable Objects, alarms, and WebSocket hibernation need the workerd runtime — they can't run inside the Next.js dev server or a plain request handler. Splitting them into their own Worker buys three things:
- Runtime. Daily dev uses
wrangler dev(miniflare) with full DO / alarm / WebSocket support, while the Next.js dev loop stays untouched. - Independence. The realtime Worker deploys, scales, and rolls back independently of the app Worker.
- A clean data boundary. The agent never touches the database directly — all persistence goes back through the web app's API routes (see below).
Since chat consolidated onto the WebSocket transport, the realtime Worker is required for chat — there is no SSE fallback. If the realtime Worker is down or unreachable at
/agents/*, chat surfaces a retry rather than silently degrading.
One hostname, two Workers
Both Workers live on the same Cloudflare zone and the same public hostname. Routing is by path:
Browser ── WebSocket ──► yourapp.example.com/agents/* ──► realtime worker (AppAgent DO)
Browser ── WebSocket ──► yourapp.example.com/realtime/* ──► realtime worker (RealtimeChannel DO)
Browser ── everything else ─► yourapp.example.com ──► app worker (OpenNext) ─► D1 + R2The app Worker owns the custom domain; the realtime Worker owns the more-specific /agents/* and /realtime/* pattern routes in the same zone (declared under [env.production] in apps/realtime/wrangler.toml). Because both are the same host, the Better Auth session cookie flows to both — the realtime Worker validates it before touching any Durable Object.
The
/agents/*path is the Agents SDK's routing convention (/agents/<binding>/<name>), not the Worker's name — it stayedagentseven though the workspace/Worker is nowrealtime.
The gate: authenticate before any Durable Object
apps/realtime/src/index.ts is a worker-level fetch handler that acts as a single authorization gate. For a normal request it:
- Validates the Better Auth session cookie (via the web app). A non-101 response cleanly refuses a WebSocket handshake before any DO is instantiated.
- For
/realtime/<channel>, callsauthorizeChannel(parsed, user)— a pure, deny-by-default policy (users may subscribe only to their ownuser:/notifications:channels; admins may watchcampaign:channels; unknown types are denied). This is the single seam downstream apps extend for shared channels. See Add a realtime channel. - For
/agents/*, rewrites the instance name to the validated user id — a client can never reach another user'sAppAgent, because the name is derived from the session, never from what the client supplied.
How the web app talks to the realtime Worker
Two directions, two mechanisms:
Client → realtime Worker (WebSocket). The browser (and the Expo app) open WebSockets to /agents/* (chat) and /realtime/<channel> (live events). Because it's the same origin, the browser attaches the session cookie automatically. The useRealtime() hook (apps/web/lib/use-realtime.ts) is the client API for channels.
Web server → realtime Worker (service binding). When server code wants to fan an event out to connected sockets, it calls publish() (apps/web/lib/realtime.ts), which POSTs to the realtime Worker's /realtime/internal/publish route:
import { publish } from "@/lib/realtime";
await publish(`user:${userId}`, { type: "demo-ping", ts: Date.now() });This goes over the REALTIME service binding declared in apps/web/wrangler.toml, authenticated with the shared AGENTS_INTERNAL_SECRET header — not a public HTTP call. (A Worker fetching its own zone's public hostname would loop into an HTTP 522; the service binding bypasses edge routing.)
Realtime Worker → web app (service binding). The reverse direction exists too: the agent persists and validates through the web app's API routes over the WEB service binding (declared under apps/realtime's [env.production]). Live turns forward the user's cookie (acting as the user, e.g. create_item → POST /api/items); cookieless work (scheduled wakes) uses the AGENTS_INTERNAL_SECRET header, and the web routes verify ownership of the asserted user id before writing.
Extending the agent
Downstream projects subclass AppAgent rather than rewriting it. The extension hooks (all in apps/realtime/src):
registerTool(tool)— add an LLM-callable tool. Built-ins live inapps/realtime/src/tools.ts(create_item,generate_image,schedule_followup). See Add a chat tool.scheduleProactive(when, message, conversationId?)— schedule a self-wake (delay-seconds, aDate, or a cron string);onScheduledWakefires when it's due.deliverProactive(message, …)/onUndeliverable(message)— publish a proactive message to the user's realtime channel, falling back to a persisted notification when no socket is live. OverrideonUndeliverableto reach users over Telegram / email / push.
Local dev and deploy
- Dev:
npm run dev:realtime(from the repo root) runswrangler dev. In the Docker dev stack, Caddy fronts both Workers solocalhostand the Tailscale HTTPS URL route/agents/*and/realtime/*to the realtime Worker. - Deploy:
npm run deploy:realtime(ornpm run deploy:cf:allto build + deploy web + deploy realtime). Secrets on the realtime Worker:ANTHROPIC_API_KEYandAGENTS_INTERNAL_SECRET(must match the app Worker's). See Deploy to Cloudflare Workers.
For the chat protocol, tools, and rich blocks, see AI chat; for the channel model and the ping demo, see Realtime.
Cloudflare Workers & OpenNext
How a Next.js app runs on Cloudflare Workers via OpenNext — the custom worker.js entrypoint, wrangler bindings, scheduled and queue handlers, and the nodejs_compat story.
Data layer (D1 + Drizzle)
How Valcraven talks to the database — Drizzle ORM over a multi-dialect (D1 / SQLite / Postgres) setup, getDb() vs getRawDb() vs getRawAdapter(), the schema, and migrations.