Av AvA Realtime / API docs
/
v2 · Production
WebSocket + WebRTC · Multimodal · Realtime

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.

1WebSocket
~150msChat turn
4STT providers
2TTS backends
50Message types
<300msAmbient gate

What the server does for you#

🎙️
Speech in, speech out
Mic audio over WebRTC, server-side VAD, streaming STT, and synthesized speech injected straight back into the same peer connection.
🧠
Agentic orchestration
A fast-chat model answers immediately while a validator decides whether a tool is needed. Multi-step work runs concurrently with the conversation.
🔌
Tools & plugins
Client-executed function tools, server-side built-ins, and just-in-time plugin discovery with a consent card when the user has not connected one yet.
👁️
Vision
Attach a JPEG snapshot to any turn, or enable sticky video chat so every turn carries the latest frame.
🎧
Ambient mode
The agent listens to a whole meeting and an engagement gate decides whether it should speak at all — instead of answering every utterance.
💾
Memory across sessions
Rolling summaries stream to your client; hand them back on reconnect and the agent greets a returning user by name with real continuity.
This page tracks CLIENT_GUIDE.md

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.

Live · runs against the production 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.

Idle
ws rtt
first token
0responses
0frames
Need a key? Sign up at console.ava.pathor.ai — create an assistant there, or just grab the token and drive it from here with your own system prompt.
Credentials
Stays in this browser. Redacted in the inspector and never sent anywhere but the AvA server.
Brain
Speech
Drives the defaults for JIT plugin search and concurrent responses.
Behaviour
Barge-in tuning
Raise minSpeech if room noise keeps interrupting the agent mid-sentence.
Live context
Pushed with update_additional_current_context the moment you stop typing, while connected.
Paste a token and hit Connect.
Then talk, or type below — both go through the same session.
attached frame
Every JSON frame in both directions lands here — click one to expand it. Binary audio frames are counted, not dumped.
Real session against 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#

Interrupt it
Ask for something long, then talk over it. Watch user_speech_start arrive with interrupted: true in the inspector while playback cuts.
Watch response_id split
With multi_response on, ask for two or three actions at once. The acknowledgement and the result arrive as separate bubbles with different ids.
Feed it app state
Type something into additional_current_context, then ask a question that depends on it. No reconnect — it patches live.
Turn on plugin search
Ask for something none of your connected tools cover. A consent card appears; accepting it sends plugin_enabled and the parked request resumes on its own.
Show it something
Open the camera, capture a frame, and send it with a question. The frame rides along as image on get_chat_completion.
Compare latency
Flip disable_tool on and reconnect. First-token time drops — that is the validator and tool routing coming out of the hot path.
Mental model

How it fits together#

Two channels, one session. Knowing which channel carries what removes most of the confusion when you start wiring things up.

Your client web · android · ios mic + speaker camera (optional) tool executor WebSocket WebRTC AvA Realtime Server VAD silero onnx STT 4 providers Orchestrator validator + fast chat tool routing TTS serialized Tool execution plugins · subagents Action ledger honest state Rolling summary cross-session memory text-stream · speech_chunk · tool-calls · server_instruction → back over the WebSocket
Mic audio rides WebRTC. Everything else — config, transcripts, tool calls, plugin cards — rides the WebSocket.
The WebSocket carries
Session config, WebRTC signaling (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.
The WebRTC connection carries
Microphone audio up, synthesized speech down. Keeping playback on this track is what lets the device echo canceller subtract the voice the agent just produced — see Audio & echo cancellation.

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.

Quickstart

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.

Open the WebSocket and send initial_config

Token first. The server rejects a session that never sends one, so send config before anything else.

Negotiate WebRTC

Send an offer, apply the answer, trade candidate messages. Capture the mic with echoCancellation: true — this is not optional on speaker setups.

Play the remote track through an <audio> element

Not through the Web Audio API. The browser echo canceller can only subtract what it knows it is playing.

Render text-stream, keyed on response_id

One response is a run of chunks ending in end: true. A session can have more than one in flight — see Concurrent responses.

Answer tool-calls with tool-result

Only for client-executed function tools. Server-side work reports progress as tool-activity and needs no reply.

1 · The page#

htmlindex.html
<!-- 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#

javascriptava-client.js
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)); }
Text-only? You are already done

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#

Client Server initial_config · token + persona session-created · clientId persona-data (when personaId was sent) offer · sdp answer · sdp candidate · candidate (both ways, repeated) mic audio flowing on the RTC track user_speech_start → user_speech_end → text-stream ai_speech_start · audio on the RTC track · speech_end
The only ordering the server enforces is that initial_config comes first.
Endpoints

Where to connect#

EndpointMethodWhat it returns
wss://avarealtime.pathor.ai/WSThe session. Everything in this document happens here.
/healthzGET{ healthy: true } — liveness probe.
/statusGETUptime, version, active session count.
/sessionsGET{ active, sessions } — a summary of live sessions.
/client_guideGETThis documentation as raw markdown, straight from the deployed server.
/docsGETThis page.
Authentication

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 sendNormalised toNotes
curr_user_tokencurr_user_tokenPreferred top-level field.
api_keycurr_user_tokenAlias, accepted for compatibility.
agent_data.curr_user_tokencurr_user_tokenNested form.
personaIdpersonaIdPreferred top-level field.
persona_idpersonaIdSnake-case alias.
agent_data.persona_idpersonaIdNested form.

What the server does#

ScenarioBehaviour
No tokenSends session-error and closes the socket immediately.
Token onlySession authenticates and runs on AvA defaults. No persona fetch.
Token + personaIdFetches the persona profile and inflates system prompt, voice, language, tools and chat history automatically.
Invalid / expired token401 or 403 upstream → session-error, socket closed.

Token and persona are enough. Voice, language, system prompt, tools and history all resolve server-side.

jsonclient → server
{
  "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.

jsonclient → server
{
  "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.

jsonserver → client
{
  "type": "session-error",
  "message": "Authentication failed. Invalid or missing token."
}
Session

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.

KeyMeaningValues / notes
curr_user_tokenRequired. Session auth token.Alias api_key.
personaIdPersona to load — voice, prompt, tools, history.Alias persona_id. Omit for AvA defaults.
client_sourceWhich 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_dataFull persona + tools payload, passed straight through.Used as-is after the token validates.
system_promptSystem prompt seed.Defaults to a friendly AvA persona.
additional_current_contextSticky app/user state appended to the instructions — cart, screen, checkout step.String. Empty string clears it. Capped at 8000 chars.
modalitiesEnabled outputs.["text", "audio"]
langSTT language.en-IN, hi-IN, unknown. Normalised to stt_lang.
stt_providerSpeech recognition backend.microsoft, sarvam, deepgram, openai_realtime. Send it explicitly — the default is deployment config.
tts_providerSpeech synthesis backend.google (Python WS) or cartesia.
tts_langTTS language.Matches the voice. Defaults to en-IN.
voice_typeVoice family.non-custom or custom. custom routes to Cartesia.
voice_idVoice selection.e.g. en-IN-Chirp3-HD-Leda, or a Cartesia voice id.
tts_style_promptVoice direction for Gemini TTS.e.g. "Speak in a soft, intimate tone". Max 100 bytes, truncated on a word boundary.
receive_tts_chunksSend 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_vadRun voice activity detection on inbound audio.true / false
vad_configVAD 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_cancellationServer-side DSP on inbound mic audio (RNNoise / NLMS). Not a replacement for device AEC.bool
mute_speechSoft-mute server TTS.bool
multi_responseOpt in to concurrent responses (more than one response_id in flight).bool. Aliases supports_multi_response, multiResponse. Read per turn — toggleable mid-session.
plugin_searchJIT 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_pluginPersona + 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_sessionShared-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_modeIsolate the session: persona plugins off, tools restricted to your tools array.bool, default false.
toolsClient-supplied custom tools used when game_mode is on.Array in OpenAI function format.
start_promptPrompt auto-run right after the session is created.String. Overridden automatically for returning users when pre_chat_context is present.
pre_chat_contextPrior-session rolling summaries restored on reconnect.Array of { summary, foundation, turn_count, timestamp }, oldest first.
notification_contextThe notification the user tapped to start this session.{ assistant_message, assistant_name, timestamp }
use_openai_realtimeRoute generation to OpenAI Realtime instead of the internal agent.bool
use_groq, groq_modelGroq routing flags.optional
max_completion_tokens, temperatureLLM parameters.numbers
ambient_intelligenceEnable ambient mode — the engagement gate decides when to speak.Default false. See Ambient.
ambient_meeting_contextOne line describing the meeting; improves gate accuracy.e.g. "weekly sales team standup"
ambient_mode_configPer-session ambient tuning, merged over server defaults.{ stt_provider, silence_gap_ms, question_gap_ms, context_window_turns, max_tokens }
ambient_trigger_namesReserved; currently unused. The gate uses persona self-identity, not keyword lists.[]
meeting_participantsRoster for resolving diarization labels to names.[{ name, designation?, speakerId? }]. Without speakerId, auto-mapped in first-speech order.
capability_notesFree-text hints about your client features, injected into the ambient gate prompt.e.g. "You can present URLs and PDFs as a screen share."
Defaults are deployment config, not API surface

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.

Session

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".

ProviderlangWhy you would pick it
microsoftSend it explicitly, e.g. en-INThe code default. Solid general-purpose recognition.
sarvamOptional — omit it, or send "unknown", to auto-detectIndic languages and code-mixed speech. Also runs a turn endpointer that merges a continued utterance into one turn instead of splitting it.
deepgramSend it explicitlyThe only provider with speaker diarization — this is why ambient and meeting sessions default to it.
openai_realtimeSend it explicitlyWhen you want the OpenAI Realtime path end to end.
Send stt_provider explicitly

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#

json
{ "type": "initial_config", "value": { "stt_provider": "sarvam" } }
json
{ "type": "initial_config", "value": { "stt_provider": "sarvam", "lang": "hi-IN" } }
json
{ "type": "initial_config", "value": { "stt_provider": "microsoft", "lang": "en-IN" } }

Updating the STT language also updates the TTS language and voice to match.

json
{ "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.

Session

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_languages
  • tools_config, main_agent_tools
  • legacy_tools — see below
  • is_expressive_persona — enables richer prosody
  • chat_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_execution is accepted on input and normalises to code_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_tools or persona function tools exist, legacy_tools is ignored for that turn.
  • Unsupported entries for the selected model are silently dropped
  • These never produce tool-calls. You may receive tool-activity instead.
jsonclient → server
{
  "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

Session modes#

Four flags change the shape of a session significantly. They compose, but each answers a different question.

disable_tool
Persona and chat history load normally, but no plugins or tools do. The session runs a single fast-chat path — no validator, no JIT discovery, no consent gate. This is the lowest-latency configuration. Vision still works. Set it once, in initial_config.
🎮
game_mode
Keeps the voice and personality, replaces the toolset. Persona plugins are bypassed and the active tools become exactly the tools array you supplied. Chat persistence is off. Built for games and sandboxes.
🏪
ephemeral_session
Shared-terminal mode. The history fetch is skipped at connect and persistence is refused. Persona, voice, tools and plugins all behave normally. This is the correct flag for kiosks and demo booths where the speaker is not the account holder.
🎧
ambient_intelligence
The agent stops answering every utterance and instead runs an engagement gate that decides whether to speak at all. See Ambient intelligence.
Shared terminals must send ephemeral_session

Do 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_agentand the session is neither game_mode nor ephemeral_session.

Protocol

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.

→ SENDinitial_config Opens the session. Must be first.

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.

json
{
  "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"
  }
}
token requiredreply: session-createdreply: persona-data
→ SENDoffer · answer · candidate WebRTC signaling.

Standard signaling over the same socket. The client offers, the server answers, and both sides trickle ICE candidates.

json
{ "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.

→ SENDget_chat_completion A text turn — the non-voice way to talk.
json
{
  "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, default false. 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.
reply: text-streamreply: turn_suggestions
→ SENDupdate_additional_current_context Push current app state into the prompt.

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.

json
{
  "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.

reply: ack_additional_current_context
→ SENDupdate_user_details Location and device permissions.
json
{
  "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.

reply: ack_user_details
→ SENDupdate_stt_language Switch recognition language mid-session.
json
{ "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-detect

This also updates the TTS language and voice to match.

→ SENDtool-result Answer a client-executed tool call.
json
{ "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.

→ SENDplugin_enabled The user accepted a plugin consent card.
json
{ "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.

→ SENDrefresh_plugins Reload the toolset without reconnecting.
json
{ "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.

reply: refresh_plugins_successreply: refresh_plugins_error
→ SENDsnapshot Attach an image to the next turn.

Binary framing is strongly preferred — a JSON header line, a newline, then the raw JPEG bytes.

binary frame
{"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.

→ SENDmic_audio Fallback audio path when WebRTC is unavailable.
both forms
// 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.

→ SENDstart_video_chat · end_video_chat Sticky visual chat mode.
json
{ "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.

→ SENDupdate_sys_prompt Append framing to the system prompt.
json
{ "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.

→ SENDstop_speech · interrupt_operation Cancel the turn in progress.

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.

json
{ "type": "stop_speech" }
→ SENDtoggle_mute_speech Soft-mute synthesized speech.
json
{ "type": "toggle_mute_speech", "mute": true }
reply: ack_mute_speech
→ SENDclear_agent_chat_history Wipe the server-side LLM history.

Clears the conversation array the agent is reasoning over. The session, the persona and the connection all stay up.

→ SENDping Application-level keepalive.
json
{ "type": "ping", "client_ts": 1718000000000 }

client_ts is optional; the server echoes it in pong so you can measure round-trip latency.

reply: pong
→ SENDstart_dev_log · end_dev_log Stream server diagnostics to this session.

Subscribe during integration and turn it off in production. Logs arrive as dev_log messages.

→ SENDmanual_disconnect Graceful close.

Optional — simply closing the socket also works; the server cleans up either way. Sending it gets you an ack_disconnect first.

reply: ack_disconnect
→ SENDdashboard_init Generate home-screen content before any conversation.
json
{ "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.

reply: dashboard_init_response
→ SENDupdate_meeting_participants Runtime roster for ambient mode.

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.

json
{ "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.

→ SENDspeaker_context Who is talking right now.
json
{ "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.

→ SENDpresentation_* AI Presenter Mode lifecycle.
json
{ "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" }
reply: ack_presentation_ready
Protocol

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.

← RECVsession-created The session is live.
json
{ "type": "session-created", "clientId": "...", "auth": true, "message": "..." }
← RECVsession-error Init failed; the socket is about to close.
json
{ "type": "session-error", "message": "Authentication failed. Invalid or missing token." }

Close and surface the problem. Retrying with the same credentials will not help.

← RECVpersona-data The resolved persona and tool config.

Sent when the server fetched a persona with your credentials. Useful for rendering the agent name, avatar and connected-tool list in your UI.

← RECVtext-stream Streaming assistant text. The one you must get right.
json
{ "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.

response_idchunked
← RECVai_speech_start · speech_end TTS playback boundaries.

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.

← RECVuser_speech_start The user started talking. Read the interrupted flag.
json
{ "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 after speech_end). Update your listening UI, but do not discard a pending or queued response.
← RECVuser_speech_end Final transcript for the utterance.
json
{ "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.

← RECVspeech_chunk Raw TTS PCM — only when you asked for it.
json
{ "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.

← RECVtool-calls Run this tool and reply.
json
{ "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.

← RECVtool-activity Status only — nothing to execute.
json
{ "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.

← RECVserver_instruction Out-of-band UI instruction — today, plugin consent.
json
{
  "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.
  • os is 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.
  • isAuthenticated appears only when applicable. authentication_required && !isAuthenticated means the user must sign in first.
  • status: "connected" means already connected — show connected state, never a fresh connect prompt.
← RECVrolling_summary Persist this — it is cross-session memory.
json
{ "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.

← RECVturn_suggestions Three follow-up prompts to render as chips.
json
{ "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.

← RECVdashboard_init_response Home-screen content.
json
{ "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.

← RECVrefresh_plugins_success · refresh_plugins_error Result of a mid-session plugin reload.
json
{ "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.

← RECVspeaker_identified A diarization label was mapped to a person.
json
{ "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.

← RECVdev_log Server diagnostics, when subscribed.
json
{ "type": "dev_log", "message": "...", "log_level": "info" }
← RECVacknowledgements Small confirmations you can mostly ignore.
json
{ "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.

← RECVself_disconnect Inactivity timeout — reconnect to continue.

Server-initiated close after the configured idle period. Reconnect with the same pre_chat_context and the conversation picks up where it left off.

Protocol · recently changed

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.

#WhatSkip it and…
1Key chat bubbles on response_idThe task result overwrites the acknowledgement, or never appears. This is the hard prerequisite.
2Opt in with multi_response: trueYou stay on the old one-response-per-turn path. Safe, just not concurrent.
3Send back a plugin artifact, every timeThe agent honestly reports actions it cannot confirm, even though your UI shows them done. See Returning artifacts.
4Render "waiting on you" as its own step stateThe 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:

streamone user request, three responses
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#

Key the assistant bubble on response_id

Chunks with a new id open a new bubble. Do not append them to the bubble you were streaming into.

end: true finalizes only that bubble

Never use it to clear global "the assistant is replying" state — another response may still be streaming.

A response can arrive with no preceding user message

A task finishing, a proactive update. Render it as a normal assistant bubble; do not require a pending user turn.

Do not assume ordering between different response_ids

Within one id, chunks are ordered. Across ids, they are not.

finish_reason stays per-response

It 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.

json
{ "multi_response": true }

multi_response: false opts back out at any time. supports_multi_response and multiResponse are accepted spellings.

ClientStatus
ava_androidAdopted and verified live. On by default server-side — no flag needed.
ava_desktopAdopted and verified live. On by default server-side — no flag needed.
vidya_android, web, web_agentPending. Send multi_response: true once you have implemented the rules.
How to verify you adopted it correctly

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".

StateWhat it meansWhat the user needs to see
executingThe step is running.Progress.
doneConfirmed, or dispatched with its artifact returned.Success.
waiting on youThe step asked a question and cannot proceed until it is answered.The question, prominently.
failedIt did not happen.Plainly, without softening.
"Waiting on you" is the one that matters

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.

Capabilities

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.

📱
Client-executed tools
The server sends tool-calls, you run the function, you answer with tool-result. This is the path for anything that lives on the device.
🌐
Server-side built-ins
Configured through agent_data.legacy_tools. The provider runs them. You get tool-activity status and nothing to execute.
🔎
JIT plugin discovery
When no connected tool covers the request, the server searches the marketplace and either runs an already-enabled plugin or offers a consent card.

Client-executed tool flow#

Configure the tools

Through the persona fetch, or by passing agent_data.tools_config yourself.

Handle tool-calls

Execute the described function locally.

Reply with tool-result

Same toolCallId, structured result.

The server resumes the turn

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_sourceJIT plugin search default
ava_android, vidya_android, ava_desktopEnabled
web, web_agent, anything else or omittedDisabled

Any client can override the default explicitly:

json
{ "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.

Client Server "book me a cab" no connected tool fits → search text-stream · "[action: show_plugin | actionValue: …]" server_instruction · show_plugin · request parked plugin_enabled ← the user tapped the toggle parked request resumes automatically — no re-prompt
The server holds the original request. 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.

jsoninitial_config
{
  "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-callstool-result flow.

Capabilities

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 classic symptom

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 image as base64 (no data-URI prefix) on the same get_chat_completion payload 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:

server log
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.

Capabilities

Vision#

The default chat model is vision-capable, so image turns need no special configuration — just get the pixels to the server.

One-shot: attach to a turn
Send 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.
Sticky: video chat mode
Send 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#

Connect and send initial_config

Include modalities: ["audio", "text"] if you want a spoken answer.

Push the image

snapshot binary frame first, or image inline on the next message.

Send get_chat_completion with speech: true
Read text-stream, ai_speech_start, speech_end

Prefer binary framing for images. A base64 JPEG costs roughly a third more bytes on a link that is also carrying realtime audio.

Capabilities

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.

Two rules, and they are non-negotiable on speaker setups

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.

javascript
// 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: true in a browser, route your playback through a loopback RTCPeerConnection and 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 fieldWhat it controlsRange
thresholdSilero speech probability required to count as speech.e.g. 0.75
minSpeechDurationSustained 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
minSilenceDurationSilence (seconds) that re-arms the barge-in latch.clamped 0.2 – 3.0
json
{ "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
Capabilities

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.

Backchannel filter

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.

Transcript buffer

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].

Engagement gate

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.

Barge-in cancellation

If new speech arrives while a SPEAK gap is pending, the timer is cancelled. The agent will not cut in mid-sentence.

DecisionBehaviour
WAITStays silent. No generation is triggered at all.
SPEAKWaits for the silence gap, then speaks. A shorter gap is used when urgency ≥ 7.
SPEAK_DEFERREDWants to speak but is mid-TTS. The generation is queued and fires after the current speech_end, so it never talks over itself.
INTERRUPTFires 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#

jsoninitial_config
{
  "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."
  }
}
FieldRequiredDescription
ambient_intelligenceYestrue activates it. false (default) is normal reactive mode.
ambient_meeting_contextNoOne line describing the meeting type. Improves gate accuracy for domain matching.
ambient_mode_configNoPer-session tuning, merged over server defaults. Sub-fields below.
meeting_participantsNoRoster for diarization label resolution. speakerId optional — entries without one are auto-mapped in first-speech order.
capability_notesNoFree-text hints about client features the gate should consider when deciding to speak.
ambient_trigger_namesNoReserved; currently unused. The gate uses persona self-identity, not name matching.

ambient_mode_config sub-fields

Sub-fieldDescriptionEnv varCode defaultDeployed
stt_providerSTT for ambient mode — deepgram is the one that diarizes.deepgramdeepgram
silence_gap_msSilence pause before a SPEAK decision fires.AMBIENT_SILENCE_GAP_MS900400
question_gap_msShorter gap used when urgency ≥ 7.AMBIENT_QUESTION_GAP_MS450150
context_window_turnsRolling transcript window fed to the gate.AMBIENT_CONTEXT_WINDOW_TURNS158
max_tokensMax output tokens for ambient generation. 0 or omitted uses the session default.AMBIENT_MAX_TOKENS0150

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#

AspectNormal modeAmbient mode
Response rateEvery STT final generates.Only on SPEAK, SPEAK_DEFERRED or INTERRUPT.
Response timingImmediate after the STT final.SPEAK: after the silence gap. SPEAK_DEFERRED: after the current speech_end. INTERRUPT: immediate, with a prefix.
text-stream / speechAlways emitted.Only on decided turns.
ai_speech_startAlways.Only on decided turns.
stop_speechCancels 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#

VariableCode defaultDescription
AMBIENT_SILENCE_GAP_MS900Silence gap before SPEAK fires. Deployed: 400.
AMBIENT_QUESTION_GAP_MS450Shorter gap for urgent/question signals. Deployed: 150.
AMBIENT_MIN_URGENCY_TO_SPEAK4Urgency (1–10) below which SPEAK is suppressed.
AMBIENT_INTERRUPT_ENABLEDtrueSet false to disable INTERRUPT server-wide.
AMBIENT_CONTEXT_WINDOW_TURNS15Recent utterances kept for gate context. Deployed: 8.
AMBIENT_ENGAGEMENT_MODELopenai/gpt-oss-20bGroq model used for the gate.
Capabilities

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?"

The server generates a rolling summary

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.

You receive rolling_summary and persist it

Store it keyed by persona id — localStorage is fine for web. Keep the newest three, FIFO.

On the next connect you send pre_chat_context

The 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#

jsonserver → client
{
  "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
}
FieldDescription
summaryThe full rolling summary, up to roughly 500 tokens.
foundationCondensed core facts — name, role, preferences. Kept stable across repeated summarisation cycles.
turn_countNumber of turns this summary covers.
timestampUnix epoch ms when it was generated.

Sending it back as pre_chat_context#

jsonclient → server
{
  "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#

javascript
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.

json
{
  "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#

VariableCode defaultDeployedDescription
ROLLING_SUMMARY_TRIGGER_MESSAGES96New transcript messages before the summary is regenerated.
ROLLING_SUMMARY_KEEP_LAST_MESSAGES94Most recent messages kept verbatim after compaction.
SUMMARY_AGENT_MAX_TOKENS512512Max 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.

Guides

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)#

Config

modalities: ["audio", "text"], an explicit stt_provider, and lang unless you are relying on Sarvam auto-detect.

Negotiate WebRTC

offeranswercandidate.

Stream the mic on the RTC track

Captured per the AEC requirements. VAD runs server-side and feeds STT and the agent.

Render events

user_speech_start / user_speech_end for the user side, text-stream for the reply, ai_speech_start / speech_end for playback state.

Keep receive_tts_chunks false

The 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.

json
{
  "type": "initial_config",
  "value": {
    "client_source": "web_agent",
    "additional_current_context": "cart_total=1499; currency=INR; selected_sku=ABC123"
  }
}
json
{
  "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.

json
{
  "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#

Send agent_data.legacy_tools with a supported Groq model

For example openai/gpt-oss-120b.

Send a normal get_chat_completion
Expect an early tool-activity with phase: "requested"

Render status_text if you want a progress line. Nothing to execute.

Read the answer from text-stream
Optionally handle phase: "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.

Guides

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 bufferedAmount exceeds roughly 4 MB, non-critical text-stream and speech_chunk messages may be dropped until congestion clears. The final end: true is always sent.
  • additional_current_context is capped at 8000 characters (MAX_ADDITIONAL_CURRENT_CONTEXT_CHARS) and truncated at a word boundary above that.
  • tts_style_prompt is capped at 100 bytes, also word-safe truncated.

Error handling#

What you seeWhat it meansWhat to do
session-errorInit failed — usually a missing or rejected token.Close the socket and surface it. Do not retry with the same credentials.
self_disconnectInactivity timeout.Reconnect. Send your stored pre_chat_context to continue where you left off.
WebRTC failureThe peer connection died or never established.Renegotiate by sending a fresh offer. The WebSocket stays up.
No text-stream after a turnIn ambient mode, most likely a WAIT decision — working as designed.Nothing. Check dev_log if you need to confirm.
Result never appears after an acknowledgementYou cleared global replying state on the first end: true.Rule 2 of the concurrency rules.
Agent says it cannot confirm an actionThe artifact never reached the server.Returning artifacts.

Operational notes#

  • Inactivity timeout is configured by INACTIVITY_TIMEOUT; the server sends self_disconnect and then closes.
  • Android clients should provide agent_data.curr_user_token and agent_data.persona_id so the persona fetch authenticates.
  • With use_openai_realtime: true the 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 personaId and a token are set and client_source is web, ava_desktop, ava_android or web_agent — and the session is neither game_mode nor ephemeral_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.

javascript
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;
  }
};
Guides

Glossary#

TermMeaning
VADVoice Activity Detection on inbound audio. Decides when someone is speaking.
SnapshotThe latest image attached to the next LLM turn for visual reasoning.
Tool callThe model asking the client to execute a function and return data.
ResponseOne unit of assistant output: chunk frames sharing a response_id, ended by exactly one end: true. A turn can produce several.
Detached planMulti-step work that runs concurrently with the conversation and reports back in its own response.
Action ledgerThe server's honest record of every action, with states like dispatched_unconfirmed and confirmed. Never overwritten with claims from prose.
JIT plugin discoverySemantic marketplace search that runs when no connected tool covers a request. Produces either execution or a consent card.
Consent cardThe server_instruction → show_plugin UI offering a plugin the user has not connected. Answered with plugin_enabled.
Dev logServer-side diagnostics pushed to the client for debugging.
Ambient modeSession mode where an engagement gate decides whether the agent speaks after each STT final, rather than always responding.
Engagement gateSub-300 ms inference returning SPEAK / SPEAK_DEFERRED / WAIT / INTERRUPT for the current conversation context. Uses persona self-identity, no keyword lists.
Silence gapConfigurable delay after a SPEAK decision before generation fires, so the agent does not step on the end of a sentence.
SPEAK_DEFERREDGate decision when the agent wants to respond but is mid-TTS. Queued and fired after speech_end.
Backchannel filterLocal pre-gate classifier that drops passive acknowledgments ("hmm", "ok", "right") before the LLM gate runs.
DiarizationPer-speaker labelling of the audio stream. Deepgram assigns speaker_0, speaker_1, … resolved to names via meeting_participants.
Rolling summaryCompact distillation of the conversation regenerated every N messages. foundation holds stable core facts, summary the latest narrative.
pre_chat_contextArray of prior-session rolling summaries sent in initial_config to restore memory on reconnect.
SESSION MEMORYThe merged pre_chat_context block injected into every LLM system prompt. Live in-session messages win any conflict.
AECAcoustic Echo Cancellation. Must run on the device, where the speaker signal is known.