Skip to content

Voice protocol (Vql frames)

The voice runtime and your brain exchange a small set of Vql* frames over one WebSocket per session. The SDKs wrap these as objects, so most brains never touch the wire directly — but the frame set is the contract, and this is the reference.

The canonical definition is proto/voqalize/frames/frames.proto (package voqalize.frames); the SDK dataclasses mirror it exactly.

Two numeric IDs thread through almost every frame:

  • interaction_id (uint64) — runtime-minted, session-monotonic. One committed user stimulus plus the brain’s whole response. 0 is the sentinel for agent-initiated speech (the greeting); real user interactions start at 1.
  • inference_id (uint64) — brain-minted, per-interaction. One model call. One interaction contains N inferences.

(interaction_id, inference_id) uniquely names any piece of bot output. Both are always numeric on the wire — never a dotted string.

“Direction” below is the logical sender. Fields marked (JSON) travel as a JSON string on the wire and arrive as a dict in the SDK.

FrameDirectionKey fieldsMeaning
VqlStartruntime → brainsession_id, agent_id, payload (JSON), audio_in_sample_rate, audio_out_sample_rateFirst frame of a session. payload is your opaque init data.
VqlUserTextruntime → braininteraction_id, textA committed user utterance; opens an interaction.
VqlUserIdleruntime → braininteraction_id, level, idle_msThe user has been silent past the idle timeout; opens an interaction with no utterance. level escalates (1, 2, 3…) while the silence persists.
VqlInterruptionboth(none)Barge-in / drain barrier. Correlation lives on data frames, not here.
VqlInferenceFinalizedruntime → braininteraction_id, inference_id, heard_text, interrupted, reasonThe text the user actually heard for one inference (post-TTS, truncated on barge-in).
VqlLLMFullResponseStartbrain → runtimeinteraction_id, inference_idStart of one bot inference.
VqlLLMTextbrain → runtimeinteraction_id, inference_id, textOne chunk of bot text; many per inference.
VqlLLMFullResponseEndbrain → runtimeinteraction_id, inference_idEnd of one bot inference.
VqlFunctionCallsStartedbrain → runtime, tool_call_id, function_name, arguments (JSON)The model decided to call a tool.
VqlFunctionCallInProgressbrain → runtimesameMid-flight tool call (drives a UI spinner).
VqlFunctionCallResultbrain → runtime, tool_call_id, function_name, result (JSON)Tool result.
VqlInteractionCompletedbrain → runtimeinteraction_idBrain is done with the whole interaction. Barge-in skips this. Idempotent per interaction.

VqlInferenceFinalized.reason is one of COMPLETED or USER_BARGE_IN. interrupted is true exactly when reason == USER_BARGE_IN.

FrameMeaning
EndGraceful end-of-session. Rides the normal lane, so teardown happens only after queued data drains.
CancelAbrupt cancel; carries a reason.
Errorerror string + fatal bool. The SDK emits this on normal-lane overflow as a drop-newest congestion signal; it is never fatal to the session.

Four frames carry JSON-encoded payloads that reconstruct richer message types. These are how UI commands and mid-call reconfiguration travel — and what the SDK helpers emit under the hood:

FrameDirectionEmitted byPurpose
RTVIServerMessagebrain → browsersession.action / interaction.actionA UI command: { type:"ui_command", action, action_id, ... }.
RTVIClientMessagebrowser → brainclient sendMessageA browser client message; surfaces as on_client_message. Carries msg_id, type, data (JSON) and a runtime-minted interaction_id the brain may spend on a reply.
TTSUpdateSettingsbrain → runtimesession.configure_ttsMid-session TTS reconfigure (next inference).
STTUpdateSettingsbrain → runtimesession.configure_sttMid-session STT reconfigure (live).
IdleUpdateSettingsbrain → runtimesession.configure_idleMid-session idle-detection reconfigure (timeout_ms; 0 disables).

There is no configure_tts / configure_stt / configure_idle frame — those are SDK methods that emit the update-settings frames above.

Messages are binary WebSocket frames only (a text frame is a protocol error). Layout:

  • Single-session leg (inbound / the /s/{session_id} route): [1-byte direction][Envelope protobuf bytes].
  • Multiplexed leg (the Cortex /agent connection): [16-byte session_id][1-byte direction][Envelope protobuf bytes] — Cortex adds the 16-byte prefix.

The direction byte is DOWNSTREAM = 1 (toward the brain / bot output) or UPSTREAM = 2 (back toward the runtime), matching pipecat’s values. The Envelope holds the frame in a body oneof plus a top-level request_id (uint64).

The runtime tags outbound data frames with a monotonically increasing request_id (starting at 1). This enforces strict in-order dispatch across the round-trip:

  • request_id == 0 (or absent) means no ack expected.
  • The receiver never interprets the value — it only echoes it back.
  • Any frame received with request_id > 0 must be acked. After your process_frame returns, the SDK sends an Ack envelope with ack_id == request_id. The sender resolves the pending frame and releases the next one.
  • Lifecycle/system frames (VqlStart, Interruption, End, Cancel) carry no request_id and are not ack-gated. Acks themselves are wire-level and never surface as frames.

The SDKs handle acking for you. You only care about this if you implement the wire yourself.

On session start:

  1. The runtime sends VqlStart (first frame) with session_id, agent_id, and payload.
  2. The SDK calls your on_session_start(session, start), where start.init is the payload.
  3. To greet, you open an agent-initiated speech bracket — session.say() — which runs under the interaction_id = 0 sentinel. Entering emits VqlLLMFullResponseStart(interaction_id=0, inference_id=n); each speak emits VqlLLMText; exiting emits VqlLLMFullResponseEnd. (No VqlInteractionCompleted — that’s for user-opened interactions.)
class Greeter(Brain):
async def on_session_start(self, session, start):
async with session.say() as speech:
await speech.speak("Hi! How can I help?")

The runtime dials {brain_url}/s/{session_id}, one WebSocket per session. It presents a short-lived RS256 JWT (as a bare token or Authorization: Bearer <jwt>), verified against Voqalize’s public key. Required claims: iss="pygato", aud="brain", sub == session_id, and exp; tenant_id / agent_id are informational. See Core concepts → Authentication.

Close-code contract: 4000 = no agent → permanent, never retry; 4001 = agent gone → transient, reconnect with backoff; anything else → transient; 1000 from the runtime → no reconnect.