Voice
This chapter covers the two ways a bot talks by voice. In the first, a client streams audio to LiteBot's /voice WebSocket and LiteBot runs the call as a Gemini Live session, executing the bot's tools itself. In the second, the VEXYL Voice Gateway handles the phone call and uses LiteBot as its brain, either turn by turn over the chat API or as a realtime /voice client. Read it when you connect a phone gateway, a mobile app or a browser to a bot that speaks.
Two ways to run a voice bot
| Pipeline (gateway brain) | Realtime (/voice) |
|
|---|---|---|
| Who listens and speaks | The gateway: its speech recogniser and voice provider | Gemini Live, inside the LiteBot session |
| What LiteBot receives | Text, one turn at a time, on POST /api/chat |
Audio frames on ws://.../voice |
| Model | The bot's provider and model |
The bot's voice.model (a Gemini Live model) |
| Bot setting needed | None beyond a normal bot | voice.enabled: true |
onSessionEnd hooks |
Yes, on DELETE /api/chat/session/:id or a hangup tool |
No (see Known gaps) |
| Credentials | None (public route) | JWT or lbk_ API key on the URL |
The pipeline gives you any LLM provider, any gateway voice, and the hooks. Realtime gives the most natural turn-taking and barge-in, at Gemini Live prices.
Enable voice on a bot
Set the voice block on the bot (dashboard: the bot's Voice tab). Every field is in Bot configuration reference; the ones you touch most:
| Field | Default | Meaning |
|---|---|---|
voice.enabled |
false |
Must be true for /voice to accept the bot |
voice.model |
GEMINI_LIVE_MODEL, else gemini-3.1-flash-live-preview |
Gemini Live model |
voice.voiceName |
Aoede |
Gemini prebuilt voice. Case-sensitive; a wrong name fails setup |
voice.languageCode |
en-US |
Language tag. Native-audio models ignore it and follow the caller, so fix the language in the system prompt |
voice.responseModality |
AUDIO |
AUDIO or TEXT |
voice.inputMimeType |
VOICE_INPUT_MIME_TYPE, else audio/pcm;rate=16000 |
Audio format the client must send |
voice.vad.* |
VOICE_VAD_* variables, else Gemini's defaults |
Turn detection: silenceDurationMs (100 to 5000), prefixPaddingMs, startOfSpeechSensitivity, endOfSpeechSensitivity, disabled |
voice.interruption.enabled |
true |
Let the caller interrupt the bot |
voice.turnCoverage |
VOICE_TURN_COVERAGE, else Gemini's default |
TURN_INCLUDES_ONLY_ACTIVITY or TURN_INCLUDES_ALL_INPUT |
Thinking is off for voice by default (budget 0), because it adds first-word delay and some native-audio models speak their reasoning. VOICE_THINKING_BUDGET changes it for every voice bot: -1 for the model's own default, or a token count. The bot field voice.thinkingBudget is read by the voice handler but the bot schema rejects it, so use the variable.
/voice uses the Gemini API key (GEMINI_API_KEY, or the bot tenant's own Gemini key), not Vertex AI. At startup LiteBot logs an error if a bot has voice.enabled: true and GEMINI_API_KEY is empty.
Connect to /voice
ws://127.0.0.1:3345/voice?token=<token>
ws://127.0.0.1:3345/voice?api_key=lbk_...
LiteBot checks the credential before it accepts the upgrade. Browsers cannot set headers on a WebSocket, so both go on the query string.
| Parameter | Use for | Identity on the session |
|---|---|---|
token |
A logged-in user (JWT from POST /api/auth/login) |
The user's id. userId in init_session is ignored |
api_key |
A server such as a gateway or dialer | userId from init_session, trusted as sent |
If both are present, token is used. A missing or bad credential gets HTTP 401 Unauthorized and the socket never opens; an error while checking the key gets 500; any path other than /chat or /voice gets 404. Failed API key checks count toward the same per-IP lockout as the X-API-Key header. The credential ends up in proxy access logs, so use wss:// and treat logs accordingly.
Session flow
Every frame is one JSON object; audio travels base64-encoded inside it. A connection carries one voice session.
server connection_established
client init_session
server session_ready -> client may send audio_chunk / text_input
... transcripts, audio, tool frames, turn_complete per turn
server session_end -> on hangup, silence or time limit
Client to server
| Type | Fields | Meaning |
|---|---|---|
init_session |
botId (required), userId, mode, variables, context, llm_context |
Start the session |
audio_chunk |
sessionId, data |
Base64 microphone audio in the format from session_ready |
text_input |
sessionId, text |
Typed text instead of speech. 30 per minute |
update_context |
sessionId, context, llm_context |
Change context mid-call. At least one of the two |
end_session |
sessionId |
Close the Gemini session cleanly |
ping |
none | Server answers pong |
init_session fields:
| Field | Meaning |
|---|---|
botId |
The bot. Must be active and have voice.enabled: true |
userId |
Caller id, used only with api_key. For a gateway, use a value unique per live call, such as its call UUID |
mode |
audio or text. Default comes from voice.responseModality |
variables |
Values for {{name}} in the system prompt, merged over the bot's variables. Filled once at start |
context |
Facts for tools, stored in the session context. The model sees them only if the bot has injectContext: true |
llm_context |
Facts for the model, added to the system prompt as key: value lines under Current session context: |
{
"type": "init_session",
"botId": "clinic-reception",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"mode": "audio",
"variables": { "callerName": "Priya" },
"context": { "callerNumber": "+919876543210" },
"llm_context": { "preferredLanguage": "en-IN" }
}
Keys and values in llm_context are cleaned of newlines and control characters before they reach the prompt, so a value cannot inject new instructions. update_context with llm_context adds the facts to the live conversation as a silent turn; the reply is context_updated with the stored keys and injected: true when the model received them.
Server to client
| Type | Fields | Meaning |
|---|---|---|
connection_established |
connectionId, timestamp |
Send init_session now |
session_ready |
sessionId, mode, audio.input.mimeType, audio.output.mimeType |
Setup done; start sending audio |
audio_response |
data |
Base64 bot audio |
text_chunk |
text |
Bot text (text mode) |
user_transcript |
text, final |
What the caller said |
assistant_transcript |
text, final |
What the bot is saying, in fragments |
interrupted |
sessionId |
The caller spoke over the bot. Drop queued playback |
generation_complete |
sessionId |
The bot finished producing this reply |
turn_complete |
sessionId |
Turn over; both sides are saved to the conversation |
tool_execution |
id, name, args |
The model called a tool. Information only |
tool_result |
id, success, hangup, transfer, control |
The tool finished |
transfer |
reason, target |
A tool asked for a transfer. The socket stays open |
usage_summary |
sessionId, model, final, tokens, cost |
Running token and cost totals after each turn; final: true once at the end |
session_expiring |
reasonMs |
Google will close the upstream session soon |
session_end |
reason, detail |
The server ended the session |
context_updated |
sessionId, keys, injected |
Reply to update_context |
pong |
timestamp |
Reply to ping |
error |
code, message |
See Errors |
Keep the last usage_summary you receive: a caller hangup can close the socket before the final one. Strip cost before showing the frame to end users.
Audio format
| Direction | Format |
|---|---|
| Client to server | Signed 16-bit little-endian PCM, mono, at the rate in session_ready.audio.input.mimeType (default 16 kHz) |
| Server to client | Signed 16-bit little-endian PCM, mono, 24 kHz |
LiteBot does not transcode. A telephony client decodes ยต-law or A-law, resamples 8 kHz to the input rate, and resamples 24 kHz back to 8 kHz for the phone leg. Chunks of 20 to 250 ms work. Inbound audio is capped at VOICE_AUDIO_BYTES_PER_SEC (256 KB per second; a chunk over it is dropped with an error) and VOICE_AUDIO_TOTAL_BYTES_CAP (100 MB per session; over it the session ends). One frame may be up to 4 MB.
Tools, hangup and transfer
The bot's tools are offered to Gemini Live with the same schemas as in chat. When the model calls one, LiteBot runs it through the tool registry and returns the result. A voice tool call times out after 30 seconds; the model then gets { "error": "timeout" }. A parameter named access_token is hidden from the model and filled from the last token any tool returned in that session, because models mistype long tokens.
LiteBot reads two intents from a tool's result:
| Tool returns | tool_result shows |
After the turn finishes |
|---|---|---|
hangup: true |
hangup: true, control.reason |
About 1.5 seconds later: final usage_summary, then session_end with reason: "hangup"; the Gemini session closes |
transfer: true or handoff: true |
transfer: true, control.target from transferTo or handoffBotId |
A transfer frame. What happens next is up to the client |
On hangup, stop the microphone, let the goodbye audio play, then close the socket. LiteBot does not close it for you on this path.
Limits and session end
| Limit | Default | Variable |
|---|---|---|
| Voice sessions per process | 10 | MAX_VOICE_SESSIONS_CONCURRENT |
| Sessions per JWT user | 1 | MAX_VOICE_SESSIONS_PER_USER |
| Sessions per API key | 50 | API_KEY_DEFAULT_MAX_VOICE_CONCURRENT, or maxVoiceConcurrent on the key's record in the api_keys collection (no API or dashboard field) |
Sessions per userId under one API key |
1 | fixed |
| Silence: no transcript, text or tool call | 30 s | VOICE_SILENCE_TIMEOUT_MS |
| Session length | 15 min | MAX_VOICE_SESSION_DURATION_MS |
| No frames at all | 5 min, swept every 5 min | VOICE_SESSION_IDLE_TIMEOUT_MS |
| Missed pings before the socket is dropped | 2 at 30 s intervals | VOICE_WS_PING_MAX_MISSED, VOICE_WS_PING_INTERVAL_MS |
text_input length, llm_context length |
unlimited | VOICE_TEXT_INPUT_MAX_CHARS, VOICE_MAX_LLM_CONTEXT_CHARS, VOICE_MAX_LLM_CONTEXT_VALUE_CHARS |
A session ends in one of these ways:
| How | What the client sees |
|---|---|
| Hangup tool | session_end reason: "hangup", socket left open |
| Silence | session_end reason: "silence", then close code 1000 |
| Length limit | session_end reason: "max_duration", then close code 1000 |
end_session, or the client closes the socket |
No frame |
| Idle sweep, missed pings, process shutdown | Socket closes with no session_end |
| Audio total cap | error AUDIO_TOTAL_CAP_EXCEEDED |
Treat a plain close as the end of the session. Every path saves pending transcripts, marks the conversation ended, writes the usage to analytics and closes the Gemini session. Conversations from /voice are stored with metadata.transport: "voice" and appear with chat sessions in the dashboard (see Conversations and analytics).
Errors
| Code | Meaning |
|---|---|
INVALID_JSON, UNKNOWN_TYPE |
Bad frame |
BOT_NOT_FOUND |
Unknown or inactive botId |
VOICE_NOT_ENABLED |
The bot has no voice.enabled: true |
CAPACITY, CAPACITY_USER, CAPACITY_KEY, CAPACITY_CALLER |
A limit in the table above |
SESSION_NOT_READY |
Audio, text or context sent before session_ready |
LIVE_SETUP_FAILED, LIVE_ERROR, SEND_FAILED |
Gemini Live refused setup, failed mid-call, or the upstream socket is gone |
RATE_LIMIT_EXCEEDED, TEXT_TOO_LONG |
text_input limits |
INVALID_CONTEXT, UPDATE_CONTEXT_FAILED |
update_context had neither field, or failed |
AUDIO_RATE_LIMIT_EXCEEDED, AUDIO_TOTAL_CAP_EXCEEDED |
Audio limits |
INTERNAL_ERROR |
Unexpected server error handling the frame |
GET /api/voice/stats (login required) shows active voice sessions.
With the VEXYL Voice Gateway
The gateway answers the phone call and can use LiteBot in either mode. The gateway side is in the VEXYL Voice Gateway manual, chapters AI brain and Realtime mode.
Pipeline. The gateway turns speech into text and calls POST /api/chat for each turn, with "stream": true by default (its LITEBOT_STREAMING=false turns that off) so it can speak the first sentence early. It sends the bot from its call settings (botId, else its LITEBOT_BOT_ID), the caller id as userId, and caller details in context. LiteBot needs nothing special. The gateway:
- speaks
response, and ends the call after speaking whenshouldHangupis true (or when a tool named in itsLITEBOT_HANGUP_TOOLS, defaulthangupCall, succeeded); - stays silent on
shouldWaitForMoreandshouldGiveTime, and skips its wait sound onskipProcessingSound; - calls
DELETE /api/chat/session/:id?reason=remote_hangupwhen the caller hangs up, orreason=transferon a transfer, which runs the bot'sonSessionEndhooks; - relies on
STREAM_HOLD_TOOL_TURNS(default on) so it never speaks text the bot wrote next to a tool call.
LiteBot Live. With the call's realtime provider set to litebot, the gateway opens /voice itself, using its LITEBOT_LIVE_URL and an lbk_ key or token. Its init_session carries its call UUID as userId, callerName, callerNumber and language as variables, the other call settings plus callerId, channel and callDirection as context, and the call's llm_context. Give the gateway its own API key, and make sure the per-key session limit (50 by default) and MAX_VOICE_SESSIONS_CONCURRENT (10 by default) cover the gateway's realtime call capacity.
Dashboard test. Set VEXYL_GATEWAY_WS_URL (a wss:// or ws:// address of a gateway whose LiteBot setting points at this LiteBot), and optionally VEXYL_GATEWAY_API_KEY and VEXYL_GATEWAY_TTS_PROVIDER, then restart. The chat screen then offers Voice: Gateway, which runs the full phone pipeline from the browser. GET /api/voice-gateway/config (login required) returns enabled, serverUrl, apiKey and ttsProvider. The key is visible to any logged-in user; the gateway's origin allowlist is the real control.
Known gaps
/voicesessions do not runonSessionEndhooks. Lead capture that must run at the end of a realtime call has to happen in a tool during the call.- Setting
VOICE_SILENCE_TIMEOUT_MS,MAX_VOICE_SESSION_DURATION_MSorVOICE_WS_PING_INTERVAL_MSto0does not disable them; the default applies. Use a large value instead. voice.thinkingBudgetis not accepted in bot config; useVOICE_THINKING_BUDGET.
Where to go next
- Every
voice.*field: Bot configuration reference - Text chat, streaming and ending sessions: Chat API
- Outbound calling campaigns through the gateway: Auto-dialer
- API keys and their limits: Users, roles and tenants