Build realtime voice agents on one connection
AvA Realtime is a speech-to-speech agent server. Your client opens one WebSocket for control, signaling and streaming text, and one WebRTC peer connection for microphone audio. Everything else — voice activity detection, speech recognition, the agent, tool execution, speech synthesis — runs server-side and streams back to you.
What the server does for you#
Everything here mirrors the deployed guide, available raw at GET /client_guide.
If the two ever disagree, the raw guide wins — it ships with the server.
Playground#
A real client, not a mock. Paste a key, hit connect, and you are talking to the same WebSocket and WebRTC endpoints your app will use. Every toggle takes effect on the live session, and the inspector on the right shows the exact frames going over the wire.
minSpeech if room noise keeps interrupting the agent mid-sentence.update_additional_current_context the moment you stop typing, while connected.Then talk, or type below — both go through the same session.
wss://avarealtime.pathor.ai/. Audio plays through a hidden
<audio> element fed by the remote WebRTC track — the AEC-safe path described in
Audio & echo cancellation. Use headphones if you are on a
speaker, or the agent will hear itself.
Things worth trying#
user_speech_start arrive
with interrupted: true in the inspector while playback cuts.response_id splitmulti_response on, ask for two or three actions at once. The
acknowledgement and the result arrive as separate bubbles with different ids.additional_current_context, then ask a question that
depends on it. No reconnect — it patches live.plugin_enabled and the parked request resumes on its own.image on get_chat_completion.disable_tool on and reconnect. First-token time drops — that is the
validator and tool routing coming out of the hot path.How it fits together#
Two channels, one session. Knowing which channel carries what removes most of the confusion when you start wiring things up.
offer/answer/candidate),
text turns, streaming assistant text, tool calls and results, plugin consent cards, dev logs, and every
acknowledgement. It is the control plane and the transcript at once.You can run a fully functional session without WebRTC at all — send text with
get_chat_completion, or push raw PCM with mic_audio frames. WebRTC is the
low-latency path, not a requirement.
A working voice client in five minutes#
Paste this into a page served over HTTPS (or localhost), drop in a token, and you have a full-duplex voice agent. Everything after this section is detail on the pieces used here.
initial_configToken first. The server rejects a session that never sends one, so send config before anything else.
Send an offer, apply the answer, trade candidate messages. Capture
the mic with echoCancellation: true — this is not optional on speaker setups.
<audio> elementNot through the Web Audio API. The browser echo canceller can only subtract what it knows it is playing.
text-stream, keyed on response_idOne response is a run of chunks ending in end: true. A session can have more than one in
flight — see Concurrent responses.
tool-calls with tool-resultOnly for client-executed function tools. Server-side work reports progress as tool-activity
and needs no reply.
1 · The page#
<!-- The agent speaks through this element. Keep it in the DOM. -->
<audio id="agent-audio" autoplay playsinline></audio>
<div id="transcript"></div>
<button id="talk">Start talking</button>
<script type="module" src="./ava-client.js"></script>
2 · The client#
const WS_URL = "wss://avarealtime.pathor.ai/";
const TOKEN = "<your-jwt>"; // required
const PERSONA = "<your-persona-id>"; // optional, but this is what gives it a voice
const ws = new WebSocket(WS_URL);
const pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
const out = document.getElementById("transcript");
// Assistant bubbles are keyed by response_id, never by "the current turn".
const bubbles = new Map();
function bubbleFor(id) {
let el = bubbles.get(id);
if (!el) {
el = document.createElement("p");
el.dataset.responseId = id;
out.appendChild(el);
bubbles.set(id, el);
}
return el;
}
// ── 1. Session config ─────────────────────────────────────────────────────
ws.onopen = () => {
send({
type: "initial_config",
value: {
curr_user_token: TOKEN,
personaId: PERSONA,
client_source: "web",
modalities: ["audio", "text"],
stt_provider: "microsoft",
lang: "en-IN",
multi_response: true, // opt in to concurrent responses
plugin_search: true, // opt in to JIT plugin discovery
},
});
};
// ── 2. Mic capture + WebRTC offer ─────────────────────────────────────────
async function connectAudio() {
const mic = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // REQUIRED — cancels the voice coming out of the speaker
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
});
mic.getAudioTracks().forEach((t) => pc.addTrack(t, mic));
// ── 3. Agent audio comes back on this track. Play it with a media element. ──
pc.ontrack = (ev) => {
document.getElementById("agent-audio").srcObject = ev.streams[0];
};
pc.onicecandidate = (ev) => {
if (ev.candidate) send({ type: "candidate", candidate: ev.candidate });
};
const offer = await pc.createOffer({ offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
send({ type: "offer", offer: pc.localDescription });
}
document.getElementById("talk").onclick = connectAudio;
// ── 4. Server events ──────────────────────────────────────────────────────
ws.onmessage = async (evt) => {
if (typeof evt.data !== "string") return; // binary frames: audio/image
const msg = JSON.parse(evt.data);
switch (msg.type) {
case "session-created":
console.log("session up:", msg.clientId);
break;
case "session-error":
console.error("auth/init failed:", msg.message);
ws.close();
break;
case "answer":
await pc.setRemoteDescription(msg.answer ?? msg.sdp);
break;
case "candidate":
if (msg.candidate) await pc.addIceCandidate(msg.candidate);
break;
case "text-stream": {
const el = bubbleFor(msg.response_id ?? "legacy");
if (msg.chunk) el.textContent += msg.chunk;
if (msg.end) el.dataset.done = "true"; // this response only, not the turn
break;
}
case "user_speech_start":
// interrupted === true means the agent was mid-sentence: drop queued audio.
if (msg.interrupted) stopLocalPlayback();
break;
case "tool-calls":
send({
type: "tool-result",
toolCallId: msg.tools.id,
result: await runTool(msg.tools),
});
break;
case "server_instruction":
if (msg.action === "show_plugin") renderPluginCard(msg.plugin);
break;
case "rolling_summary":
persistSummary(msg); // hand it back as pre_chat_context next connect
break;
case "turn_suggestions":
renderChips(msg.suggestions);
break;
}
};
// ── 5. Text turns work without any of the audio plumbing ──────────────────
function ask(text) {
send({ type: "get_chat_completion", text, speech: true });
}
function send(obj) { ws.send(JSON.stringify(obj)); }
Skip steps 2 and 3 entirely. Open the socket, send initial_config, then
get_chat_completion. Set speech: false if you do not want audio at all.
The handshake, in order#
initial_config comes first.Where to connect#
| Endpoint | Method | What it returns |
|---|---|---|
wss://avarealtime.pathor.ai/ | WS | The session. Everything in this document happens here. |
/healthz | GET | { healthy: true } — liveness probe. |
/status | GET | Uptime, version, active session count. |
/sessions | GET | { active, sessions } — a summary of live sessions. |
/client_guide | GET | This documentation as raw markdown, straight from the deployed server. |
/docs | GET | This page. |
Tokens and personas#
Every session needs a bearer token. The server validates it against the PathOr persona API before it will set up an agent — there is no anonymous mode.
Accepted field names#
Send these at the top level of initial_config.value, or nested inside agent_data.
Both forms are accepted; aliases exist for backwards compatibility.
| You send | Normalised to | Notes |
|---|---|---|
curr_user_token | curr_user_token | Preferred top-level field. |
api_key | curr_user_token | Alias, accepted for compatibility. |
agent_data.curr_user_token | curr_user_token | Nested form. |
personaId | personaId | Preferred top-level field. |
persona_id | personaId | Snake-case alias. |
agent_data.persona_id | personaId | Nested form. |
What the server does#
| Scenario | Behaviour |
|---|---|
| No token | Sends session-error and closes the socket immediately. |
| Token only | Session authenticates and runs on AvA defaults. No persona fetch. |
| Token + personaId | Fetches the persona profile and inflates system prompt, voice, language, tools and chat history automatically. |
| Invalid / expired token | 401 or 403 upstream → session-error, socket closed. |
Token and persona are enough. Voice, language, system prompt, tools and history all resolve server-side.
{
"type": "initial_config",
"value": {
"curr_user_token": "your-jwt",
"personaId": "your-persona-id"
}
}
If you already hold the encrypted tools_config blob from the dashboard, pass it straight
through. The server uses it as-is once the token validates.
{
"type": "initial_config",
"value": {
"curr_user_token": "your-jwt",
"agent_data": {
"persona_id": "your-persona-id",
"curr_user_token": "your-jwt",
"tools_config": "<encrypted-blob>",
"is_expressive_persona": true
}
}
}
Sent immediately before the socket closes. Do not retry in a tight loop — the token will not become valid.
{
"type": "session-error",
"message": "Authentication failed. Invalid or missing token."
}
Config reference#
The server keeps a per-session userConfig. Send only the fields you care about in
initial_config.value — everything else falls back to a default or to the persona profile.
| Key | Meaning | Values / notes |
|---|---|---|
curr_user_token | Required. Session auth token. | Alias api_key. |
personaId | Persona to load — voice, prompt, tools, history. | Alias persona_id. Omit for AvA defaults. |
client_source | Which client this is. Drives auth, plugin OS resolution, JIT-search default and concurrency default. | Default "web". Known: web, web_agent, ava_android, vidya_android, ava_desktop. |
agent_data | Full persona + tools payload, passed straight through. | Used as-is after the token validates. |
system_prompt | System prompt seed. | Defaults to a friendly AvA persona. |
additional_current_context | Sticky app/user state appended to the instructions — cart, screen, checkout step. | String. Empty string clears it. Capped at 8000 chars. |
modalities | Enabled outputs. | ["text", "audio"] |
lang | STT language. | en-IN, hi-IN, unknown. Normalised to stt_lang. |
stt_provider | Speech recognition backend. | microsoft, sarvam, deepgram, openai_realtime. Send it explicitly — the default is deployment config. |
tts_provider | Speech synthesis backend. | google (Python WS) or cartesia. |
tts_lang | TTS language. | Matches the voice. Defaults to en-IN. |
voice_type | Voice family. | non-custom or custom. custom routes to Cartesia. |
voice_id | Voice selection. | e.g. en-IN-Chirp3-HD-Leda, or a Cartesia voice id. |
tts_style_prompt | Voice direction for Gemini TTS. | e.g. "Speak in a soft, intimate tone". Max 100 bytes, truncated on a word boundary. |
receive_tts_chunks | Send PCM frames as speech_chunk instead of injecting into the WebRTC track. | Default false. Leave it false unless you have an AEC-safe playback path — see Audio. |
use_vad | Run voice activity detection on inbound audio. | true / false |
vad_config | VAD tuning. threshold is Silero speech probability; minSpeechDuration (s) is sustained speech needed to barge in while the agent is speaking (clamped 0.12–2.0); minSilenceDuration (s) re-arms the barge-in latch (clamped 0.2–3.0). | { threshold: 0.75, minSpeechDuration: 0.2, minSilenceDuration: 0.4 } |
noise_supression, echo_cancellation | Server-side DSP on inbound mic audio (RNNoise / NLMS). Not a replacement for device AEC. | bool |
mute_speech | Soft-mute server TTS. | bool |
multi_response | Opt in to concurrent responses (more than one response_id in flight). | bool. Aliases supports_multi_response, multiResponse. Read per turn — toggleable mid-session. |
plugin_search | JIT plugin discovery: marketplace search + connect cards. | bool or "true"/"false"/"on"/"off"/1/0. Default depends on client_source. Accepted on later messages too. |
disable_tool / disable_plugin | Persona + history, but no tools at all — the lowest-latency fast-chat path. Vision still works. | bool. Set once in initial_config. Aliases disable_tools / disable_plugins. |
ephemeral_session | Shared-terminal mode. No history read at connect, no persistence on write. Persona, voice, tools unaffected. | bool. Aliases ephemeral, no_chat_history, disable_chat_persistence. |
game_mode | Isolate the session: persona plugins off, tools restricted to your tools array. | bool, default false. |
tools | Client-supplied custom tools used when game_mode is on. | Array in OpenAI function format. |
start_prompt | Prompt auto-run right after the session is created. | String. Overridden automatically for returning users when pre_chat_context is present. |
pre_chat_context | Prior-session rolling summaries restored on reconnect. | Array of { summary, foundation, turn_count, timestamp }, oldest first. |
notification_context | The notification the user tapped to start this session. | { assistant_message, assistant_name, timestamp } |
use_openai_realtime | Route generation to OpenAI Realtime instead of the internal agent. | bool |
use_groq, groq_model | Groq routing flags. | optional |
max_completion_tokens, temperature | LLM parameters. | numbers |
ambient_intelligence | Enable ambient mode — the engagement gate decides when to speak. | Default false. See Ambient. |
ambient_meeting_context | One line describing the meeting; improves gate accuracy. | e.g. "weekly sales team standup" |
ambient_mode_config | Per-session ambient tuning, merged over server defaults. | { stt_provider, silence_gap_ms, question_gap_ms, context_window_turns, max_tokens } |
ambient_trigger_names | Reserved; currently unused. The gate uses persona self-identity, not keyword lists. | [] |
meeting_participants | Roster for resolving diarization labels to names. | [{ name, designation?, speakerId? }]. Without speakerId, auto-mapped in first-speech order. |
capability_notes | Free-text hints about your client features, injected into the ambient gate prompt. | e.g. "You can present URLs and PDFs as a screen share." |
Fields like stt_provider and the ambient gaps have code defaults that the running
deployment overrides. If your product depends on a specific value, send it rather than
relying on the default.
Speech recognition#
Two fields decide everything: stt_provider and lang. The language
rules differ per provider, and picking the wrong pair is the most common source of "it does not hear me".
| Provider | lang | Why you would pick it |
|---|---|---|
microsoft | Send it explicitly, e.g. en-IN | The code default. Solid general-purpose recognition. |
sarvam | Optional — omit it, or send "unknown", to auto-detect | Indic languages and code-mixed speech. Also runs a turn endpointer that merges a continued utterance into one turn instead of splitting it. |
deepgram | Send it explicitly | The only provider with speaker diarization — this is why ambient and meeting sessions default to it. |
openai_realtime | Send it explicitly | When you want the OpenAI Realtime path end to end. |
Omitting it takes the server-wide DEFAULT_STT_PROVIDER. The code default is
microsoft; the current deployment sets deepgram. That is an operational
setting and it can change under you without any client-visible API change.
Examples#
{ "type": "initial_config", "value": { "stt_provider": "sarvam" } }{ "type": "initial_config", "value": { "stt_provider": "sarvam", "lang": "hi-IN" } }{ "type": "initial_config", "value": { "stt_provider": "microsoft", "lang": "en-IN" } }Updating the STT language also updates the TTS language and voice to match.
{ "type": "update_stt_language", "lang": "en-IN" }
// Legacy alias, still accepted:
{ "type": "update_stt_language", "language": "en-IN" }
// Sarvam only — force auto-detection again:
{ "type": "update_stt_language", "lang": "unknown" }Backwards compatibility: stt_lang is still accepted in initial_config, and
language is still accepted in update_stt_language.
Persona and agent_data#
Sending a personaId is enough for most clients. agent_data is the
escape hatch for when you already hold the resolved payload.
Fields used by persona-aware flows#
curr_user_token,persona_id,persona_value,persona_languagestools_config,main_agent_toolslegacy_tools— see belowis_expressive_persona— enables richer prosodychat_history— seeded into the system prompt
Server-executed built-ins via legacy_tools#
Use these when you want the model provider to run a built-in tool directly, instead of the server emitting
a client-side tool-calls request.
- Supported today:
browser_search,code_interpreter code_executionis accepted on input and normalises tocode_interpreter- Only used on Groq-backed GPT-OSS paths
- Precedence: built-ins are attached only when no normal function tools are active for that turn. If
main_agent_toolsor persona function tools exist,legacy_toolsis ignored for that turn. - Unsupported entries for the selected model are silently dropped
- These never produce
tool-calls. You may receivetool-activityinstead.
{
"type": "initial_config",
"value": {
"use_groq": true,
"groq_model": "openai/gpt-oss-120b",
"agent_data": {
"legacy_tools": ["browser_search", "code_execution"]
}
}
}
A browser-search turn may emit an early tool-activity with phase: "requested" and
a conversational status_text before the answer is ready. That keep-alive phrase is spoken
server-side and is not sent as normal text-stream content.
Session modes#
Four flags change the shape of a session significantly. They compose, but each answers a different question.
disable_toolinitial_config.game_modetools array you supplied. Chat persistence is off.
Built for games and sandboxes.ephemeral_sessionambient_intelligenceDo not get isolation by inventing an off-list client_source. The source also drives auth,
plugin OS resolution and the JIT-search default, so an off-list value buys privacy by making the client
lie about what it is — and it silently starts persisting the day someone corrects it.
exclude_from_chat_history is not a substitute either: it is per-get_chat_completion,
so it covers typed turns only and voice turns still persist.
Chat persistence runs when personaId and a token are set and client_source
is one of web, ava_desktop, ava_android, web_agent —
and the session is neither game_mode nor ephemeral_session.
Client → Server messages#
Everything is JSON unless noted. snapshot and mic_audio also accept a
binary frame — a JSON header line, a newline, then the raw bytes — which avoids base64 overhead.
Send once, right after the socket opens. curr_user_token (or api_key) is
required — the server rejects a session without it. Everything else is optional; see the
config reference for the full field list.
{
"type": "initial_config",
"value": {
"curr_user_token": "your-jwt",
"personaId": "your-persona-id",
"client_source": "web",
"modalities": ["audio", "text"],
"stt_provider": "microsoft",
"lang": "en-IN"
}
}Standard signaling over the same socket. The client offers, the server answers, and both sides trickle ICE candidates.
{ "type": "offer", "offer": { "type": "offer", "sdp": "..." } }
{ "type": "candidate", "candidate": { "candidate": "...", "sdpMid": "0", "sdpMLineIndex": 0 } }If the peer connection fails, renegotiate by sending a fresh offer.
{
"type": "get_chat_completion",
"text": "Can I apply available coupons?",
"speech": true,
"image": "<base64-jpeg>",
"additional_current_context": "cart_total=1999; coupon_screen=true",
"exclude_from_chat_history": false,
"plugin_search": true
}speech— synthesize the reply as audio as well as text.image— optional base64 JPEG (no data-URI prefix); becomes the snapshot for this turn.additional_current_context— optional; replaces the sticky session context going forward.exclude_from_chat_history— optional, defaultfalse. When true, neither the user message nor the reply is saved. Respond-and-forget.plugin_search— optional; flips JIT plugin discovery for this turn and every turn after it.
The canonical way to tell the agent what is on screen right now — cart contents, selected item,
checkout step, permission state. Replace semantics, not merge: a new value
overwrites the old one. Send "" to clear.
{
"type": "update_additional_current_context",
"additional_current_context": "cart_total=1999; currency=INR; selected_sku=XYZ789"
}
// Also accepted:
{ "type": "additional_current_context", "value": "..." }Capped at MAX_ADDITIONAL_CURRENT_CONTEXT_CHARS (default 8000). Over-cap values are
truncated at a word boundary and logged as additional_current_context_truncated.
{
"type": "update_user_details",
"user_details": {
"latitude": "18.8047601",
"longitude": "73.3204305",
"location": "Pune",
"devicePermissions": { "approved": ["location"], "not_approved": [] }
}
}
// Also accepted:
{ "type": "user_details", "value": { ... } }Merges into what initial_config sent — send only what changed.
null, undefined and "" are ignored, so a partial patch never
blanks a field the server already knows.
Coordinates are the reason this message exists. Location-aware plugins (maps, nearby search,
weather) take latitude/longitude parameters, and the agent can only fill
them from what the client has pushed. Without them it will honestly report that it cannot get the
location. Send it whenever the device location changes or a permission is granted —
initial_config is a one-shot snapshot taken at connect.
{ "type": "update_stt_language", "lang": "en-IN" }
{ "type": "update_stt_language", "language": "en-IN" } // legacy alias
{ "type": "update_stt_language", "lang": "unknown" } // Sarvam: back to auto-detectThis also updates the TTS language and voice to match.
{ "type": "tool-result", "toolCallId": "call_abc123", "result": { "ok": true }, "isDirect": false }Only for explicit client-executed function tools delivered through tool-calls.
Server-side built-ins configured via agent_data.legacy_tools never use this path —
they surface as tool-activity instead.
{ "type": "plugin_enabled", "plugin": { "_id": "6612...", "title": "Spotify" } }Sent when the user flips the toggle on a card the server offered via
server_instruction → show_plugin. The server connects the plugin and then
resumes the request it parked — no re-prompt, no second confirmation, on every
client and modality.
A consent card with no plugin_enabled wired to it is a dead
end. The user taps, nothing happens, and the parked request expires silently.
{ "type": "refresh_plugins" }
{ "type": "refresh_plugins", "plugins": [ /* push a specific set */ ] }Conversation context is preserved across the reload. Use it after the user connects or disconnects a plugin in your own UI.
A session started with disable_tool: true replies
{ toolsCount: 0, toolsDisabled: true } and loads nothing — intentional, and it lasts
the whole session.
Binary framing is strongly preferred — a JSON header line, a newline, then the raw JPEG bytes.
{"type":"snapshot","contentType":"image/jpeg"}\n<jpeg-bytes>The server holds it as the current snapshot and uses it for the next LLM call. You can also pass
image inline on get_chat_completion.
// JSON + base64
{ "type": "mic_audio", "data": "<base64 pcm s16le 48k mono>" }
// Binary frame (preferred)
{"type":"mic_audio","format":"s16le","sampleRate":48000,"channels":1}\n<pcm-bytes>WebRTC is the low-latency path. Use this when you genuinely cannot establish a peer connection.
{ "type": "start_video_chat", "prompt": "You are looking through the user's camera at a whiteboard." }
{ "type": "end_video_chat" }Turns visual reasoning on for the whole session rather than one turn. The optional
prompt is client-supplied framing that takes precedence over the server default.
end_video_chat clears the snapshot state and resets the system prompt.
{ "type": "update_sys_prompt", "prompt": "You are assisting with a live product demo." }This shares a handler with start_video_chat, so it also
turns visual chat on. If you want to add context without enabling vision, use
additional_current_context instead.
Cancels the ongoing LLM generation and TTS playback. In ambient mode it additionally cancels a
pending silence-gap timer and any queued SPEAK_DEFERRED generation.
{ "type": "stop_speech" }{ "type": "toggle_mute_speech", "mute": true }Clears the conversation array the agent is reasoning over. The session, the persona and the connection all stay up.
{ "type": "ping", "client_ts": 1718000000000 }client_ts is optional; the server echoes it in pong so you can measure
round-trip latency.
Subscribe during integration and turn it off in production. Logs arrive as
dev_log messages.
Optional — simply closing the socket also works; the server cleans up either
way. Sending it gets you an ack_disconnect first.
{ "type": "dashboard_init", "weather": "28°C, light rain" }weather is optional. The server replies once with
dashboard_init_response; duplicate requests while one is in flight are ignored.
No-op when ambient mode is not active. Join and leave events are recorded in the ambient transcript, so the engagement gate knows who is in the room.
{ "type": "update_meeting_participants", "action": "join",
"participant": { "name": "Ravi Sharma", "designation": "CTO", "speakerId": "speaker_0" } }
{ "type": "update_meeting_participants", "action": "leave", "speakerId": "speaker_1" }
{ "type": "update_meeting_participants", "action": "sync", "participants": [ /* full replacement */ ] }designation and speakerId are optional. Entries without a
speakerId are auto-mapped in first-speech order.
{ "type": "speaker_context", "speaker_name": "Ravi Sharma", "speaker_identity": "user_8821" }Meeting clients receive a single mixed audio track, so STT cannot diarize it.
This message bridges that gap: it tells the server who is currently speaking so the ambient
transcript and the LLM context carry the right name. Send it on every active-speaker change —
for example from the LiveKit ActiveSpeakersChanged event.
speaker_identity is optional.
{ "type": "presentation_started", ... } // a presentation has begun
{ "type": "presentation_ready", ... } // rendered and ready → ack_presentation_ready
{ "type": "presentation_page_changed", ... } // slide navigation
{ "type": "presentation_ended" }Server → Client messages#
Handle the ones your product needs and ignore the rest — unknown types are safe to skip. The
four you almost certainly need are session-created, text-stream,
user_speech_start and tool-calls.
{ "type": "session-created", "clientId": "...", "auth": true, "message": "..." }{ "type": "session-error", "message": "Authentication failed. Invalid or missing token." }Close and surface the problem. Retrying with the same credentials will not help.
Sent when the server fetched a persona with your credentials. Useful for rendering the agent name, avatar and connected-tool list in your UI.
{ "type": "text-stream", "chunk": "On it — ", "response_id": "resp_7" }
{ "type": "text-stream", "chunk": "give me a second.", "response_id": "resp_7" }
{ "type": "text-stream", "end": true, "text": "On it — give me a second.",
"finish_reason": "stop", "response_id": "resp_7" }Key your assistant bubble on response_id, not on the turn.
end: true terminates that response, not the conversation turn — and a
session can have more than one response in flight. Full rules in
Concurrent responses.
Use these to drive a speaking indicator or waveform.
speech_end marks the end or flush of playback. In ambient mode a queued
SPEAK_DEFERRED generation fires right after speech_end.
interrupted flag.
{ "type": "user_speech_start", "reason": "silero_vad", "interrupted": true }reason is "silero_vad", "stt_interim" or "unknown".
interrupted: true— the agent was actively playing TTS when the barge-in fired. Stop playback and discard your audio queue.interrupted: false— the agent was silent (the user spoke before the first reply, or afterspeech_end). Update your listening UI, but do not discard a pending or queued response.
{ "type": "user_speech_end", "text": "play something by Ali Sethi" }Emitted when STT produces an accepted final. Pairs with user_speech_start for each
complete utterance — render it as the user bubble.
{ "type": "speech_chunk", "encoding": "base64", "data": "..." }Only sent when receive_tts_chunks: true. Frames are 24 kHz mono 16-bit.
Audio you play yourself is invisible to the platform echo canceller — read
Audio & echo cancellation before turning this on.
{ "type": "tool-calls", "tools": { "id": "call_abc123", "function": { "name": "...", "arguments": "{...}" } } }Execute it client-side and answer with tool-result carrying the same
toolCallId. The server resumes the turn and keeps streaming.
{ "type": "tool-activity", "tool": "...", "tools": [...], "provider": "ava_live_tools",
"path": "ava_legacy_tool_fast", "phase": "requested",
"status_text": "Let me look that up…", "response_text": "...",
"evidence": [...], "citation_count": 3 }Reports work the server or provider performs internally — such as browser search.
phase is "requested" when dispatched and "completed" when
usage is confirmed. Client-facing labels use Ava naming
(provider: "ava_live_tools").
Treat it as telemetry, not a request. Unlike tool-calls, it never
needs a reply.
{
"type": "server_instruction",
"action": "show_plugin",
"plugin": {
"_id": "6612...", "title": "Spotify", "name": "spotify", "logo": "https://...",
"human_description": "Play music and control playback",
"creator": "PathOr", "authentication_required": true,
"status": "not_connected", "os": ["android"], "isAuthenticated": false
}
}- Sent whenever the agent offers a plugin the user has not connected. The assistant text carries
a matching
[action: show_plugin | actionValue: <_id>]tag — render the card in place of the tag. If the instruction arrives with no tag in the transcript, inject a standalone card. - The toggle must send
plugin_enabled. The server has the original request parked behind it and resumes the moment it arrives. osis present only when the plugin declares specific platforms; absent or empty means it runs everywhere. Disable the toggle when the list excludes this client rather than letting the tap fail.isAuthenticatedappears only when applicable.authentication_required && !isAuthenticatedmeans the user must sign in first.status: "connected"means already connected — show connected state, never a fresh connect prompt.
{ "type": "rolling_summary", "summary": "...", "foundation": "...",
"turn_count": 8, "timestamp": 1718000000000 }Store it keyed by persona id, keep the newest three, and send them back as
pre_chat_context on the next connect. See Cross-session memory.
{ "type": "turn_suggestions", "suggestions": ["Summarize that", "Send it to Priya", "What did I miss?"] }Generated in the background after a persisted assistant turn, always exactly three. Not emitted for a detached plan acknowledgement — suggestions derived from "on it, I'll let you know" would be nonsense, and the real ones arrive with the plan result a moment later. Generation failures are silent: no message, no error.
{ "type": "dashboard_init_response",
"thought_of_the_day": "Keep your face to the sun ☀️.",
"suggestions": ["Summarize my day.", "Teach me basics of python.", "Lets play some game"],
"error": "optional — present only when generation failed" }On failure the server still sends usable fallback content plus error.
Render the payload either way rather than branching on error.
{ "type": "refresh_plugins_success", "status": "success",
"message": "Plugins refreshed successfully", "toolsCount": 12 }
{ "type": "refresh_plugins_success", "toolsCount": 0, "toolsDisabled": true } // disable_tool session
{ "type": "refresh_plugins_error", "status": "error", "message": "Failed to refresh plugins: ..." }On error the plugin set is unchanged — the session keeps the tools it already had.
{ "type": "speaker_identified", "speakerId": "speaker_0", "name": "Ravi Sharma", "designation": "CTO" }Ambient mode only. Emitted when a new diarization label is auto-mapped to the next unassigned
roster entry, in appearance order. Use it to confirm or correct the mapping in a meeting UI — it
sticks for the rest of the session. designation may be null.
{ "type": "dev_log", "message": "...", "log_level": "info" }{ "type": "ack_disconnect" }
{ "type": "ack_mute_speech" }
{ "type": "ack_additional_current_context", "status": "ok", "has_context": true, "length": 47 }
{ "type": "ack_user_details", "status": "ok", "fields": ["latitude", "longitude"], "has_location": true }
{ "type": "ack_presentation_ready", "status": "ok" }
{ "type": "pong", "client_ts": 1718000000000, "server_ts": 1718000000123 }ack_user_details is worth reading: has_location tells you whether the
agent can now answer location questions.
Server-initiated close after the configured idle period. Reconnect with the
same pre_chat_context and the conversation picks up where it left off.
Concurrent responses#
The server now runs multi-step work while the conversation continues. The user can keep talking, independent actions run together, and the agent reports back when it finishes. That breaks one assumption almost every client makes: one turn, one response.
Adoption checklist#
In the order worth doing them. Nothing breaks if you do none of it — concurrency is enabled
per client_source, so an unadopted client keeps getting exactly one response per turn.
| # | What | Skip it and… |
|---|---|---|
| 1 | Key chat bubbles on response_id | The task result overwrites the acknowledgement, or never appears. This is the hard prerequisite. |
| 2 | Opt in with multi_response: true | You stay on the old one-response-per-turn path. Safe, just not concurrent. |
| 3 | Send back a plugin artifact, every time | The agent honestly reports actions it cannot confirm, even though your UI shows them done. See Returning artifacts. |
| 4 | Render "waiting on you" as its own step state | The user watches a spinner for a step that is waiting on them. |
What response_id means#
Every text-stream frame carries response_id (e.g. "resp_3"),
monotonic per session. A response is one unit of assistant output: a stream of chunk frames
terminated by exactly one end: true.
Until now a turn produced exactly one response, so clients could safely treat "the turn is over" and "this reply is over" as the same event. That equivalence is gone. A session can have several responses in flight: an acknowledgement, anything said while the work runs, then the result of the work itself.
One request — "play a song, take a selfie, text Sneha" — now looks like this:
resp_7 "On it — I'll let you know as soon as it is done." end:true ← the turn closes here
resp_8 "Kya kar rahi ho?" → "Bas isi pe lagi hoon!" end:true ← user talked mid-task
resp_9 "Song chal raha hai, selfie bhi le li — Sneha ko end:true ← the task, reporting back
message bhej diya hai, delivery confirm nahi kar
paayi."
resp_9 arrives with no user message before it. That is rule 3 below, and it is
the whole point of the feature.
The five client rules#
response_idChunks with a new id open a new bubble. Do not append them to the bubble you were streaming into.
end: true finalizes only that bubbleNever use it to clear global "the assistant is replying" state — another response may still be streaming.
A task finishing, a proactive update. Render it as a normal assistant bubble; do not require a pending user turn.
response_idsWithin one id, chunks are ordered. Across ids, they are not.
finish_reason stays per-responseIt describes the response that just ended, not the turn.
Opting in#
Implement the five rules, then either ask for your client_source to be added server-side
(DETACHED_PLAN_EXECUTION_SOURCES) or send the flag yourself. It is read per turn, so it
can be toggled mid-session and does not have to live in initial_config.
{ "multi_response": true }
multi_response: false opts back out at any time. supports_multi_response and
multiResponse are accepted spellings.
| Client | Status |
|---|---|
ava_android | Adopted and verified live. On by default server-side — no flag needed. |
ava_desktop | Adopted and verified live. On by default server-side — no flag needed. |
vidya_android, web, web_agent | Pending. Send multi_response: true once you have implemented the rules. |
Ask for something with two or three device actions in it. You should see the acknowledgement bubble finalize, be able to type or speak while the work runs and get an answer, and then see a separate final bubble with the outcome.
Failure modes to look for: the result overwriting the acknowledgement (rule 1), the result never
appearing (rule 2 — you cleared global replying state on the first end), or the result
being dropped for having no user message (rule 3).
Rendering a running plan#
With multi_response on, a multi-step request unfolds over several responses — and a step has
more states than "running" and "finished".
| State | What it means | What the user needs to see |
|---|---|---|
executing | The step is running. | Progress. |
done | Confirmed, or dispatched with its artifact returned. | Success. |
| waiting on you | The step asked a question and cannot proceed until it is answered. | The question, prominently. |
failed | It did not happen. | Plainly, without softening. |
It is not a slower kind of executing — nothing will happen until the user replies. Showing it as "Executing" is actively misleading. If your task card has two states, this is the one to add.
The question itself arrives as a normal assistant response with its own response_id, carrying
the options inline. If the user says something else in the meantime, the server re-asks
rather than dropping it — so expect the same question to legitimately appear more than once. Key any
de-duplication on response_id, not on message text.
Steps whose dependency failed are reported as not done, never silently omitted. If your UI hides them, the user reads "1 of 4 succeeded" as an unqualified success.
Tools and plugins#
Three different things can execute work on a turn, and they behave differently on the wire. Knowing which one you are looking at removes most tool-integration confusion.
tool-calls, you run the function, you answer with
tool-result. This is the path for anything that lives on the device.agent_data.legacy_tools. The provider runs them.
You get tool-activity status and nothing to execute.Client-executed tool flow#
Through the persona fetch, or by passing agent_data.tools_config yourself.
tool-callsExecute the described function locally.
tool-resultSame toolCallId, structured result.
Text and speech keep streaming from where they left off.
JIT plugin discovery (plugin_search)#
When a turn needs a capability none of the session's connected tools cover, the server can semantically search the plugin marketplace and either run a plugin the user already enabled, or return a "connect this plugin?" consent card. This behaviour is per client source.
client_source | JIT plugin search default |
|---|---|
ava_android, vidya_android, ava_desktop | Enabled |
web, web_agent, anything else or omitted | Disabled |
Any client can override the default explicitly:
{ "type": "initial_config", "value": { "client_source": "web", "plugin_search": true } }
// Toggle mid-session — on any later message, no reconnect needed:
{ "type": "get_chat_completion", "text": "book me a cab", "speech": true, "plugin_search": true }
- Accepts
true/false,"true"/"false","on"/"off",1/0. - Aliases:
pluginSearch,plugin_search_enabled,jit_plugin_search,enable_plugin_search. - The value is sticky until the client sends a different one, and applies from the next turn.
When disabled, the session is limited to its connected tools: no marketplace search, no consent cards, no
JIT recovery after a tool failure — the agent answers honestly that it cannot do it. Explicit plugin
management ("enable the Spotify plugin") still works either way, and disable_tool overrides
everything.
The consent handshake#
plugin_enabled is what releases it.Isolated game mode with custom tools#
Keep the voice and personality, replace the toolset. Useful for games and sandboxes where persona plugins (messaging, system apps) would be wrong.
{
"type": "initial_config",
"value": {
"curr_user_token": "your-jwt",
"personaId": "your-persona-id",
"game_mode": true,
"tools": [
{
"type": "function",
"function": {
"name": "check_game_item",
"description": "Evaluate if the physical item shown in the user's camera snapshot matches the target.",
"parameters": {
"type": "object",
"properties": {
"item_detected": { "type": "string" },
"closeness": { "type": "string", "enum": ["cold", "warm", "hot", "correct"] },
"verbal_hint": { "type": "string" }
},
"required": ["item_detected", "closeness", "verbal_hint"]
}
}
}
]
}
}
The server loads the persona voice, prompt guidelines and details, then restricts the active tools to
exactly your list. Execution uses the standard tool-calls → tool-result flow.
Returning a plugin artifact#
Read this if any of your plugins produce something — a photo, a scan, a file, a screenshot. Posting it back into the conversation is supported and expected. But the artifact has to reach the server.
Why it matters#
A device action the server dispatches but cannot verify is recorded as
dispatched_unconfirmed: "I sent it to your device, I can't confirm it worked." That is
deliberate — the server refuses to claim an outcome it has no evidence for.
The artifact is the evidence. When it arrives, that action resolves to
confirmed, and every downstream surface changes: the closing summary says it is done, a later
"did that go through?" answers correctly, and any reply made while the work runs stops hedging.
The assistant says "I couldn't confirm the selfie" while the photo is sitting right there in your chat window. That means the photo was rendered locally and never sent to the server. It is not a bug in the agent.
The contract#
- Attach the artifact to the message you post back. For an image, send
imageas base64 (no data-URI prefix) on the sameget_chat_completionpayload that carries the caption. A binary frame works too. - Send it every time, not only when convenient. Intermittent delivery is worse than none: the assistant confirms on some runs and hedges on others, for reasons the user cannot see.
- The caption alone is not evidence. A message reading "Captured the moment" with no attachment tells the server that a user spoke, not that a capture succeeded.
- Late is fine. The server resolves the action whenever the artifact lands — a different turn, several seconds later, any route. It does not need to share a frame with the caption, and the server will not block waiting for it.
How to check#
Ask for something that produces an artifact. In the server log you want:
dispatch_confirmed_by_inbound_artifact source: "chat_message"
If that line is absent, the artifact did not reach the server, and the assistant will correctly report the action as unconfirmed no matter what your UI is showing.
Vision#
The default chat model is vision-capable, so image turns need no special configuration — just get the pixels to the server.
snapshot as a binary frame just before the turn, or pass
image as base64 on get_chat_completion. The server holds it as the current
snapshot for the next LLM call.start_video_chat and every turn reasons over the latest frame you
pushed. Optional prompt gives the model framing — what it is looking at and why.
end_video_chat clears it.Text plus vision to speech#
initial_configInclude modalities: ["audio", "text"] if you want a spoken answer.
snapshot binary frame first, or image inline on the next message.
get_chat_completion with speech: truetext-stream, ai_speech_start, speech_endPrefer binary framing for images. A base64 JPEG costs roughly a third more bytes on a link that is also carrying realtime audio.
Audio and echo cancellation#
The agent's voice comes out of the speaker and back into the mic. The only place that loop can be cancelled properly is on the device, where the OS knows exactly what is being played. This is a client requirement, not a nice-to-have.
1. Capture the mic with echoCancellation: true.
2. Play the agent's audio from the remote WebRTC track through a media
element — never through the Web Audio API.
The server adds echo-resistant barge-in gating and self-echo transcript filtering as a safety net, but without device AEC a speakerphone session degrades: delayed barge-in commits, occasionally swallowed first words. This is the same contract Gemini Live and OpenAI Realtime rely on.
// 1. Capture the mic WITH processing enabled — do not disable these.
const mic = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // REQUIRED — cancels the voice coming back from the speaker
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
});
pc.addTrack(mic.getAudioTracks()[0], mic);
// 2. Play the agent's audio via the remote WebRTC track and a media element.
// Do NOT decode/play agent audio through the Web Audio API
// (AudioContext / AudioWorklet) — Chromium's AEC cannot see that path
// and will not cancel it.
pc.ontrack = (ev) => {
const audioEl = document.getElementById("agent-audio"); // <audio autoplay>
audioEl.srcObject = ev.streams[0];
};Record with MediaRecorder.AudioSource.VOICE_COMMUNICATION (the platform AEC path), or attach
AcousticEchoCanceler to the AudioRecord session. Play agent audio through the
same audio session — STREAM_VOICE_CALL or the WebRTC audio device module.
Use AVAudioSession category .playAndRecord with mode .voiceChat,
which enables VoiceProcessingIO AEC, and keep agent playback inside that session.
Rules of thumb#
- Platform AEC only cancels audio it knows it is playing. Any playback path that bypasses the platform voice pipeline — raw PCM through your own audio graph — escapes cancellation.
- Headphones eliminate the loop entirely. Speakerphone is the worst case — test there.
- If you must use
receive_tts_chunks: truein a browser, route your playback through a loopbackRTCPeerConnectionand a media element so the AEC gets a reference signal.
Server-side DSP is a safety net, not a substitute#
noise_supression and echo_cancellation in the config toggle server-side RNNoise
denoise and an NLMS echo filter on inbound mic audio. They help, but they run where the speaker signal is
not known. Real AEC has to run on the device.
Barge-in and VAD tuning#
vad_config field | What it controls | Range |
|---|---|---|
threshold | Silero speech probability required to count as speech. | e.g. 0.75 |
minSpeechDuration | Sustained speech (seconds) needed to trigger barge-in while the agent is speaking. Raise it if the agent is interrupted by coughs and room noise. | clamped 0.12 – 2.0 |
minSilenceDuration | Silence (seconds) that re-arms the barge-in latch. | clamped 0.2 – 3.0 |
{ "vad_config": { "threshold": 0.75, "minSpeechDuration": 0.2, "minSilenceDuration": 0.4 } }
Formats#
- mic_audio
- PCM
s16le, 48 kHz, mono - speech_chunk
- PCM 24 kHz, mono, 16-bit, base64
- WebRTC downlink
- Server resamples TTS to 48 kHz before injecting it into the track
Ambient conversational intelligence#
Ambient mode changes the agent from reactive — it responds when addressed — to ambient: it attends the whole conversation and autonomously decides when, or whether, to speak.
How a turn is decided#
In normal mode the server responds to every finalized STT utterance. In ambient mode each utterance passes through a fast engagement gate before any generation happens.
A local classifier runs before the LLM gate and drops passive acknowledgments ("hmm", "ok", "right", "haan") and continuation signals ("go on", "please continue"). While the agent is speaking these are dropped immediately, so filler sounds never interrupt it.
A rolling window of the last utterances. With Deepgram diarization active, utterances are labelled
[speaker_0], [speaker_1] and resolved to real names when a
meeting_participants roster exists. Agent responses are recorded as [ai].
A sub-300 ms Groq call evaluates the window using the agent's own persona system prompt as its identity — self-identity, no keyword lists — and returns one of four decisions.
If new speech arrives while a SPEAK gap is pending, the timer is cancelled. The agent will not cut in mid-sentence.
| Decision | Behaviour |
|---|---|
WAIT | Stays silent. No generation is triggered at all. |
SPEAK | Waits for the silence gap, then speaks. A shorter gap is used when urgency ≥ 7. |
SPEAK_DEFERRED | Wants to speak but is mid-TTS. The generation is queued and fires after the current speech_end, so it never talks over itself. |
INTERRUPT | Fires immediately with a softening prefix. Requires urgency ≥ 9 while already speaking. |
The gate is WAIT-biased by default. It derives its identity — name, domain, role — from the
persona system prompt and decides autonomously. It speaks on a clear signal: a direct name mention, an
unanswered open question in the window, or a high-urgency domain-relevant contribution such as a correction
or a critical risk. Below urgency 4, even a SPEAK decision is suppressed.
Enabling it#
{
"type": "initial_config",
"value": {
"modalities": ["audio", "text"],
"ambient_intelligence": true,
"ambient_meeting_context": "customer support call",
"ambient_mode_config": {
"stt_provider": "deepgram",
"silence_gap_ms": 300,
"question_gap_ms": 100,
"context_window_turns": 6,
"max_tokens": 120
},
"meeting_participants": [
{ "name": "Ravi Sharma", "designation": "CTO", "speakerId": "speaker_0" },
{ "name": "Priya Nair", "designation": "PM" }
],
"capability_notes": "You can present URLs and PDFs as a screen share."
}
}
| Field | Required | Description |
|---|---|---|
ambient_intelligence | Yes | true activates it. false (default) is normal reactive mode. |
ambient_meeting_context | No | One line describing the meeting type. Improves gate accuracy for domain matching. |
ambient_mode_config | No | Per-session tuning, merged over server defaults. Sub-fields below. |
meeting_participants | No | Roster for diarization label resolution. speakerId optional — entries without one are auto-mapped in first-speech order. |
capability_notes | No | Free-text hints about client features the gate should consider when deciding to speak. |
ambient_trigger_names | No | Reserved; currently unused. The gate uses persona self-identity, not name matching. |
ambient_mode_config sub-fields
| Sub-field | Description | Env var | Code default | Deployed |
|---|---|---|---|---|
stt_provider | STT for ambient mode — deepgram is the one that diarizes. | — | deepgram | deepgram |
silence_gap_ms | Silence pause before a SPEAK decision fires. | AMBIENT_SILENCE_GAP_MS | 900 | 400 |
question_gap_ms | Shorter gap used when urgency ≥ 7. | AMBIENT_QUESTION_GAP_MS | 450 | 150 |
context_window_turns | Rolling transcript window fed to the gate. | AMBIENT_CONTEXT_WINDOW_TURNS | 15 | 8 |
max_tokens | Max output tokens for ambient generation. 0 or omitted uses the session default. | AMBIENT_MAX_TOKENS | 0 | 150 |
The last two columns differ on purpose — the deployment is tuned tighter than the code defaults for latency.
Neither is a contract. If your experience depends on a specific gap, send
ambient_mode_config and pin it per session.
What changes for your client#
| Aspect | Normal mode | Ambient mode |
|---|---|---|
| Response rate | Every STT final generates. | Only on SPEAK, SPEAK_DEFERRED or INTERRUPT. |
| Response timing | Immediate after the STT final. | SPEAK: after the silence gap. SPEAK_DEFERRED: after the current speech_end. INTERRUPT: immediate, with a prefix. |
text-stream / speech | Always emitted. | Only on decided turns. |
ai_speech_start | Always. | Only on decided turns. |
stop_speech | Cancels current TTS. | Also cancels a pending gap timer and any queued SPEAK_DEFERRED. |
No new message types are needed. The types you already handle —
text-stream, ai_speech_start, speech_end,
tool-calls, stop_speech, interrupt_operation — work exactly the
same in ambient mode.
Speaker diarization#
With stt_provider: "deepgram", labels (speaker_0, speaker_1, …) are
assigned sequentially by first-speech appearance and resolved to names through the
meeting_participants roster. Each auto-mapping emits a speaker_identified event so
a meeting UI can confirm or correct it. Update the roster mid-session with
update_meeting_participants, without reconnecting.
If your client receives a mixed audio track — one blended stream for the whole room —
diarization cannot work at all. Send speaker_context on every active-speaker change instead.
The interruption prefix#
On an INTERRUPT decision the spoken response is prefixed with a randomised softening phrase —
"Actually, let me add something here — ", "Hold on, that's worth clarifying — ". It
appears at the start of the text-stream content, so you can display it in a transcript.
Server-side tuning#
| Variable | Code default | Description |
|---|---|---|
AMBIENT_SILENCE_GAP_MS | 900 | Silence gap before SPEAK fires. Deployed: 400. |
AMBIENT_QUESTION_GAP_MS | 450 | Shorter gap for urgent/question signals. Deployed: 150. |
AMBIENT_MIN_URGENCY_TO_SPEAK | 4 | Urgency (1–10) below which SPEAK is suppressed. |
AMBIENT_INTERRUPT_ENABLED | true | Set false to disable INTERRUPT server-wide. |
AMBIENT_CONTEXT_WINDOW_TURNS | 15 | Recent utterances kept for gate context. Deployed: 8. |
AMBIENT_ENGAGEMENT_MODEL | openai/gpt-oss-20b | Groq model used for the gate. |
Cross-session memory#
The server condenses each conversation into a rolling summary and streams it to you. Hand it back on the next connect and a returning user gets a greeting that references their last session — not a generic "Hello, how can I assist?"
After every N new transcript messages the conversation is condensed into a compact summary (≤ 512 tokens). It grows with the session and is continuously refreshed.
rolling_summary and persist itStore it keyed by persona id — localStorage is fine for web. Keep the newest three, FIFO.
pre_chat_contextThe server injects it as a SESSION MEMORY block in the system prompt and overrides the
opening start_prompt so the greeting is contextual.
The rolling_summary event#
{
"type": "rolling_summary",
"summary": "User is Priya Nair, mobile app PM. Discussed feature prioritisation for Q3 sprint — delay widget + dark mode. Prefers bullet points. Next step: share mockup.",
"foundation": "Priya Nair is a mobile app PM focused on Q3 sprint planning.",
"turn_count": 8,
"timestamp": 1718000000000
}
| Field | Description |
|---|---|
summary | The full rolling summary, up to roughly 500 tokens. |
foundation | Condensed core facts — name, role, preferences. Kept stable across repeated summarisation cycles. |
turn_count | Number of turns this summary covers. |
timestamp | Unix epoch ms when it was generated. |
Sending it back as pre_chat_context#
{
"type": "initial_config",
"value": {
"modalities": ["audio", "text"],
"stt_provider": "microsoft",
"lang": "en-IN",
"agent_data": { "persona_id": "my-persona-id", "curr_user_token": "user-jwt" },
"pre_chat_context": [
{
"summary": "User is Priya Nair, mobile app PM. Discussed feature prioritisation for Q3 sprint...",
"foundation": "Priya Nair is a mobile app PM focused on Q3 sprint planning.",
"turn_count": 8,
"timestamp": 1718000000000
}
]
}
}
The array is ordered oldest first. The server merges every entry into one
SESSION MEMORY block and injects it into every LLM system prompt for the session. Where session
memory conflicts with live in-session messages, the live messages win.
A working localStorage pattern#
const MAX_ENTRIES = 3;
const keyFor = (personaId) => `ava_summaries_${personaId}`;
// Save whenever a rolling_summary arrives
ws.addEventListener("message", (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type !== "rolling_summary") return;
const entries = JSON.parse(localStorage.getItem(keyFor(personaId)) || "[]");
entries.push({
summary: msg.summary,
foundation: msg.foundation,
turn_count: msg.turn_count,
timestamp: msg.timestamp,
});
if (entries.length > MAX_ENTRIES) entries.shift(); // keep the newest three
localStorage.setItem(keyFor(personaId), JSON.stringify(entries));
});
// Read on the next connect
function buildInitialConfig(personaId, token) {
const stored = JSON.parse(localStorage.getItem(keyFor(personaId)) || "[]");
return {
type: "initial_config",
value: {
modalities: ["audio", "text"],
stt_provider: "microsoft",
lang: "en-IN",
agent_data: { persona_id: personaId, curr_user_token: token },
...(stored.length > 0 ? { pre_chat_context: stored } : {}),
},
};
}
Starting from a notification#
When the conversation starts because the user tapped a simulated notification, pass what they tapped. The
server appends a NOTIFICATION ENTRY POINT instruction to SESSION MEMORY and
directs the assistant to continue contextually from that message.
{
"type": "initial_config",
"value": {
"agent_data": { "persona_id": "my-persona-id", "curr_user_token": "user-jwt" },
"notification_context": {
"assistant_message": "yoo supp rohan, remember we talked about burger yesterday, r u there?",
"assistant_name": "sam",
"timestamp": 1718000000000
}
}
}
The returning-user greeting#
When pre_chat_context or notification_context is present, the server replaces the
default start_prompt with a context-aware instruction:
"A returning user has reconnected. Their previous session context is in SESSION MEMORY above — read it before responding. Greet them warmly by name if their name is mentioned in the context. Reference something specific and relevant from prior conversations naturally. Do NOT say this is their first call. Do NOT introduce yourself from scratch."
With notification_context it also appends an instruction to continue from the notification
message. This is what stops the model falling back to a generic greeting even when the configured
start_prompt would normally trigger a fresh introduction.
Server-side tuning#
| Variable | Code default | Deployed | Description |
|---|---|---|---|
ROLLING_SUMMARY_TRIGGER_MESSAGES | 9 | 6 | New transcript messages before the summary is regenerated. |
ROLLING_SUMMARY_KEEP_LAST_MESSAGES | 9 | 4 | Most recent messages kept verbatim after compaction. |
SUMMARY_AGENT_MAX_TOKENS | 512 | 512 | Max tokens for the summariser response. |
Do not build a client that assumes a fixed cadence. Persist whatever rolling_summary
events arrive and send back the newest.
Recipes#
The common shapes, end to end. Each one assumes you have already sent
initial_config with a valid token.
Speech to speech (WebRTC first)#
modalities: ["audio", "text"], an explicit stt_provider, and
lang unless you are relying on Sarvam auto-detect.
offer → answer → candidate.
Captured per the AEC requirements. VAD runs server-side and feeds STT and the agent.
user_speech_start / user_speech_end for the user side,
text-stream for the reply, ai_speech_start / speech_end for
playback state.
receive_tts_chunks falseThe server injects TTS into the WebRTC track, which is the only playback path the platform echo canceller can subtract.
Feeding live app state#
additional_current_context is the canonical channel for current app state — cart, selected
item, active screen, checkout step, permission state. Session-scoped and sticky until replaced.
{
"type": "initial_config",
"value": {
"client_source": "web_agent",
"additional_current_context": "cart_total=1499; currency=INR; selected_sku=ABC123"
}
}{
"type": "update_additional_current_context",
"additional_current_context": "cart_total=1999; currency=INR; selected_sku=XYZ789"
}This also updates the sticky context for later turns.
{
"type": "get_chat_completion",
"text": "Can I apply available coupons?",
"speech": true,
"additional_current_context": "cart_total=1999; coupon_screen=true"
}Practices worth keeping
- Keep it compact and factual — state, ids, flags. Avoid long prose.
- Update only when state changes materially.
- Prefer stable keys (
selected_sku,cart_total,step,locale) so behaviour stays predictable. - Do not put secrets or unnecessary PII in it — this text goes into model instructions.
Server-executed built-in tools#
agent_data.legacy_tools with a supported Groq modelFor example openai/gpt-oss-120b.
get_chat_completiontool-activity with phase: "requested"Render status_text if you want a progress line. Nothing to execute.
text-streamphase: "completed"Sent when the server confirms tool usage from the provider response.
Dev logging#
Send start_dev_log to subscribe this session to dev_log messages. Use it during
integration; turn it off with end_dev_log.
Limits, errors and operations#
Payload sizing#
- WebSocket max payload is about 5 MB. Prefer binary framing for audio and images to avoid base64 bloat.
- TTS PCM frames are 24 kHz mono 16-bit. The server resamples to 48 kHz when injecting into WebRTC.
- Backpressure: when the socket
bufferedAmountexceeds roughly 4 MB, non-criticaltext-streamandspeech_chunkmessages may be dropped until congestion clears. The finalend: trueis always sent. additional_current_contextis capped at 8000 characters (MAX_ADDITIONAL_CURRENT_CONTEXT_CHARS) and truncated at a word boundary above that.tts_style_promptis capped at 100 bytes, also word-safe truncated.
Error handling#
| What you see | What it means | What to do |
|---|---|---|
session-error | Init failed — usually a missing or rejected token. | Close the socket and surface it. Do not retry with the same credentials. |
self_disconnect | Inactivity timeout. | Reconnect. Send your stored pre_chat_context to continue where you left off. |
| WebRTC failure | The peer connection died or never established. | Renegotiate by sending a fresh offer. The WebSocket stays up. |
No text-stream after a turn | In ambient mode, most likely a WAIT decision — working as designed. | Nothing. Check dev_log if you need to confirm. |
| Result never appears after an acknowledgement | You cleared global replying state on the first end: true. | Rule 2 of the concurrency rules. |
| Agent says it cannot confirm an action | The artifact never reached the server. | Returning artifacts. |
Operational notes#
- Inactivity timeout is configured by
INACTIVITY_TIMEOUT; the server sendsself_disconnectand then closes. - Android clients should provide
agent_data.curr_user_tokenandagent_data.persona_idso the persona fetch authenticates. - With
use_openai_realtime: truethe server streams text and optionally audio from the OpenAI Realtime endpoint. Tool-calling flows may differ depending on the configured deployment. - Chat persistence runs when
personaIdand a token are set andclient_sourceisweb,ava_desktop,ava_androidorweb_agent— and the session is neithergame_modenorephemeral_session. - Default non-vision Groq chat paths use
openai/gpt-oss-120b; vision turns use the configured vision model.
Minimum viable client#
Everything you strictly need, with no audio plumbing at all.
const ws = new WebSocket("wss://avarealtime.pathor.ai/");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "initial_config",
value: {
curr_user_token: TOKEN,
modalities: ["audio", "text"],
stt_provider: "microsoft",
lang: "en-IN",
},
}));
};
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
switch (msg.type) {
case "session-created":
console.log("session", msg.clientId);
break;
case "text-stream":
if (msg.chunk) render(msg.response_id, msg.chunk);
if (msg.end) finalize(msg.response_id, msg.text);
break;
case "tool-calls":
ws.send(JSON.stringify({
type: "tool-result",
toolCallId: msg.tools.id,
result: { ok: true },
}));
break;
}
};
Glossary#
| Term | Meaning |
|---|---|
| VAD | Voice Activity Detection on inbound audio. Decides when someone is speaking. |
| Snapshot | The latest image attached to the next LLM turn for visual reasoning. |
| Tool call | The model asking the client to execute a function and return data. |
| Response | One unit of assistant output: chunk frames sharing a response_id, ended by exactly one end: true. A turn can produce several. |
| Detached plan | Multi-step work that runs concurrently with the conversation and reports back in its own response. |
| Action ledger | The server's honest record of every action, with states like dispatched_unconfirmed and confirmed. Never overwritten with claims from prose. |
| JIT plugin discovery | Semantic marketplace search that runs when no connected tool covers a request. Produces either execution or a consent card. |
| Consent card | The server_instruction → show_plugin UI offering a plugin the user has not connected. Answered with plugin_enabled. |
| Dev log | Server-side diagnostics pushed to the client for debugging. |
| Ambient mode | Session mode where an engagement gate decides whether the agent speaks after each STT final, rather than always responding. |
| Engagement gate | Sub-300 ms inference returning SPEAK / SPEAK_DEFERRED / WAIT / INTERRUPT for the current conversation context. Uses persona self-identity, no keyword lists. |
| Silence gap | Configurable delay after a SPEAK decision before generation fires, so the agent does not step on the end of a sentence. |
| SPEAK_DEFERRED | Gate decision when the agent wants to respond but is mid-TTS. Queued and fired after speech_end. |
| Backchannel filter | Local pre-gate classifier that drops passive acknowledgments ("hmm", "ok", "right") before the LLM gate runs. |
| Diarization | Per-speaker labelling of the audio stream. Deepgram assigns speaker_0, speaker_1, … resolved to names via meeting_participants. |
| Rolling summary | Compact distillation of the conversation regenerated every N messages. foundation holds stable core facts, summary the latest narrative. |
| pre_chat_context | Array of prior-session rolling summaries sent in initial_config to restore memory on reconnect. |
| SESSION MEMORY | The merged pre_chat_context block injected into every LLM system prompt. Live in-session messages win any conflict. |
| AEC | Acoustic Echo Cancellation. Must run on the device, where the speaker signal is known. |