LiteBot

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 when shouldHangup is true (or when a tool named in its LITEBOT_HANGUP_TOOLS, default hangupCall, succeeded);
  • stays silent on shouldWaitForMore and shouldGiveTime, and skips its wait sound on skipProcessingSound;
  • calls DELETE /api/chat/session/:id?reason=remote_hangup when the caller hangs up, or reason=transfer on a transfer, which runs the bot's onSessionEnd hooks;
  • 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

  • /voice sessions do not run onSessionEnd hooks. 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_MS or VOICE_WS_PING_INTERVAL_MS to 0 does not disable them; the default applies. Use a large value instead.
  • voice.thinkingBudget is not accepted in bot config; use VOICE_THINKING_BUDGET.

Where to go next