Imported from fayazara/cf-developers-day-blr (
AGENTS.md). Install upstream withnpx skills add fayazara/cf-developers-day-blr. Copyright stays with the author.
Agent Instructions
This is a TanStack Start + Cloudflare Workers app: a voice AI support agent. Read this whole file before generating code. The patterns here are the ones that work - most AI training data is out of date for both frameworks.
Stack
- TanStack Start (RC) - full-stack React on Vite, file-based routing
- Cloudflare Workers as the runtime (not Node, not Pages)
- Agents SDK (
agents) + Durable Objects for agent state @cloudflare/voicefor the STT → LLM → TTS pipeline (beta)- Workers AI for speech and reasoning
- AI Search for retrieval / grounding
- Cloudflare D1 (SQLite) as the database
- Drizzle ORM + Drizzle Kit for schema and migrations
- Zod for input validation (
drizzle-zodfor table-derived schemas) - Tailwind v4 + Kumo (
@cloudflare/kumo) for UI - Phosphor Icons (
@phosphor-icons/react) for icons - Kumo's recommended icon set
R2 was removed from this project. The AI Search instance uses built-in storage (
--type builtin), so there is no bucket to configure.
The Worker entry is custom - do not "fix" it
wrangler.jsonc sets "main": "src/server.ts", not
@tanstack/react-start/server-entry. This is deliberate and required:
- Durable Object classes must be named exports of the Worker entry module.
You cannot add an export to a module inside
node_modules. routeAgentRequestmust see requests before TanStack Start, so it can claim/agents/*including the WebSocket upgrade.
src/server.ts re-implements the default entry (which is only
createStartHandler(defaultStreamHandler)) and falls through to it for
everything the Agents SDK does not claim. If you add another Durable Object,
export it from src/server.ts too.
Skills
This template includes agent skills for common workflows. Load a skill when the task matches - it will give you the exact steps and patterns.
| Skill | When to use |
|---|---|
new-api-route |
Creating a new HTTP endpoint (REST route) |
new-db-table |
Adding a table, modifying the schema, creating a model |
add-binding |
Adding a Cloudflare binding (KV, R2, D1, Queue, secret, var) |
How bindings work - read this first
Bindings (D1, KV, R2, secrets, vars) are declared in wrangler.jsonc and
accessed on the server only via:
import { env } from "cloudflare:workers"
env.DB // D1Database
env.AI // Workers AI
env.AI_SEARCH // AiSearchInstance
Rules:
cloudflare:workersis a virtual module. Only import it from server-only code: server functions, API routeserver.handlers, middleware. Never from a client component or anything that runs in the browser.- Do not pass
envthrough function arguments. Import it where you need it. Top-levelimport { env } from "cloudflare:workers"is the blessed pattern. - After editing
wrangler.jsonc, runnpm run cf-typegento refreshworker-configuration.d.tsso the bindings are typed.
The Drizzle client in src/db/index.ts already wraps env.DB:
import { db } from "@/db"
import { tickets } from "@/db/schema"
await db.select().from(tickets).all()
Server functions vs API routes
This template ships both. They are different things. Pick correctly.
Server functions - createServerFn
Use for data the app's own UI consumes. They are typed RPCs, not URLs.
// src/routes/index.tsx
import { createServerFn } from "@tanstack/react-start"
const getItems = createServerFn().handler(async () => {
return await db.select().from(items).all()
})
export const Route = createFileRoute("/")({
loader: () => getItems(),
component: App,
})
- Called like
getItems()from a loader/component. - Inputs validated via
.inputValidator(...)/.validator(...). - No URL to think about, no manual
fetch.
API routes - file-based, server.handlers
Use for external HTTP callers: curl, mobile apps, webhooks, anything that needs a stable URL + verb.
// src/routes/api/items.ts → /api/items
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/api/items")({
server: {
handlers: {
GET: async ({ request }) => Response.json([]),
POST: async ({ request }) => {
const body = await request.json()
return Response.json(body, { status: 201 })
},
},
},
})
- File path → URL.
src/routes/api/items.$id.ts→/api/items/:id. - Handlers take
{ request, params }and return a WebResponse. - No
componentfield needed if it's a pure API route.
What NOT to do
- Don't write
createServerFileRoute(...). That's an old API. UsecreateFileRoute(...).server.handlers. - Don't write a Hono/Express app inside the Worker entry. Routing belongs in
file routes. The only logic in
src/server.tsis therouteAgentRequesthandoff described above - do not add more. - Don't import
process.envfor bindings. It won't work. - Don't pass
envas a parameter. Import it.
Database workflow
The schema lives in src/db/schema.ts. To change it:
# 1. edit src/db/schema.ts
npm run db:generate # writes SQL to ./drizzle/
npm run db:migrate # applies to LOCAL D1 (default for dev)
npm run db:migrate:prod # applies to REMOTE D1 (production)
Notes:
- Migrations live in
./drizzle/and are applied by wrangler, not by drizzle-kit. This is intentional - D1 has its own migrations system and we want a single source of truth. db:migrateiswrangler d1 migrations apply DB --local. Local D1 data lives in.wrangler/state/.- First-time setup requires creating the D1 database:
Then paste the printednpx wrangler d1 create meridian-support-dbdatabase_idintowrangler.jsonc.
The voice agent
src/agent/support-agent.ts is the whole agent. Key facts:
- It extends
withVoice(Agent)from@cloudflare/voice, which supplies STT, turn detection, TTS, interrupts and conversation persistence. - The only required method is
onTurn(transcript, context). Return a string, anAsyncIterable<string>, or aReadableStream. It must stayasync- the base signature isPromise<TextSource>. - Always pass
abortSignal: context.signalto the model call so interrupts actually cancel generation. - Providers are class fields, not constructor work: field initialisers run
after
super(), sothis.envis available. - Conversation history is
context.messages, already loaded from the DO's SQLite. Do not re-implement it.
Client side, useVoiceAgent needs a microphone, a WebSocket and an
AudioWorklet, so it must never run during SSR. Wrap it in <ClientOnly> from
@tanstack/react-router.
Speech-to-text: use Nova 3, not Flux
The transcriber is WorkersAINova3STT. Do not "restore" it to
WorkersAIFluxSTT: @cf/deepgram/flux currently returns HTTP 500
AiError 5030 for every session. This was verified against the doc-exact
minimal payload, with and without keyterms, with and without
remote: true, on two compatibility dates and two Cloudflare accounts -
while @cf/deepgram/aura-1 and @cf/deepgram/nova-3 both return 101 over
the same WebSocket path. It is a service-side fault, not configuration.
Nova 3 implements the same Transcriber interface and accepts the same
keyterms, so switching back later is a one-line change.
Testing voice without a microphone
scripts/probe-voice.mjs is a full end-to-end check that needs no browser
and no mic. It synthesizes the question with Aura via /api/debug-say, then
streams it into the agent socket as 20 ms PCM16 frames exactly like the
browser client, exercising STT, turn detection, onTurn() and TTS:
npm run voice:probe -- "what is the refund window?"
Always let it generate its own call id. Reusing an id reuses the Durable Object, so the agent answers with the previous conversation still in context - which looks exactly like a retrieval bug and is not one.
AI Search
Retrieval goes through the AI_SEARCH instance binding:
const results = await env.AI_SEARCH.search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
})
// results.chunks[].text, results.chunks[].item.key, results.chunks[].score
env.AI.autorag(...)is the old API. Do not use it.- The binding is validated at dev-server startup. If the instance does not
exist,
npm run devwill not boot at all. - Treat the binding as optional in code (
env as { AI_SEARCH?: AiSearchInstance }) so the app still runs when the block is commented out. - Documents are uploaded with
env.AI_SEARCH.items.upload(name, content), which is an upsert. Seesrc/routes/api/kb.ts.
Validation
Use drizzle-zod to derive zod schemas from the drizzle table, then
validate request bodies with .safeParse(...). See src/db/schema.ts for
the table-derived schemas (insertTicketSchema).
Local dev
npm run dev # vite dev on :3000, with cloudflare plugin + local D1
npm run build # production build
npm run preview # build + vite preview (runs on workerd locally)
npm run deploy # build + wrangler deploy
npm run dev is the one to use 99% of the time. It runs the full Worker
runtime via @cloudflare/vite-plugin, so bindings (D1, AI, etc.) work
the same way they will in production.
There is a predev hook that runs db:migrate automatically before
dev starts, so local D1 is always in sync with whatever migrations
exist in ./drizzle/. If you generate a new migration with
db:generate, the next npm run dev will apply it for you.
Paths
@/*→./src/*
Cloudflare docs
When you need documentation for any Cloudflare product (Workers, D1, R2, KV, Queues, Durable Objects, AI, etc.), use the Cloudflare docs MCP instead of relying on training data. Cloudflare APIs move fast and your training data is likely stale.
If the MCP is available, query it directly. If not, tell the user to add it to their MCP config:
{
"mcpServers": {
"cloudflare-api": {
"url": "https://mcp.cloudflare.com/mcp"
}
}
}
This gives you access to the full Cloudflare developer documentation. Always prefer it over guessing.
When in doubt
- Check
src/routes/dashboard.tsxfor a server-function + D1 example - Check
src/routes/api/kb.tsfor an API-route example - Check
src/agent/support-agent.tsfor the agent, its tools and D1 writes - Check
src/db/schema.tsfor table + zod schema patterns - Check
wrangler.jsoncfor how bindings are declared