Voice agent fully on Cloudflare
Run the whole voice stack on Cloudflare, with Deepgram Flux speech-to-text, a Workers AI LLM and Aura text-to-speech in a Durable Object.
Voice agents on Workers and Durable Objects runs one agent per Durable Object, with the models at OpenAI. This page swaps the models for Workers AI, so the transport, the agent and the models all run on Cloudflare's network, and the only secrets are a Cloudflare account ID and API token:
| Step | Service | Workers AI model |
|---|---|---|
| Speech-to-text and turn detection | WorkersAIFluxSTTService | @cf/deepgram/flux |
| LLM | WorkersAILLMService | @cf/meta/llama-3.3-70b-instruct-fp8-fast (or any chat model) |
| Text-to-speech | WorkersAIAuraTTSService | @cf/deepgram/aura-2-en |
client ──WebSocket──► Worker ──getByName(id)──► VoiceSession (Durable Object)
│ FastAPIWebsocketTransport
│ Flux (env.AI, WebSocket): transcripts and turns
│ Workers AI LLM (OpenAI-compatible REST)
│ Aura (env.AI, WebSocket): speechInstall
bun add @pipedog/core @pipedog/asyncio @pipedog/cloudflare \
@pipedog/transport-websocket @pipedog/serializer-protobufConfigure the Worker
Add the AI binding next to the Durable Object in wrangler.jsonc:
{
"name": "voice-agent",
"main": "src/worker.ts",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI" },
"durable_objects": {
"bindings": [{ "name": "VOICE_SESSION", "class_name": "VoiceSession" }]
},
"migrations": [{ "tag": "v1", "new_classes": ["VoiceSession"] }]
}The speech services use the binding, with no token. The LLM uses Workers AI's OpenAI-compatible REST endpoint (the binding answers chat requests in Workers AI's own format), so it needs the account ID and an API token with the Workers AI permission:
bunx wrangler secret put CLOUDFLARE_ACCOUNT_ID
bunx wrangler secret put CLOUDFLARE_API_TOKENimport type { WorkersAIBinding } from "@pipedog/cloudflare/client";
export interface Env {
/** The Workers AI binding: `"ai": { "binding": "AI" }` in wrangler.jsonc. */
AI: WorkersAIBinding;
/** For the LLM, which uses Workers AI's OpenAI-compatible REST endpoint. */
CLOUDFLARE_ACCOUNT_ID: string;
CLOUDFLARE_API_TOKEN: string;
}
The agent
The pipeline is the same as in any voice bot. What changes is the services:
import { WorkersAIFluxSTTService } from "@pipedog/cloudflare/flux_stt";
import { WorkersAILLMService } from "@pipedog/cloudflare/llm";
import { WorkersAIAuraTTSService } from "@pipedog/cloudflare/tts";
import { LLMRunFrame } from "@pipedog/core/frames/frames";
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineParams, PipelineWorker } from "@pipedog/core/pipeline/worker";
import { LLMContext } from "@pipedog/core/processors/aggregators/llm_context";
import { LLMContextAggregatorPair } from "@pipedog/core/processors/aggregators/llm_response_universal";
import { WorkerRunner } from "@pipedog/core/workers/runner";
import { ProtobufFrameSerializer } from "@pipedog/serializer-protobuf/protobuf";
import {
FastAPIWebsocketParams,
FastAPIWebsocketTransport,
} from "@pipedog/transport-websocket/fastapi";
import type { WebsocketConnection } from "@pipedog/transport-websocket/socket";
/** Flux and the client both use 16 kHz audio; Aura answers at 24 kHz. */
const INPUT_SAMPLE_RATE = 16000;
const OUTPUT_SAMPLE_RATE = 24000;
export async function runVoiceAgent(connection: WebsocketConnection, env: Env): Promise<void> {
const transport = new FastAPIWebsocketTransport(
connection,
new FastAPIWebsocketParams({
audioInEnabled: true,
audioOutEnabled: true,
serializer: new ProtobufFrameSerializer(),
}),
);
// Flux detects the start and end of each turn, so there is no VAD analyzer
// and no Smart Turn model to load: the user aggregator follows Flux.
const stt = new WorkersAIFluxSTTService({ ai: env.AI });
const llm = new WorkersAILLMService({
accountId: env.CLOUDFLARE_ACCOUNT_ID,
apiToken: env.CLOUDFLARE_API_TOKEN,
settings: new WorkersAILLMService.Settings({
model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
systemInstruction:
"You are a helpful assistant in a voice conversation. Answer briefly, in plain sentences.",
}),
});
const tts = new WorkersAIAuraTTSService({
ai: env.AI,
sampleRate: OUTPUT_SAMPLE_RATE,
settings: new WorkersAIAuraTTSService.Settings({ voice: "luna" }),
});
const context = new LLMContext();
const aggregators = new LLMContextAggregatorPair(context);
const pipeline = new Pipeline([
transport.input(),
stt,
aggregators.user(),
llm,
tts,
transport.output(),
aggregators.assistant(),
]);
const worker = new PipelineWorker(pipeline, {
cancelOnIdleTimeout: false,
params: new PipelineParams({
audioInSampleRate: INPUT_SAMPLE_RATE,
audioOutSampleRate: OUTPUT_SAMPLE_RATE,
enableMetrics: true,
enableUsageMetrics: true,
}),
});
const runner = new WorkerRunner({ handleSigint: false, handleSigterm: false });
await runner.addWorkers(worker);
transport.eventHandler("on_client_connected", async () => {
context.addMessage({ role: "developer", content: "Please introduce yourself to the user." });
await worker.queueFrames([new LLMRunFrame()]);
});
transport.eventHandler("on_client_disconnected", async () => {
await runner.cancel();
});
await runner.run();
}Flux detects turns on Cloudflare's side and the user aggregator follows its turn events, so there is no VAD analyzer and no Smart Turn model: the Worker loads no ONNX runtime and stays small (see turn detection on Workers).
The Durable Object and the Worker
They are those of Voice agents on Workers, with
runVoiceAgent from above:
import { DurableObject } from "cloudflare:workers";
import { websocketFetchHandler } from "@pipedog/transport-websocket/fetch";
export class VoiceSession extends DurableObject<Env> {
readonly #handler = websocketFetchHandler((connection) => runVoiceAgent(connection, this.env));
override fetch(request: Request): Promise<Response> {
return this.#handler(request);
}
}
export default {
async fetch(request: Request, env: Env & { VOICE_SESSION: DurableObjectNamespace<VoiceSession> }) {
const id = new URL(request.url).pathname.split("/")[3];
return env.VOICE_SESSION.getByName(id).fetch(request);
},
};The example
adds reconnection, tracing and alerts on top, and runs the same way: replace its bot.ts services
with the ones above and add the AI binding.
Variations
- Nova-3 or Whisper instead of Flux.
WorkersAINova3STTService(streaming) andWorkersAIWhisperSTTService(one request per utterance) need a VAD and a turn analyzer. On Workers, Smart Turn v2 runs on Workers AI too (WorkersAISmartTurnAnalyzer, see turn detection); the newer local Smart Turn v3.2 also runs on Workers but makes the bundle much larger. - Aura over HTTP.
WorkersAIAuraHttpTTSServicemakes one request per sentence instead of holding a WebSocket. - Outside Workers. Every service takes
accountIdandapiTokeninstead ofai, and works on Bun, Node and Deno over the REST API.
Limits to plan for
- The Durable Object limits of Voice agents on Workers apply: billed duration while a session is connected, CPU per session, WebSocket audio only.
- Workers AI bills Flux and Aura per audio minute and per character; see each model page.
- The services are tested against a local fake of Workers AI (binding and REST), not against Workers AI itself.
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).