---
description: Build real-time voice agents with speech-to-text, text-to-speech, and conversation persistence over WebSocket.
title: Voice
image: https://developers.cloudflare.com/agents/agent-class/communication-channels/voice/og.png?v=def5f75be26dd0bb
---

[Skip to content](#main-content)

> 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.

# Voice

Last updated Oct 29, 2026|Copy as Markdown| [View as Markdown](https://developers.cloudflare.com/agents/agent-class/communication-channels/voice/index.md)| [Agent setup](https://developers.cloudflare.com/agent-setup/)

Build real-time voice agents with speech-to-text, text-to-speech, and conversation persistence. Audio streams over WebSocket — no SFU or meeting infrastructure required. Beta

## Overview

`agents/voice` provides two server-side mixins and matching client libraries:

| Export | Import | Purpose |
| --- | --- | --- |
| `withVoice` | `agents/voice` | Full voice agent: STT, LLM, TTS, persistence |
| `withVoiceInput` | `agents/voice` | STT-only: transcription without response |
| `useVoiceAgent` | `agents/voice/react` | React hook for `withVoice` agents |
| `useVoiceInput` | `agents/voice/react` | React hook for `withVoiceInput` agents |
| `VoiceClient` | `agents/voice/client` | Framework-agnostic client |

Migrating from \`@cloudflare/voice\`

Voice now ships in the `agents` package (version 0.23.0 and later). The `@cloudflare/voice` package is deprecated. It still works and forwards each entry point to its replacement, so you can migrate by changing imports:

| Previous import | Replacement |
| --- | --- |
| `@cloudflare/voice` | `agents/voice` |
| `@cloudflare/voice/client` | `agents/voice/client` |
| `@cloudflare/voice/react` | `agents/voice/react` |
| `@cloudflare/voice/errors` | `agents/voice/errors` |

Exported names, the voice wire protocol, and SQLite table names are unchanged.

Built on Cloudflare Durable Objects, you get:

- **Real-time audio** — mic audio streams as binary WebSocket frames, TTS audio streams back
- **Automatic conversation persistence** — messages stored in SQLite, survive restarts
- **Streaming TTS** — LLM tokens are sentence-chunked and synthesized concurrently
- **Interruption handling** — user speech during playback cancels the current response
- **Continuous STT** — per-call transcriber session, model handles turn detection
- **Pipeline hooks** — intercept and transform text at every stage

## Quick start

### Install

```sh
npm install agents
```

### Server

```js
import { Agent } from "agents";
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "agents/voice";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	async onTurn(transcript, context) {
		return "Hello! I heard you say: " + transcript;
	}
}
```

```ts
import { Agent } from "agents";
import {
	withVoice,
	WorkersAIFluxSTT,
	WorkersAITTS,
	type VoiceTurnContext,
} from "agents/voice";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent<Env> {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	async onTurn(transcript: string, context: VoiceTurnContext) {
		return "Hello! I heard you say: " + transcript;
	}
}
```

### Client (React)

```tsx
import { useVoiceAgent } from "agents/voice/react";

function VoiceUI() {
	const {
		status,
		transcript,
		interimTranscript,
		audioLevel,
		isMuted,
		startCall,
		endCall,
		toggleMute,
	} = useVoiceAgent({ agent: "MyAgent" });

	return (
		<div>
			<p>Status: {status}</p>

			<button onClick={status === "idle" ? startCall : endCall}>
				{status === "idle" ? "Start Call" : "End Call"}
			</button>

			<button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>

			{interimTranscript && (
				<p>
					<em>{interimTranscript}</em>
				</p>
			)}

			{transcript.map((msg, i) => (
				<p key={i}>
					<strong>{msg.role}:</strong> {msg.text}
				</p>
			))}
		</div>
	);
}
```

### Wrangler configuration

```jsonc
{
	"ai": {
		"binding": "AI"
	},
	"durable_objects": {
		"bindings": [
			{
				"name": "MyAgent",
				"class_name": "MyAgent"
			}
		]
	},
	"migrations": [
		{
			"tag": "v1",
			"new_sqlite_classes": ["MyAgent"]
		}
	]
}
```

```toml
[ai]
binding = "AI"

[[durable_objects.bindings]]
name = "MyAgent"
class_name = "MyAgent"

[[migrations]]
tag = "v1"
new_sqlite_classes = [ "MyAgent" ]
```

## How it works

```txt
Browser                              Durable Object (withVoice)
┌──────────┐                         ┌──────────────────────────┐
│ Mic      │   binary PCM (16kHz)    │ Transcriber session      │
│          │ ──────────────────────► │ (per-call, continuous)   │
│          │                         │   ↓ model detects turn   │
│          │   JSON: transcript      │ onTurn() → your LLM code │
│          │ ◄────────────────────── │   ↓ (sentence chunking)  │
│          │   binary: audio         │ TTS                      │
│ Speaker  │ ◄────────────────────── │                          │
└──────────┘                         └──────────────────────────┘
```

1. The client captures mic audio and sends it as binary WebSocket frames (16kHz mono 16-bit PCM).
2. Audio streams continuously to the transcriber session (created at `start_call`, lives for the entire call).
3. The STT model detects when the user finishes an utterance and fires `onUtterance`. All providers use **model-driven turn detection** — the client does not need to signal end-of-speech for STT.
4. Your `onTurn()` method runs — typically an LLM call.
5. The response is sentence-chunked and synthesized via TTS.
6. Audio streams back to the client for playback.

The client receives `transcript_interim` messages with partial results as the user speaks, so you can show real-time feedback in the UI.

## Server API: `withVoice`

`withVoice(Agent)` adds the full voice pipeline to an Agent class.

### Providers

Set providers as class properties. Class field initializers run after `super()`, so `this.env` is available.

| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `transcriber` | `Transcriber` | Yes | Continuous per-call STT provider |
| `tts` | `TTSProvider` | Yes | Text-to-speech |

```js
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "agents/voice";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);
}
```

```ts
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "agents/voice";

const VoiceAgent = withVoice(Agent);

export class MyAgent extends VoiceAgent<Env> {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);
}
```

For runtime model switching (for example, a Flux vs Nova 3 dropdown), override `createTranscriber`:

```js
export class MyAgent extends VoiceAgent {
	tts = new WorkersAITTS(this.env.AI);

	createTranscriber(connection) {
		return new WorkersAIFluxSTT(this.env.AI);
	}
}
```

```ts
export class MyAgent extends VoiceAgent<Env> {
	tts = new WorkersAITTS(this.env.AI);

	createTranscriber(connection: Connection): Transcriber {
		return new WorkersAIFluxSTT(this.env.AI);
	}
}
```

### `onTurn(transcript, context)`

**Required.** Called when the user finishes speaking and the transcript is ready. `context.messages` contains completed conversation history before this transcript. Append `transcript` exactly once when constructing an LLM message list.

Return a `string`, `AsyncIterable<string>`, or `ReadableStream` for streaming responses.

**Simple response:**

```js
export class MyAgent extends VoiceAgent {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	async onTurn(transcript, context) {
		return "You said: " + transcript;
	}
}
```

```ts
export class MyAgent extends VoiceAgent<Env> {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	async onTurn(transcript: string, context: VoiceTurnContext) {
		return "You said: " + transcript;
	}
}
```

**Streaming response (recommended for LLM):**

```js
import { streamText } from "ai";
import { createAI } from "agents/models/ai-sdk";

export class MyAgent extends VoiceAgent {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	async onTurn(transcript, context) {
		const ai = createAI({ binding: this.env.AI });

		const result = streamText({
			model: ai("@cf/moonshotai/kimi-k2.6"),
			system: "You are a helpful voice assistant. Keep responses concise.",
			messages: [
				...context.messages.map((m) => ({
					role: m.role,
					content: m.content,
				})),
				{ role: "user", content: transcript },
			],
			abortSignal: context.signal,
		});

		return result.textStream;
	}
}
```

```ts
import { streamText } from "ai";
import { createAI } from "agents/models/ai-sdk";

export class MyAgent extends VoiceAgent<Env> {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	async onTurn(transcript: string, context: VoiceTurnContext) {
		const ai = createAI({ binding: this.env.AI });

		const result = streamText({
			model: ai("@cf/moonshotai/kimi-k2.6"),
			system: "You are a helpful voice assistant. Keep responses concise.",
			messages: [
				...context.messages.map((m) => ({
					role: m.role as "user" | "assistant",
					content: m.content,
				})),
				{ role: "user", content: transcript },
			],
			abortSignal: context.signal,
		});

		return result.textStream;
	}
}
```

The `context` object provides:

| Field | Type | Description |
| --- | --- | --- |
| `connection` | `Connection` | The WebSocket connection |
| `messages` | `Array<{ role: string; content: string }>` | Completed history before the transcript |
| `signal` | `AbortSignal` | Aborted on interrupt or disconnect |

### Lifecycle hooks

| Method | Description |
| --- | --- |
| `beforeCallStart(connection)` | Return `false` to reject the call |
| `onCallStart(connection)` | Called after a call is accepted |
| `onCallEnd(connection)` | Called when a call ends |
| `onInterrupt(connection)` | Called when user interrupts during playback |

### Pipeline hooks

Intercept and transform data at each pipeline stage. Return `null` to skip the current utterance.

| Method | Receives | Can skip? |
| --- | --- | --- |
| `afterTranscribe(transcript, connection)` | STT text | Yes |
| `beforeSynthesize(text, connection)` | Text before TTS | Yes |
| `afterSynthesize(audio, text, connection)` | Audio after TTS | Yes |

```js
import {} from "agents";

export class MyAgent extends VoiceAgent {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	afterTranscribe(transcript, connection) {
		if (transcript.length < 3) return null;
		return transcript;
	}

	beforeSynthesize(text, connection) {
		return text.replace(/\bAI\b/g, "A.I.");
	}

	async onTurn(transcript, context) {
		return transcript;
	}
}
```

```ts
import { type Connection } from "agents";

export class MyAgent extends VoiceAgent<Env> {
	transcriber = new WorkersAIFluxSTT(this.env.AI);
	tts = new WorkersAITTS(this.env.AI);

	afterTranscribe(transcript: string, connection: Connection) {
		if (transcript.length < 3) return null;
		return transcript;
	}

	beforeSynthesize(text: string, connection: Connection) {
		return text.replace(/\bAI\b/g, "A.I.");
	}

	async onTurn(transcript: string, context: VoiceTurnContext) {
		return transcript;
	}
}
```

### Convenience methods

| Method | Description |
| --- | --- |
| `speak(connection, text)` | Synthesize and send audio to one connection |
| `speakAll(text)` | Synthesize and send audio to all connections |
| `forceEndCall(connection)` | Programmatically end a call |
| `saveMessage(role, text)` | Persist a message to conversation history |
| `getConversationHistory()` | Retrieve conversation history from SQLite |

### Configuration options

Pass options to `withVoice()` as the second argument:

```js
const VoiceAgent = withVoice(Agent, {
	historyLimit: 20,
	audioFormat: "mp3",
	maxMessageCount: 1000,
});
```

```ts
const VoiceAgent = withVoice(Agent, {
	historyLimit: 20,
	audioFormat: "mp3",
	maxMessageCount: 1000,
});
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `historyLimit` | `number` | `20` | Max messages loaded for context |
| `audioFormat` | `string` | `"mp3"` | Audio format sent to client |
| `maxMessageCount` | `number` | `1000` | Max messages stored in SQLite |

## Server API: `withVoiceInput`

`withVoiceInput(Agent)` adds STT-only voice input — no TTS, no LLM, no response generation. Use this for dictation, search-by-voice, or any UI where you need speech-to-text without a conversational agent.

```js
import { Agent } from "agents";
import { withVoiceInput, WorkersAINova3STT } from "agents/voice";

const InputAgent = withVoiceInput(Agent);

export class DictationAgent extends InputAgent {
	transcriber = new WorkersAINova3STT(this.env.AI);

	onTranscript(text, connection) {
		console.log("User said:", text);
	}
}
```

```ts
import { Agent } from "agents";
import { withVoiceInput, WorkersAINova3STT } from "agents/voice";

const InputAgent = withVoiceInput(Agent);

export class DictationAgent extends InputAgent<Env> {
	transcriber = new WorkersAINova3STT(this.env.AI);

	onTranscript(text: string, connection: Connection) {
		console.log("User said:", text);
	}
}
```

### `onTranscript(text, connection)`

Called after each utterance is transcribed. Override this to process the transcript.

### Hooks

`withVoiceInput` supports the same lifecycle hooks as `withVoice`:

- `beforeCallStart(connection)` — return `false` to reject
- `onCallStart(connection)`, `onCallEnd(connection)`, `onInterrupt(connection)`
- `createTranscriber(connection)` — override for runtime model switching
- `afterTranscribe(transcript, connection)` — filter or transform transcripts

It does **not** have TTS hooks (`beforeSynthesize`, `afterSynthesize`) or `onTurn`.

## Client API: React hooks

### `useVoiceAgent`

Wraps `VoiceClient` for `withVoice` agents. Manages connection, mic capture, playback, silence detection, and interrupt detection.

```tsx
import { useVoiceAgent } from "agents/voice/react";

const selectedSpeakerId = "default";

const {
	status, // "idle" | "listening" | "thinking" | "speaking"
	transcript, // TranscriptMessage[] — conversation history
	interimTranscript, // string | null — real-time partial transcript
	metrics, // VoicePipelineMetrics | null
	audioLevel, // number (0–1) — current mic RMS level
	isMuted, // boolean
	connected, // boolean — WebSocket connected
	error, // string | null
	outputDeviceError, // string | null — non-fatal speaker routing issue
	startCall, // () => Promise<void>
	endCall, // () => void
	toggleMute, // () => void
	sendText, // (text: string) => void — bypass STT
	sendJSON, // (data: Record<string, unknown>) => void
	lastCustomMessage, // unknown — last non-voice message from server
} = useVoiceAgent({
	agent: "MyAgent",
	name: "default",
	host: window.location.host,
	outputDeviceId: selectedSpeakerId, // Optional audiooutput device ID
	enabled: true,
});
```

Use `enabled: false` when the app must wait for async connection prerequisites, such as a user-scoped capability token. While disabled, the hook does not create or connect a `VoiceClient`, returns the idle disconnected state, and action callbacks such as `startCall()`, `sendText()`, and `sendJSON()` are safe no-ops.

When `enabled` changes to `true`, the hook connects with the current options. The first enable is treated as an initial connection, so `onReconnect` only fires for later connection identity changes while the hook remains enabled.

#### Output device selection

Pass `outputDeviceId` to route assistant playback to a selected speaker when the browser supports `HTMLMediaElement.setSinkId()`:

```js
const [outputDeviceId, setOutputDeviceId] = useState("default");

const voice = useVoiceAgent({
	agent: "MyAgent",
	outputDeviceId,
});
```

```tsx
const [outputDeviceId, setOutputDeviceId] = useState("default");

const voice = useVoiceAgent({
	agent: "MyAgent",
	outputDeviceId,
});
```

Use a `MediaDeviceInfo.deviceId` from `navigator.mediaDevices.enumerateDevices()` where `kind === "audiooutput"`. `"default"` and `undefined` use the system default output. Browsers without sink selection support continue playing through the default output and set `outputDeviceError` when a non-default output is requested. Device labels may be blank until the user grants microphone permission, so refresh device lists after `startCall()` if you show a speaker picker.

#### Tuning options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Delay client creation and connection when false |
| `silenceThreshold` | `number` | `0.04` | RMS below this is silence |
| `silenceDurationMs` | `number` | `500` | Silence duration before `end_of_speech` (ms) |
| `interruptThreshold` | `number` | `0.05` | RMS to detect speech during playback |
| `interruptChunks` | `number` | `2` | Consecutive high-RMS chunks to trigger interrupt |

Changing tuning options triggers a client reconnect (the connection key includes them).

### `useVoiceInput`

Lightweight hook for dictation and voice-to-text. Accumulates user transcripts into a single string.

```tsx
import { useVoiceInput } from "agents/voice/react";

function Dictation() {
	const {
		transcript, // string — accumulated text from all utterances
		interimTranscript, // string | null — current partial transcript
		isListening, // boolean
		audioLevel, // number (0–1)
		isMuted, // boolean
		error, // string | null
		start, // () => Promise<void>
		stop, // () => void
		toggleMute, // () => void
		clear, // () => void — clear accumulated transcript
	} = useVoiceInput({ agent: "DictationAgent" });

	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 "agents/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();
```

```ts
import { VoiceClient } from "agents/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 |

```js
import { Agent } from "agents";
import {
	withVoice,
	WorkersAIFluxSTT,
	WorkersAINova3STT,
	WorkersAITTS,
} from "agents/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",
	});
}
```

```ts
import { Agent } from "agents";
import {
	withVoice,
	WorkersAIFluxSTT,
	WorkersAINova3STT,
	WorkersAITTS,
} from "agents/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:**

```js
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",
	});
}
```

```ts
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:**

```js
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);
}
```

```ts
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:

```js
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",
	});
}
```

```ts
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:

```txt
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:

```txt
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](#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](https://github.com/cloudflare/agents/tree/main/examples/plivo-voice-agent)

Answer inbound Plivo calls in a Worker, including the answer URL and application setup.

### [Telnyx voice agent](https://github.com/cloudflare/agents/tree/main/examples/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.

```tsx
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:**

```js
export class MyAgent extends VoiceAgent {
	onMessage(connection, message) {
		const data = JSON.parse(message);
		if (data.type === "kick_speaker") {
			this.forceEndCall(connection);
		}
	}
}
```

```ts
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:**

```tsx
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:

```js
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;
		}
	}
}
```

```ts
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:

```tsx
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.

```js
// Get stored history, including the current transcript during onTurn()
const history = this.getConversationHistory(20);

this.saveMessage("assistant", "Welcome! How can I help?");
```

```ts
// 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

[![](https://developers.cloudflare.com/_astro/logo.te5VL_aD.svg)Docs](https://developers.cloudflare.com/)

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/agent-class/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/agent-class/communication-channels/voice/","inLanguage":"en","image":"https://developers.cloudflare.com/agents/agent-class/communication-channels/voice/og.png?v=def5f75be26dd0bb","dateModified":"2026-10-29","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/"}}
```
