Realtime voice opens a live audio connection between the browser and the model. The server mints a short-lived token. The API key stays on the server.
This builds a voice chat with OpenAI in React. It takes about 5 minutes.
npm i @tanstack/ai @tanstack/ai-react @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openaiOPENAI_API_KEY=sk-...import { realtimeToken } from '@tanstack/ai'
import { openaiRealtimeToken } from '@tanstack/ai-openai'
export async function POST() {
const token = await realtimeToken({
adapter: openaiRealtimeToken({ model: 'gpt-realtime-2.1' }),
})
return Response.json(token)
}import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
export function VoiceChat() {
const {
status,
mode,
messages,
connect,
disconnect,
pendingUserTranscript,
pendingAssistantTranscript,
} = useRealtimeChat({
getToken: () =>
fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()),
adapter: openaiRealtime(),
instructions: 'You are a helpful voice assistant. Keep answers short.',
voice: 'alloy',
})
return (
<div>
<p>
Status: {status}. Mode: {mode}.
</p>
<button onClick={status === 'idle' ? connect : disconnect}>
{status === 'idle' ? 'Start conversation' : 'End conversation'}
</button>
{pendingUserTranscript && <p>You: {pendingUserTranscript}...</p>}
{pendingAssistantTranscript && <p>AI: {pendingAssistantTranscript}...</p>}
{messages.map((msg) => (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{msg.parts.map((part, i) => (
<span key={i}>
{part.type === 'text' ? part.content : null}
{part.type === 'audio' ? part.transcript : null}
</span>
))}
</div>
))}
</div>
)
}Click Start conversation and allow the microphone. Say something. The model answers out loud, and the transcript shows up below the button.
The chat has two halves:
After you connect, the provider listens to the user. When the user stops talking, the model answers with voice. If the user talks while the model speaks, the model stops. The hook keeps a transcript in messages.
This is a live conversation, not a generation. For one clip of speech from text, use Text-to-Speech.
Every provider uses the same hook. Swap the token adapter on the server and the connection adapter in the browser.
| Provider | Server token | Browser adapter | Key | Transport |
|---|---|---|---|---|
| OpenAI | openaiRealtimeToken() | openaiRealtime() | OPENAI_API_KEY | WebRTC (default) or WebSocket |
| Gemini | geminiRealtimeToken() | geminiRealtime() | GOOGLE_API_KEY or GEMINI_API_KEY | WebSocket |
| Grok | grokRealtimeToken() | grokRealtime() | XAI_API_KEY | WebRTC (default) or WebSocket |
| ElevenLabs | elevenlabsRealtimeToken() | elevenlabsRealtime() | ELEVENLABS_API_KEY | WebSocket or WebRTC (detected by default) |
To pick the transport, pass connectionMode to the browser adapter, for example openaiRealtime({ connectionMode: 'websocket' }).
Pass the model to the token adapter. The default is gpt-realtime-2.1.
import { realtimeToken } from '@tanstack/ai'
import { openaiRealtimeToken } from '@tanstack/ai-openai'
const token = await realtimeToken({
adapter: openaiRealtimeToken({ model: 'gpt-realtime-2.1-mini' }),
})| Model | Description |
|---|---|
| gpt-realtime-2.1 | Newest full model (default) |
| gpt-realtime-2.1-mini | Newest smaller, faster model |
| gpt-realtime-2 | Full model |
| gpt-realtime-1.5 | Full model |
| gpt-realtime | Full model |
| gpt-realtime-mini | Smaller, faster model |
Voices: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar.
Pass the model to the browser adapter. The default is gemini-3.8-live.
import { realtimeToken } from '@tanstack/ai'
import { geminiRealtimeToken } from '@tanstack/ai-gemini'
const token = await realtimeToken({ adapter: geminiRealtimeToken() })import { geminiRealtime } from '@tanstack/ai-gemini'
const adapter = geminiRealtime({ model: 'gemini-3.8-live' })Models:
The token is good for one connection and 30 minutes by default. Change this with uses and expiresAt. See the Gemini adapter for voices and Live options.
The default model is grok-voice-think-fast-2.0. grok-voice-latest always points to the newest model.
import { realtimeToken } from '@tanstack/ai'
import { grokRealtimeToken } from '@tanstack/ai-grok'
const token = await realtimeToken({
adapter: grokRealtimeToken({ model: 'grok-voice-think-fast-2.0' }),
})In the browser, use grokRealtime(). See the Grok adapter.
ElevenLabs runs an agent that you create in the ElevenLabs dashboard. The agent holds the voice and the system prompt.
import { realtimeToken } from '@tanstack/ai'
import { elevenlabsRealtimeToken } from '@tanstack/ai-elevenlabs'
const token = await realtimeToken({
adapter: elevenlabsRealtimeToken({ agentId: 'your-agent-id' }),
})If you leave out agentId, the adapter reads ELEVENLABS_AGENT_ID. To change the voice, the system prompt, the first message, or the language for one session, pass overrides. In the browser, use elevenlabsRealtime():
import { useRealtimeChat } from '@tanstack/ai-react'
import { elevenlabsRealtime } from '@tanstack/ai-elevenlabs'
export function ElevenLabsChat() {
const { status, connect } = useRealtimeChat({
getToken: () => fetch('/api/elevenlabs-token').then((r) => r.json()),
adapter: elevenlabsRealtime(),
})
// Render the same way as in the quick setup.
}See the ElevenLabs adapter for the agent setup.
Voice Activity Detection (VAD) decides when the user starts and stops talking. Set it with vadMode:
| Mode | How it works | Use it for |
|---|---|---|
| server | The provider detects speech from the audio level. This is the default. | Most apps |
| semantic | The provider detects the end of a sentence from pauses and meaning. | Natural talk that does not cut the user off |
| manual | You call startListening() and stopListening(). | Push-to-talk |
A push-to-talk button:
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
export function PushToTalk() {
const { startListening, stopListening } = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
vadMode: 'manual',
})
return (
<button onMouseDown={startListening} onMouseUp={stopListening}>
Hold to talk
</button>
)
}With semantic, semanticEagerness sets how long the model waits before it decides the user is done. 'low' waits the longest.
To change the mode without a reconnect, call updateSession({ vadMode: 'semantic' }).
The model can call client tools during the conversation. Define them with toolDefinition() and pass the .client() implementations to tools:
import { toolDefinition } from '@tanstack/ai'
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { z } from 'zod'
import { getToken } from './token'
const getWeatherDef = toolDefinition({
name: 'getWeather',
description: 'Get weather for a location',
inputSchema: z.object({
location: z.string().meta({ description: 'City name' }),
}),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
})
const getWeather = getWeatherDef.client(async ({ location }) => {
const res = await fetch(`/api/weather?location=${location}`)
return res.json()
})
export function useWeatherVoiceChat() {
return useRealtimeChat({
getToken,
adapter: openaiRealtime(),
tools: [getWeather],
})
}The client runs each tool call and sends the result back to the provider. The calls show up as tool-call and tool-result parts in messages. Keep tool output small, because the model reads it while the conversation runs. For more on tools, see Tools.
The user can also type, or send a picture:
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
import { base64ImageData } from './assets'
export function useAskAboutPhoto() {
const { sendText, sendImage } = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
})
return () => {
sendImage(base64ImageData, 'image/png')
sendText('What is in this photo?')
}
}sendImage takes base64 data or a URL, and a MIME type.
With server or semantic VAD, the model stops when the user starts to talk. To stop it from code, call interrupt():
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
export function useStopButton() {
const { interrupt, mode } = useRealtimeChat({
getToken,
adapter: openaiRealtime(),
})
return () => {
if (mode === 'speaking') interrupt()
}
}An interrupted message has interrupted: true.
You now have a voice chat that listens, answers, calls tools, and stops when the user cuts in.
| Option | Type | Default | Description |
|---|---|---|---|
| getToken | () => Promise<RealtimeToken> | required | Fetches a token from your server |
| adapter | RealtimeAdapter | required | The browser adapter, for example openaiRealtime() |
| instructions | string | - | System instructions for the assistant |
| voice | string | - | The voice for audio output |
| tools | ReadonlyArray<AnyClientTool> | - | Client tools with their implementations |
| vadMode | 'server' | 'semantic' | 'manual' | 'server' | Turn detection mode |
| semanticEagerness | 'low' | 'medium' | 'high' | - | How fast semantic VAD ends a turn |
| autoPlayback | boolean | true | Play the assistant audio |
| autoCapture | boolean | true | Ask for the microphone on connect |
| outputModalities | Array<'audio' | 'text'> | - | What the model answers with |
| temperature | number | - | Generation temperature |
| maxOutputTokens | number | 'inf' | - | Maximum tokens in one answer |
Callbacks:
| Callback | Called when |
|---|---|
| onConnect, onDisconnect | The session opens or closes |
| onStatusChange(status), onModeChange(mode) | The status or the mode changes |
| onMessage(message) | A message is complete |
| onInterrupted() | The user interrupts the model |
| onError(error) | Something fails |
| onUsage(usage) | The provider reports usage |
| onGoAway(timeLeft?) | The provider is about to close the session |
updateSession(config) changes the session config while connected. It accepts voice, instructions, vadMode, and the other session fields, plus providerOptions for provider-specific settings.
status is the connection:
| Status | Description |
|---|---|
| idle | Not connected |
| connecting | The connection is opening |
| connected | The session is active |
| reconnecting | The connection dropped and is coming back |
| error | The connection failed |
mode is what the conversation does while connected:
| Mode | Description |
|---|---|
| idle | Connected, nothing happens |
| listening | The microphone captures the user |
| thinking | The model works on the input |
| speaking | The model answers |
Realtime messages hold a list of parts:
import type { RealtimeMessagePart } from '@tanstack/ai'
interface RealtimeMessage {
id: string
role: 'user' | 'assistant'
timestamp: number
parts: Array<RealtimeMessagePart>
interrupted?: boolean
}| Part type | Fields | Description |
|---|---|---|
| text | content | Text from sendText() |
| audio | transcript, durationMs | A spoken turn and its transcript |
| tool-call | id, name, arguments, input, output | A tool call |
| tool-result | toolCallId, content | A tool result |
| image | data, mimeType | An image from sendImage() |
Read error (an Error or null), or pass onError. A denied microphone is the most common error:
import { useRealtimeChat } from '@tanstack/ai-react'
import { openaiRealtime } from '@tanstack/ai-openai'
import { getToken } from './token'
export function useVoiceChatWithErrors() {
return useRealtimeChat({
getToken,
adapter: openaiRealtime(),
onError: (err: Error) => {
if (err.message.includes('Permission denied')) {
alert('Voice chat needs the microphone.')
} else {
console.error('Realtime error:', err)
}
},
})
}For short network failures, add your own retry logic that calls connect() again.
The hook gives you audio data for level meters, waveforms, and spectrum views:
A pulsing indicator driven by a level:
export function AudioIndicator({ level }: { level: number }) {
return (
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
transform: `scale(${1 + level * 0.5})`,
backgroundColor: `rgba(59, 130, 246, ${0.3 + level * 0.7})`,
transition: 'transform 0.1s ease',
}}
/>
)
}A spectrum view on a canvas:
export function drawSpectrum(
canvas: HTMLCanvasElement,
getInputFrequencyData: () => Uint8Array,
) {
const ctx = canvas.getContext('2d')
if (!ctx) return
const draw = () => {
const data = getInputFrequencyData()
ctx.clearRect(0, 0, canvas.width, canvas.height)
const barWidth = canvas.width / data.length
data.forEach((value, i) => {
const height = (value / 255) * canvas.height
ctx.fillRect(i * barWidth, canvas.height - height, barWidth - 1, height)
})
requestAnimationFrame(draw)
}
draw()
}Without React, or for full control, use RealtimeClient. It takes the same options and callbacks as the hook:
import { RealtimeClient } from '@tanstack/ai-client'
import { openaiRealtime } from '@tanstack/ai-openai'
const client = new RealtimeClient({
getToken: () =>
fetch('/api/realtime-token', { method: 'POST' }).then((r) => r.json()),
adapter: openaiRealtime(),
instructions: 'You are a helpful assistant.',
voice: 'alloy',
onMessage: (message) => console.log(`${message.role}:`, message.parts),
onStatusChange: (status) => console.log('Status:', status),
onModeChange: (mode) => console.log('Mode:', mode),
})
await client.connect()
client.sendText('Hello!')
const unsubscribe = client.onStateChange((state) => {
console.log('Messages:', state.messages.length)
})
// When you are done:
unsubscribe()
await client.disconnect()
client.destroy()The React hook disconnects by itself when the component unmounts. With RealtimeClient, call disconnect() and destroy() yourself.