- Your server calls
GET /v1/tokenwith your API key to mint a short-lived temporary token. - Your browser opens the WebSocket with
?token=<token>, no API key exposed. - The browser sends one
session.updatewith youragent_id; the agent’s stored prompt, voice, and tools load automatically.
This page connects to a stored agent by
agent_id, the recommended path. If you’d rather configure the agent inline per session instead of creating one, send system_prompt / greeting / output in the session.update and omit agent_id. The two are mutually exclusive. See Inline configuration.1. Generate a token on your server
CallGET /v1/token with your API key in the Authorization header. Pick an expires_in_seconds short enough to limit replay risk (60–300s is a good default) and an optional max_session_duration_seconds to cap the session length.
These two parameters control different things and are easy to confuse:
expires_in_secondsis the token redemption window: how long the client has to use this token to open a WebSocket. If the window elapses before the WebSocket is opened, the server returns asession.errorwith codeunauthorizedon the first frame instead ofsession.ready. Once asession.readyhas been received, this value no longer applies.max_session_duration_secondsis the session duration cap: how long the resulting voice agent session is allowed to run after the WebSocket is open.
expires_in_seconds must be between 1 and 600. max_session_duration_seconds must be between 60 and 10800 (defaults to 10800, the 3-hour maximum session duration).session.resume. End sessions cleanly with session.end so you don’t pay for the 30-second resume grace window.
2. Connect from the browser with the token
Fetch the token from your server, open the WebSocket with?token=<token> (no Authorization header needed), and bind to your agent by agent_id:
3. Browser client
The full app
The Python and JavaScript starters ship a browser client with the token endpoint from step 1 already wired up, plus a live event log of every frame in both directions:AudioContext with a resampling worklet, so a browser that refuses to open a context at 24 kHz still sounds right. Start there if you want something working before you start cutting.
The lite version
One HTML file, no repo, no build step
One HTML file, no repo, no build step
A minimal client that captures microphone audio, streams it to the Voice Agent API, and plays back the agent’s response. Two files: an HTML page and an AudioWorklet processor.Create Then create your HTML file:
AudioWorklet processors load from a URL, so this needs two files. Serve them locally with
npx serve ..pcm-processor.js in the same directory as your HTML file:4. Audio capture and echo cancellation
Ask for the microphone withechoCancellation on and noiseSuppression off:
status: "interrupted". The browser’s built-in acoustic echo cancellation removes speaker output from the mic signal, which is why a browser client needs no headphones.
Noise suppression off. The server already cleans up the input, and stacking a second denoising layer on top introduces artifacts that hurt transcription accuracy more than the original noise did. Same goes for RNNoise, Krisp, BVC, and similar. To tune how aggressively the server isolates the caller from background noise, use input.voice_focus instead, see Isolate the caller’s voice.
5. Browser compatibility
The client above works as-is on Chromium-based browsers (Chrome, Edge, Brave, Arc) and Firefox. Safari has a known quirk that produces silently garbled audio if you don’t account for it.Safari: resample inside the worklet
Safari ignores thesampleRate constructor option, so an AudioContext({ sampleRate: 24000 }) will silently run at 48 kHz on most Macs. Sending those samples to the Voice Agent API as if they were 24 kHz produces audio that sounds chipmunked or garbled.
Detect the actual context rate at runtime, send it into the worklet, and resample there:
createBuffer(1, length, 24000) works on all current browsers, since the context resamples on output. Linear interpolation is good enough for speech.
Cross-browser checklist
- User gesture required. All major browsers gate
getUserMediaandAudioContextstartup behind a user gesture (Safari is strictest). Start audio inside aclickortouchstarthandler and callawait audioCtx.resume()before connecting nodes. - HTTPS or
localhost.getUserMediaonly works on secure origins. - Echo cancellation. Pass
echoCancellation: truetogetUserMediaso the agent’s TTS playing through the speakers doesn’t get re-captured by the mic. - Audio output sink. On iOS Safari, set the
<audio playsinline>attribute or route through anAudioContextdestination. Autoplay and full-screen behavior differ from desktop.
6. Ending the session cleanly
Wiresession.end to your explicit end-call control, wait for session.ended, and clean up there:
pagehide fires on both and is more reliable than beforeunload on mobile Safari:
Send
session.end synchronously inside the pagehide handler. Anything async (like await fetch) will not finish before the socket is torn down.SIGINT, SIGTERM, or your own hangup handler). See Unexpected billing after the call ended if your sessions look about 30 seconds longer than the call itself.