Voice·续
return ( <div> <textarea value={transcript + (interimTranscript ? " " + interimTranscript : "")} readOnly /> <button onClick={isListening ? stop : start}> {isListening ? "Stop" : "Dictate"} </button> </div> ); }
## Client API: `VoiceClient`
Framework-agnostic client for environments without React.
```js
import { VoiceClient } from "@cloudflare/voice/client";
const client = new VoiceClient({ agent: "MyAgent" });
client.addEventListener("statuschange", (status) => {
console.log("Status:", status);
});
client.addEventListener("transcriptchange", (messages) => {
console.log("Transcript:", messages);
});
client.addEventListener("error", (err) => {
console.error("Error:", err);
});
client.connect();
await client.startCall();
// Switch assistant playback without reconnecting the call.
await client.setOutputDevice(selectedSpeakerId);
// Later:
client.endCall();
client.disconnect();
import { VoiceClient } from "@cloudflare/voice/client";
const client = new VoiceClient({ agent: "MyAgent" });
client.addEventListener("statuschange", (status) => {
console.log("Status:", status);
});
client.addEventListener("transcriptchange", (messages) => {
console.log("Transcript:", messages);
});
client.addEventListener("error", (err) => {
console.error("Error:", err);
});
client.connect();
await client.startCall();
// Switch assistant playback without reconnecting the call.
await client.setOutputDevice(selectedSpeakerId);
// Later:
client.endCall();
client.disconnect();
Events
| Event | Data type | Description |
|---|---|---|
statuschange |
VoiceStatus |
Pipeline state changed |
transcriptchange |
TranscriptMessage[] |
Transcript updated |
interimtranscript |
string \| null |
Interim transcript from streaming STT |
metricschange |
VoicePipelineMetrics |
Pipeline timing metrics |
audiolevelchange |
number |
Mic audio level (0–1) |
connectionchange |
boolean |
WebSocket connected/disconnected |
mutechange |
boolean |
Mute state changed |
error |
string \| null |
Error occurred |
outputdeviceerror |
string \| null |
Non-fatal speaker routing issue |
custommessage |
unknown |
Non-voice message from server |
Advanced options
| Option | Type | Description |
|---|---|---|
transport |
VoiceTransport |
Custom transport (default: WebSocket via PartySocket) |
audioInput |
VoiceAudioInput |
Custom mic capture (default: built-in AudioWorklet) |
preferredFormat |
VoiceAudioFormat |
Hint for server audio format (advisory only) |
outputDeviceId |
string |
Preferred audiooutput device for assistant playback |
Providers
Built-in (Workers AI)
No API keys required — use your Workers AI binding:
| Class | Type | Default model | Recommended for |
|---|---|---|---|
WorkersAIFluxSTT |
Continuous STT | @cf/deepgram/flux |
withVoice |
WorkersAINova3STT |
Continuous STT | @cf/deepgram/nova-3 |
withVoiceInput |
WorkersAITTS |
TTS | @cf/deepgram/aura-1 |
Both |
import { Agent } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAINova3STT,
WorkersAITTS,
} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
// Default usage
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
}
// Custom options
export class CustomAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI, {
eotThreshold: 0.8,
keyterms: ["Cloudflare", "Workers"],
});
tts = new WorkersAITTS(this.env.AI, {
model: "@cf/deepgram/aura-1",
speaker: "asteria",
});
}
import { Agent } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAINova3STT,
WorkersAITTS,
} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
// Default usage
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
}
// Custom options
export class CustomAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI, {
eotThreshold: 0.8,
keyterms: ["Cloudflare", "Workers"],
});
tts = new WorkersAITTS(this.env.AI, {
model: "@cf/deepgram/aura-1",
speaker: "asteria",
});
}
Third-party providers
| Package | Class | Description |
|---|---|---|
@cloudflare/voice-deepgram |
DeepgramSTT |
Continuous STT |
@cloudflare/voice-elevenlabs |
ElevenLabsTTS |
High-quality TTS |
@cloudflare/voice-telnyx |
TelnyxSTT, TelnyxTTS |
STT, TTS, and telephony |
@cloudflare/voice-twilio |
TwilioAdapter |
Telephony (phone calls) |
@cloudflare/voice-plivo |
PlivoAdapter |
Telephony (phone calls) |
ElevenLabs TTS:
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new ElevenLabsTTS({
apiKey: this.env.ELEVENLABS_API_KEY,
voiceId: "21m00Tcm4TlvDq8ikWAM",
});
}
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new ElevenLabsTTS({
apiKey: this.env.ELEVENLABS_API_KEY,
voiceId: "21m00Tcm4TlvDq8ikWAM",
});
}
Deepgram STT:
import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyAgent extends VoiceAgent {
transcriber = new DeepgramSTT({
apiKey: this.env.DEEPGRAM_API_KEY,
});
tts = new WorkersAITTS(this.env.AI);
}
import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new DeepgramSTT({
apiKey: this.env.DEEPGRAM_API_KEY,
});
tts = new WorkersAITTS(this.env.AI);
}
Telnyx STT and TTS:
Import from the /stt and /tts subpaths, which are server-safe:
import { TelnyxSTT } from "@cloudflare/voice-telnyx/stt";
import { TelnyxTTS } from "@cloudflare/voice-telnyx/tts";
export class MyAgent extends VoiceAgent {
transcriber = new TelnyxSTT({
apiKey: this.env.TELNYX_API_KEY,
engine: "Telnyx", // or "Deepgram"
interimResults: true,
});
tts = new TelnyxTTS({
apiKey: this.env.TELNYX_API_KEY,
voice: "Telnyx.NaturalHD.astra",
});
}
import { TelnyxSTT } from "@cloudflare/voice-telnyx/stt";
import { TelnyxTTS } from "@cloudflare/voice-telnyx/tts";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new TelnyxSTT({
apiKey: this.env.TELNYX_API_KEY,
engine: "Telnyx", // or "Deepgram"
interimResults: true,
});
tts = new TelnyxTTS({
apiKey: this.env.TELNYX_API_KEY,
voice: "Telnyx.NaturalHD.astra",
});
}
TelnyxTTS defaults to backend: "rest". Set backend: "websocket" for lower time-to-first-audio. That backend requires the Workers runtime.
Telephony
Telephony connects phone calls to the same withVoice agent that serves your browser clients. The call shares that agent instance's conversation history, state, tools, and schedules, so one agent can answer the phone and the web.
Providers take one of two approaches, which determines where call audio arrives and what you have to deploy:
| Provider | Approach | Call audio arrives at | Best for |
|---|---|---|---|
| Twilio | Server-side adapter in your Worker | Your Worker | Inbound numbers answered server-side |
| Plivo | Server-side adapter in your Worker | Your Worker | Inbound numbers answered server-side |
| Telnyx | Browser WebRTC bridge | The browser | Softphone and click-to-call in an app you ship |
Server-side adapters (Twilio and Plivo)
Install the adapter for your provider:
npmyarnpnpmbun
npm i @cloudflare/voice-twilio
yarn add @cloudflare/voice-twilio
pnpm add @cloudflare/voice-twilio
bun add @cloudflare/voice-twilio
npmyarnpnpmbun
npm i @cloudflare/voice-plivo
yarn add @cloudflare/voice-plivo
pnpm add @cloudflare/voice-plivo
bun add @cloudflare/voice-plivo
The adapter terminates the provider's audio WebSocket in your Worker and converts between the provider's 8 kHz mulaw audio and the agent's 16 kHz PCM protocol:
Phone → provider → WebSocket → adapter → WebSocket → VoiceAgent
No browser is involved. Each adapter exposes a handleRequest() method that you call from your fetch handler for the provider's WebSocket path, and by default each call gets its own agent instance named after the provider's call identifier.
Beyond that path, the two providers differ in what they need from you. Twilio is configured with TwiML that points at your Worker. Plivo needs your auth ID, auth token, and phone number — deploying automatically provisions the Plivo application and points its answer URL at your Worker, so neither needs manual setup in the Plivo console.
Browser WebRTC bridge (Telnyx)
npmyarnpnpmbun
npm i @cloudflare/voice-telnyx
yarn add @cloudflare/voice-telnyx
pnpm add @cloudflare/voice-telnyx
bun add @cloudflare/voice-telnyx
Telnyx bridges the PSTN call through WebRTC in the browser and reuses your existing voice client transport:
Phone ↔ Telnyx ↔ WebRTC ↔ browser bridge ↔ WebSocket → VoiceAgent
Because the browser holds the WebRTC session, it needs a short-lived Telnyx credential — never your API key. TelnyxJWTEndpoint mints those tokens server-side and requires an authorize callback, so a public route cannot mint credentials for arbitrary callers. Telephony needs TELNYX_CREDENTIAL_CONNECTION_ID alongside TELNYX_API_KEY.
Telnyx also provides STT and TTS, so it can supply the whole pipeline. Refer to Third-party providers for those.
PCM output for telephony
WorkersAITTS returns MP3, which cannot be decoded to PCM in the Workers runtime. With the Twilio or Plivo adapter, use a TTS provider that outputs raw PCM — for example ElevenLabs with outputFormat: "pcm_16000", or a Workers AI model called with encoding: "linear16" and container: "none".
This constraint does not apply to Telnyx, where the browser decodes audio before playback.
Complete examples
Each adapter ships a runnable example with the Worker routes, provider configuration, and deployment steps:
Plivo voice agent
Answer inbound Plivo calls in a Worker, including the answer URL and application setup.
Telnyx voice agent
Bridge PSTN calls through the browser, including the JWT endpoint and client wiring.
Text messages
withVoice agents can also receive text messages, bypassing STT entirely. This is useful for chat-style input alongside voice.
const { sendText } = useVoiceAgent({ agent: "MyAgent" });
// Send text — goes straight to onTurn() without STT
sendText("What is the weather like today?");
Text messages work both during and outside of active calls. During a call, the response is spoken aloud via TTS. Outside a call, the response is sent as text-only transcript messages.
Custom messages
Send and receive application-level JSON messages alongside voice protocol messages. Non-voice messages pass through to your onMessage handler on the server and emit custommessage events on the client.
Server:
export class MyAgent extends VoiceAgent {
onMessage(connection, message) {
const data = JSON.parse(message);
if (data.type === "kick_speaker") {
this.forceEndCall(connection);
}
}
}
export class MyAgent extends VoiceAgent<Env> {
onMessage(connection: Connection, message: WSMessage) {
const data = JSON.parse(message as string);
if (data.type === "kick_speaker") {
this.forceEndCall(connection);
}
}
}
Client:
const { sendJSON, lastCustomMessage } = useVoiceAgent({ agent: "MyAgent" });
sendJSON({ type: "kick_speaker" });
useEffect(() => {
if (lastCustomMessage) {
console.log("Custom message:", lastCustomMessage);
}
}, [lastCustomMessage]);
Single-speaker enforcement
Use beforeCallStart to restrict who can start a call. This example enforces single-speaker — only one connection can be the active speaker at a time:
import {} from "agents";
export class MyAgent extends VoiceAgent {
#speakerId = null;
beforeCallStart(connection) {
if (this.#speakerId !== null) {
return false;
}
this.#speakerId = connection.id;
return true;
}
onCallEnd(connection) {
if (this.#speakerId === connection.id) {
this.#speakerId = null;
}
}
}
import { type Connection } from "agents";
export class MyAgent extends VoiceAgent<Env> {
#speakerId: string | null = null;
beforeCallStart(connection: Connection) {
if (this.#speakerId !== null) {
return false;
}
this.#speakerId = connection.id;
return true;
}
onCallEnd(connection: Connection) {
if (this.#speakerId === connection.id) {
this.#speakerId = null;
}
}
}
Pipeline metrics
withVoice agents emit timing metrics after each turn:
const { metrics } = useVoiceAgent({ agent: "MyAgent" });
// metrics: {
// llm_ms: 850,
// tts_ms: 200,
// first_audio_ms: 950,
// total_ms: 1200,
// }
Conversation history
withVoice automatically persists conversation messages to SQLite. In onTurn(), context.messages is a snapshot of the completed history before the current transcript. The pipeline persists the current transcript before invoking the hook. Therefore, a direct getConversationHistory() call inside onTurn() includes it.
// Get stored history, including the current transcript during onTurn()
const history = this.getConversationHistory(20);
this.saveMessage("assistant", "Welcome! How can I help?");
// Get stored history, including the current transcript during onTurn()
const history = this.getConversationHistory(20);
this.saveMessage("assistant", "Welcome! How can I help?");
History survives Durable Object restarts and client reconnections. Voice agents use keepAlive to prevent eviction during active calls.
Was this helpful?
YesNo
On this page
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/communication-channels/voice/#page","headline":"Voice","description":"Build real-time voice agents with speech-to-text, text-to-speech, and conversation persistence over WebSocket.","url":"https://developers.cloudflare.com/agents/communication-channels/voice/","inLanguage":"en","image":"https://developers.cloudflare.com/agents/communication-channels/voice/og.png?v=def5f75be26dd0bb","dateModified":"2026-10-21","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
description: Receive and route webhook events from external services to dedicated Cloudflare Agent instances. title: Webhooks image: https://developers.cloudflare.com/agents/communication-channels/webhooks/og.png?v=3407b6ff09d39a33
Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/agents/llms.txt
Use this file to discover all available pages before exploring further.
Docs