Shipping a Voice Agent on Cloudflare Workers AI: Three Silent Failures Between the Mic and the Model
The orb said "listening." The worker logs said Ok. The STT model was reachable, the LLM answered test prompts, the TTS synthesized audio on demand. Every layer reported success, and the user heard nothing.
We spent a day shipping a production voice agent on Cloudflare Workers AI and most of that day was spent on three bugs that produced the same symptom: silence. No errors, no stack traces, no failed requests. Three different layers — the browser's Content-Security-Policy, a voice library inside a Durable Object, and the Workers AI gateway itself — each failed without saying so.
This is the debugging trail, with the evidence, because the next team to build voice on Workers AI will hit at least one of these.
The architecture: a voice agent on Cloudflare Workers AI with zero external API keys
The demo is live at stackbilder.com/voice. A visitor taps an orb, talks, and a sales-engineer persona answers questions about img-forge out loud. The entire stack runs on Cloudflare:
- Browser: an AudioWorklet captures mic audio at 48 kHz, downsamples to 16 kHz linear16, and streams it over a same-origin WebSocket.
- Worker:
stackbilt-voice, path-routed atstackbilder.com/agents/*so the more-specific route wins over the main site worker. Same origin means no CORS, no cross-origin CSP entries. - Agent: a
DemoVoiceAgentDurable Object built on the Agents SDK with the@cloudflare/voicemixin. One DO per visitor, rooms named by per-visit UUID. - Models, all via the Workers AI binding: Deepgram Flux (
@cf/deepgram/flux) for conversational speech recognition, Llama 3.3 70B for answers, Deepgram Aura for TTS. - Abuse control: a
VoiceQuotaDO enforcing per-IP and global daily session caps — the same bound-the-blast-radius thinking we use for cost-aware routing in serverless AI workloads.
There are zero external API keys in this system. No Deepgram account, no OpenAI key, no secrets to rotate for the model layer. The AI binding is the only credential surface, and it is not a credential — it is a capability of the worker itself.
That is the pitch for edge voice, and it holds. What does not hold is the assumption that failures will announce themselves.
Silent failure #1: CSP blocks the AudioWorklet and the library calls it a mic error
First user test: "microphone access denied." The user had granted mic permission. Windows privacy settings were correct. The Permissions-Policy header explicitly allowed microphone=(self).
The browser console had the real story:
Loading the script 'blob:https://stackbilder.com/01bb36d2-...' violates the
following Content Security Policy directive: "script-src 'self' 'unsafe-inline'
https://www.googletagmanager.com https://static.cloudflareinsights.com".
Note that 'script-src-elem' was not explicitly set, so 'script-src' is used
as a fallback.
[VoiceClient] Mic error: AbortError: Unable to load a worklet's module.
AudioWorklet modules load under script-src-elem, which falls back to script-src — and voice libraries ship their worklet as a blob: URL. A CSP written before your site had voice will block it, and @cloudflare/voice surfaces the failure as a microphone error, which sends you debugging the wrong layer entirely.
The fix is one token:
script-src 'self' 'unsafe-inline' blob: https://www.googletagmanager.com ...
If your policy already carries 'unsafe-inline' for script-src, adding blob: does not meaningfully widen the injection surface. If you run a strict nonce-based CSP, you will need to weigh this more carefully — the worklet gives you no nonce hook.
Silent failure #2: the STT session that never speaks inside an agents-SDK Durable Object
With the worklet loading, the orb reached "listening" and stayed there forever. No interim transcript, no turn detection, no reply. And this time, nothing in the logs at all — the library logs loudly on every failure path we could read in its source, and it logged nothing.
We instrumented the transcriber and got this:
[diag] beforeCallStart conn=2045e1ba... others=0 allowed=true
[diag] transcriber session created
[diag] audio fed to flux: 65360 bytes
[diag] audio fed to flux: 196080 bytes
[diag] audio fed to flux: 326800 bytes
Ten seconds of audio in. Zero events out. No connection error either.
So we ran the identical ai.run("@cf/deepgram/flux", input, { websocket: true }) call in three execution contexts:
- Worker fetch handler: works.
Connectedframe,TurnInfoevents streaming. - Plain Durable Object (our quota DO): works. Same frames.
- Agents-SDK Durable Object (where
@cloudflare/voice@0.1.3creates its session): connects, accepts audio, and never delivers a single message event.
The library's WorkersAIFluxSTT session is dead on arrival inside an agents-SDK DO, and it dies without an error because the library silently discards Deepgram Error frames — its message handler switches on an event field that error frames do not have.
The fix was to stop using the library's session and speak the Flux listen-v2 protocol directly. The Agents SDK voice mixin exposes a createTranscriber() hook, so the replacement is contained:
override createTranscriber() {
const ai = this.env.AI;
return {
createSession: (options) => {
let ws: WebSocket | null = null;
const pending: ArrayBuffer[] = [];
void (async () => {
const resp = await ai.run("@cf/deepgram/flux",
{ encoding: "linear16", sample_rate: "16000", eot_threshold: "0.75",
keyterm: "img-forge" },
{ websocket: true });
const sock = resp.webSocket; sock.accept(); ws = sock;
sock.addEventListener("message", (event) => {
const data = JSON.parse(String(event.data));
if (data.type === "Error") { console.error("[flux]", event.data); return; }
if (data.type !== "TurnInfo") return;
if (data.event === "Update" && data.transcript) options?.onInterim?.(data.transcript);
if (data.event === "EndOfTurn" && data.transcript) options?.onUtterance?.(data.transcript);
});
for (const chunk of pending) sock.send(chunk);
})();
return {
feed: (chunk) => { ws ? ws.send(chunk) : pending.push(chunk); },
close: () => { ws?.close(); ws = null; },
};
},
};
}
Eighty lines replaced the broken path, and the first end-to-end run answered a spoken question in 2.7 seconds:
[2.5s] <- {"type":"transcript_interim","text":"Hello there."}
[5.5s] <- {"type":"transcript","role":"user","text":"Hello there. What is img-forge, and how does the pricing work?"}
[5.5s] <- {"type":"status","status":"thinking"}
[7.8s] <- {"type":"status","status":"speaking"}
[8.4s] <- {"type":"metrics","llm_ms":2267,"tts_ms":528,"first_audio_ms":2795}
One more silent trap inside this layer: Flux's keyterm input on Workers AI accepts a single string only. Pass an array and you get no error and no events — the session goes dark exactly like the library bug. We verified this against production twice before believing it.
Silent failure #3: the gateway KeepAlive that poisons Deepgram Flux sessions
While probing Flux directly, we watched a healthy session die on schedule:
{"type":"TurnInfo","event":"Update","transcript":"","end_of_turn_confidence":0.0037,...}
{"type":"Error","code":"UNPARSABLE_CLIENT_MESSAGE","description":"Could not
deserialize last text message: unknown variant `KeepAlive`, expected
`CloseStream` at line 1 column 19","sequence_id":5}
{"type":"Connected","request_id":"e486051b-...","sequence_id":0}
We never sent KeepAlive. The Workers AI gateway injects {"type":"KeepAlive"} text frames into idle model websockets — that message is a legitimate client message in Deepgram's listen-v1 protocol, which Nova-3 speaks. Flux is listen-v2 and accepts only CloseStream. The gateway's own keepalive is a protocol error to the model it is keeping alive, and every injection resets the session: new request_id, accumulated turn state gone.
The saving grace: the keepalive only fires on idle sockets. A live voice call streams audio continuously, so production calls never idle long enough to trigger it. Burst-style testing — send a clip, wait for results — triggers it constantly. If your integration test sends audio and then listens quietly, the gateway will poison your session while you wait, and you will conclude the model is broken when it is not.
Both this and the agents-SDK session failure are filed for upstream report to Cloudflare. Until the gateway speaks listen-v2 keepalives, keep audio flowing or expect resets.
Instrument the seams, not the layers
Every component in this stack worked when tested alone. The browser captured audio. The worker routed. Flux transcribed. Llama answered. Aura synthesized. All three real failures lived in the seams: browser-to-CSP, library-to-runtime-context, gateway-to-model-protocol. We have written before about alerts that lie by omission and about naming agent failure modes so they can be caught — voice adds a twist, because the end-to-end test requires a mouth.
Two instruments earned their keep and are staying in the toolbox:
A synthetic caller. We generated real speech with Aura TTS, then wrote a 40-line Node client that speaks the voice wire protocol — start_call, 16 kHz binary frames paced in real time, trailing silence. It converts "does voice work" from a human-in-the-loop question into a CI-able assertion. It is how we proved the server blameless in minutes instead of another round of "try it again?"
Server-side audio energy logging. The final "bug" produced the same silence as the other three: orb listening, no transcript. One log line settled it:
[flux] audio energy: avg|sample|=0 over 50 chunks
The browser was streaming perfect digital silence — a Windows default-input-device problem on the test machine, not a system problem at all. Without that number we would have burned hours re-auditing a working stack. When your pipeline consumes opaque media, log a cheap scalar that proves signal exists at each hop. The same discipline that makes an autonomous agent's actions auditable makes a media pipeline debuggable.
Voice on the edge is real: sub-3-second round trips, no external accounts, quota-bounded spend, one worker deploy. Just do not expect the failures to introduce themselves.
Try the demo at stackbilder.com/voice. The voice primitives we extracted along the way — DO base class, Flux session handling, the orb UI — are published as @stackbilt/cf-voice on npm. More on how the agent decides what to say: AEGIS, and the platform it sells for: img-forge.