Voice API
Create voice sessions and manage audio over WebSocket.
Voice API uses two steps: your backend creates a session with a Developer API key, then the client connects over WebSocket with a short-lived token. Never send the Developer API key to a browser.
This is an advanced Preview path. Assistant Core does not yet provide a browser voice SDK, WebRTC transport, or microphone/playback helpers. Use it only when your team can manage capture, resampling, codecs, playback, cancellation, and reconnection.
1. Allow the browser origin
For a browser client, go to Developer API → Voice Browser Access and add the website's exact origin. The origin must use http or https; its scheme, hostname, and effective port must all match.
This list is independent from the websites allowed to load the Web Widget. Server, mobile, and desktop clients that omit Origin do not require this step, but they must still present a valid session token.
2. Create a session from your backend
POST /v1/voice/sessions · scope voice:connect
curl "$ASSISTANT_API_URL/v1/voice/sessions" \
--request POST \
--header "Authorization: Bearer $ASSISTANT_API_KEY" \
--header "Content-Type: application/json" \
--data "{
\"external_user_id\": \"$EXTERNAL_USER_ID\",
\"mode\": \"realtime\",
\"turn_detection\": \"server_vad\",
\"max_session_seconds\": 60,
\"input_audio_format\": {
\"codec\": \"pcm_s16le\",
\"sample_rate_hz\": 16000,
\"channels\": 1
},
\"output_audio_format\": {
\"codec\": \"opus\",
\"sample_rate_hz\": 24000,
\"channels\": 1
}
}"| Field | Meaning |
|---|---|
external_user_id | 1–256 character end-user identity. |
mode | standard uses ASR → LLM → TTS; realtime uses a speech-to-speech provider. |
turn_detection | manual (default); auto for standard mode; or server_vad for realtime mode. |
max_session_seconds | Optional server-enforced duration from 10 to 1800 seconds; the global maximum still applies. |
input_audio_format | pcm_s16le at 16/24 kHz or opus at 24 kHz; mono. |
output_audio_format | Same codec options; Opus requires 24 kHz; mono. |
conversation_id | Optional UUID to resume a conversation owned by the same external user. |
include_metrics | Optional, default false. When true, the server sends response.metrics after each completed response. |
201 response:
{
"id": "sess_01K...",
"token": "short_lived_single_use_token",
"expires_at": "2026-08-13T08:32:00Z",
"websocket_url": "wss://api.assistantcore.com/v1/voice"
}Return only id, token, expires_at, and websocket_url to the authenticated client. Never return the Developer API key. The token expires after about 120 seconds and can be used once.
Mobile and desktop applications
Do not embed a Developer API key in an APK, IPA, or desktop binary. A native application is a public client, and a secret shipped with the application can be extracted.
Your backend keeps the Developer API key, authenticates the app user, creates a voice session, and returns only token, expires_at, and websocket_url to the application. Store a per-installation refresh token or private key in Keychain/Keystore when your application has its own sign-in flow; do not use one shared Developer API key as the app credential.
3. Open the WebSocket
Connect to the exact returned websocket_url. The first event, sent within 15 seconds, must be:
{
"type": "session.start",
"token": "short_lived_single_use_token"
}Do not put the token in the URL. The server sends session.created when ready:
{
"type": "session.created",
"sequence": 1,
"session_id": "sess_01K..."
}Every JSON server event has a monotonically increasing sequence. Binary server messages contain audio in the negotiated output format.
The browser connection skeleton below handles session and control events only; capture and playback depend on your application's audio stack:
const session = await fetch("/api/assistant/voice-session", {
method: "POST",
}).then((response) => response.json())
const socket = new WebSocket(session.websocket_url)
socket.binaryType = "arraybuffer"
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "session.start",
token: session.token,
}))
})
socket.addEventListener("message", (message) => {
if (typeof message.data === "string") {
const event = JSON.parse(message.data)
console.log(event.type, event.sequence)
return
}
// Queue message.data for playback using the negotiated output format.
})
socket.addEventListener("close", () => {
// Discard this token. Ask the backend for a new session before reconnecting.
})Send text
{ "type": "input_text.create", "text": "What is your return policy?" }Do not send new input while a response is active. Send response.cancel first for barge-in.
Send audio
For standard + manual or realtime + manual, use this order:
- Send
{"type":"input_audio.start"}. - Send one or more binary WebSocket messages in the negotiated input format.
- Send
{"type":"input_audio.commit"}.
For standard + auto or realtime + server_vad, send input_audio.start once after
session.created, stream binary audio continuously, and do not send
input_audio.commit. The server or provider emits speech boundaries and creates exactly
one response per detected turn.
For pcm_s16le, the binary payload is raw signed 16-bit little-endian mono PCM without a WAV header. Prefer short, regular frames; each binary message must be at most 256 KiB. For Opus, each binary message contains an Opus frame at 24 kHz mono.
Client events
| Event | Use |
|---|---|
session.start | First event; submits the token. |
input_audio.start | Opens a manual turn, or the continuous input stream for server VAD. |
| Binary message | Sends audio after input_audio.start. |
input_audio.commit | Ends a manual turn; invalid when server VAD is enabled. |
input_text.create | Sends text instead of audio. |
response.cancel | Cancels the active response or performs barge-in. |
ping | Checks the connection; the server sends pong. |
Server events
| Event | Content |
|---|---|
session.created | The session is ready. In Standard mode, when the assistant has a voice greeting and the session starts a new conversation, the greeting follows as a normal response (output_audio.*, then response.completed without message_id). Speech or response.cancel interrupts it. It is saved with the first user turn. |
conversation.created | A new conversation ID. |
input_audio.speech_started | Audio input opened. |
input_audio.speech_stopped | Audio input committed. |
transcript.completed | Completed transcript in standard mode. |
response.text.delta | Assistant text delta. |
output_audio.started | Binary output audio begins. |
| Binary message | Audio in the negotiated output format. |
output_audio.stopped | Binary output audio ended. |
response.completed | The response completed or was cancelled. |
response.metrics | Latency breakdown of the completed response; only with include_metrics. |
error | An error field with {code, message, retryable}. |
pong | Response to ping. |
Latency metrics
With include_metrics: true, each completed response is followed by one response.metrics event. Cancelled or failed responses do not emit it.
{
"type": "response.metrics",
"sequence": 42,
"message_id": "7c1e...",
"end_of_utterance_ms": 610,
"transcription_ms": 180,
"llm_ttft_ms": 420,
"tts_ttfb_ms": 150,
"e2e_latency_ms": 1390
}| Field | Measured from → to |
|---|---|
end_of_utterance_ms | User stops speaking → server commits the turn (VAD silence window). |
transcription_ms | Turn committed → final transcript. |
llm_ttft_ms | Final transcript → first LLM token. |
tts_ttfb_ms | First TTS request → first TTS audio. |
e2e_latency_ms | User stops speaking → first response audio sent. |
All values are integer milliseconds measured on the server, so they exclude network time to your client. A field is omitted when the stage was not measured. Realtime mode runs recognition, reasoning, and speech inside the provider, so it reports only e2e_latency_ms.
Cancel and barge-in
{ "type": "response.cancel" }After canceling, stop playback and discard buffered output audio, then wait for response.completed with status cancelled. Send new input only afterward.
Reconnect
A session token cannot be reused. After a lost connection:
- Close the client socket and discard the old token.
- Ask your backend to create a new session.
- Include the previous
conversation_idin the session request to resume it. - Connect to the new URL and send the new token.
Do not retry forever. Use exponential backoff for retryable failures and stop when the credential, origin, plan, or credits are invalid.
Current connection limits
- JSON event: 16 KiB maximum; binary frame: 256 KiB maximum.
- Up to 50 events/second and 10 MiB/minute per connection.
- Up to 15 minutes of input audio, 30 minutes/session, and 2 minutes idle. A smaller
max_session_secondsis enforced when issued in the session token. - Realtime sessions are also capped at what the owner's credits pay for: the issued
max_session_secondsnever exceeds that budget, and a session ended by it closes withINSUFFICIENT_CREDITSrather thanSESSION_DURATION_EXCEEDED. - Up to 5 connections/credential and 50 connections/assistant.
Limits may change during Preview. See Errors and limits for close-code handling.