Realtime Speech to Speech
One WebSocket carries the whole conversation: you stream the caller's microphone up, the engine streams the agent's voice back, and small JSON messages control language, voice, grounding and turn-taking. This is the same engine behind the Voice Agent demo and the platform's Live talk studio.
Connection
wss://platform.oogam.ai/v1/realtime — three ways to authenticate:
| Method | How | Use when |
|---|---|---|
| Bearer header | Authorization: Bearer sk-setu-… | Server-side clients (Node, Python). |
| Query key | ?api_key=sk-setu-… | Tools that cannot set WebSocket headers. Never in a browser page. |
| Session token | ?session_token=… | Browser apps: your backend holds the API key and mints a short-lived token per call, so the key never ships to the client. |
Audio format
| Direction | Format |
|---|---|
| You → engine (mic) | Binary frames of 16-bit PCM, 16 kHz, mono. Stream continuously — the server does voice-activity detection and end-of-speech; do not gate or trim on the client. |
| Engine → you (voice) | Binary frames of 16-bit PCM, 24 kHz, mono. Schedule chunks back-to-back on an audio clock for gapless playback. |
Message protocol
Everything that is not binary audio is a one-line JSON object.
You send
| Message | Purpose |
|---|---|
{"type":"set_lang","lang":"hi"} | Pin the conversation language (short code — see the Live talk column in language codes). Send first. |
{"type":"set_voice","voice":"f1"} | Pick the agent voice (live voice ids: f1–f4, m1–m4). Wait for voice_ack before speaking. |
{"type":"set_context","system":"…","kb_id":"kb_…"} | Ground the agent: persona and rules as one system prompt — hard limit 8,000 characters (bigger payloads are rejected with context_too_large; the rejection names what is still active — active_context: "previous" or "none" — and while it is "none" the session refuses start_utterance, say and audio with no_active_context rather than taking a call with no instructions), plus an optional kb_id to answer from a hosted knowledge base. The prompt and the knowledge base share one budget: 7,863 characters combined, of which the knowledge base may use at most 6,000. Size your prompt against the combined figure, not the 8,000 field cap — a prompt that fits on its own can leave the knowledge base no room, and the exact numbers are advertised per session on session.updated under limits so you can size against them rather than hardcoding. The wire is the authority — as of this release it advertises: {"context_system_max_chars":8000,"context_total_chars":7863,"kb_max_chars":6000,"say_max_chars":500}. If a figure on this page ever disagrees with the frame, trust the frame. The platform compiles the knowledge base's ready documents into the prompt the model reads, newest first, trimming from the end. If it will not fit at all you get an error and your system prompt is still applied — you lose the grounding, never the instructions. Confirmed by context_ack, which reports the loaded kb_chars — see below. |
{"type":"say","text":"…"} | Make the agent speak a line verbatim — ideal for the greeting. At most 500 characters per chunk (split longer text into sentence-sized says; over-long text is rejected with say_too_long). Confirmed by say_ack; a refused say surfaces as say_rejected. |
{"type":"set_mode","mode":"external_brain"} | Switch the session mode (ack: mode_ack). In external_brain the engine emits transcripts only and never speaks on its own — your orchestrator receives stt events, thinks, and answers through say chunks. Use it when your own logic (tools, multi-node workflows) owns the replies; the engine still owns voice-activity detection and endpointing. Default mode: the engine converses end-to-end. |
{"type":"start_utterance"} / {"type":"stop_utterance"} | Optional turn control: cancel the agent mid-reply (barge-in) with stop_utterance, then keep streaming mic audio. |
{"type":"reset"} | Clear conversation state without reconnecting. |
You receive
| Message | Meaning |
|---|---|
session.updated | Sent once at connect: capabilities advertises what this session supports — gate feature use (e.g. tool registration) on it rather than assuming. See function calling. |
voice_ack | Voice is set; safe to say / talk. |
context_ack | Grounding accepted. When you attached a kb_id it also reports what was actually loaded — kb_documents, kb_chars, kb_status. Assert kb_chars > 0 to confirm the agent is genuinely grounded, and compare kb_hash (sha256 of the source documents, also returned by GET /v1/kb/{kb_id}) with a hash of your own copy to catch a stale hosted fork. Statuses: loaded; loaded_truncated (the tail of the knowledge base did not fit — shorten your prompt); and skipped_* (the knowledge base was refused, kb_error says why, and applied: true confirms your system prompt took effect regardless). |
say_ack | The say line was accepted; audio follows. |
state | Engine state: listening → thinking → speaking. Drive your UI from this. |
reply | The agent's text (with the caller's transcript) for captions and logs. |
lang_hint | The caller seems to be speaking another language; re-pin with set_lang if you agree. |
error | Something failed mid-session; message says what. |
Recommended call flow
- Connect (auth above).
set_lang→set_voice→ wait forvoice_ack.set_contextwith your system prompt (optional but what makes it your agent).saya greeting so the agent opens the call.- Stream mic frames continuously; play reply frames as they arrive.
- On user interruption, send
stop_utteranceand keep streaming.
Voices & languages for realtime
The realtime engine uses short voice ids — f1–f4 / m1–m4 for presets, and a clone's live_voice_id (a v_… value). To discover what a key may use:
- The REST GET /voices catalog lists each preset and every clone; a clone that can carry a live call carries a
live_voice_id— that is the value you pass toset_voice. Clones markedrealtime_capable: falseare batch-only. - For the realtime-native catalog (short codes and per-tenant clones) the platform also serves
GET /api/naad/sts/voicesandGET /api/naad/sts/languages, which the Live talk studio uses to build its pickers. - Language uses the short code (
hi,gu,ta…) — the "Live talk" column in the language table. Send it inset_langbeforeset_voice.
Function calling (preview)
Every session receives, as its first frame, a capability advertisement:
{"type":"session.updated","capabilities":{"function_calling":true,"parallel_tool_calls":false,"max_tools":20,"max_calls_per_turn":10}}Gate tool registration on capabilities.function_calling — while it is false the model never requests a tool run, so register tools only when it reads true (no client update needed; the flag is the signal). Read the values you are sent rather than the ones printed above: the advertisement is resolved per session from what the gateway and the model can both actually serve, so it can differ from this example and can change between releases.
parallel_tool_calls is false today, deliberately: the model runs tools strictly one at a time. The correlation rules below already allow answering several calls in any order, so a client written to them needs no change if this becomes true — but do not design around concurrency you are currently told you do not have. It will not change silently.
What works today:
{"type":"set_tools","tools":[…]}— declare tools in OpenAI function-schema format, verbatim. Each declaration replaces the previous set (never appends);tools: []clears it; sent mid-turn it applies from the next turn. Optional fields:tool_choice(auto·none· a named function),filler,timeout_ms(1,000–15,000, default 5,000),max_calls_per_turn(1–10, default 3).tools_ack— one per declaration. It echoes the effective config, so a clamped value is visible rather than silent (ask fortimeout_ms: 60000and the ack tells you it became 15,000):On a validation failure you get anjson{"type":"tools_ack","count":2,"names":["lookup_order","end_call"],"applied_from_turn":"turn_7","tool_choice":"auto","filler":"auto","timeout_ms":5000,"max_calls_per_turn":3}errorevent instead and the previous tool set survives intact — a bad declaration never leaves the session with no tools.- Limits: 20 tools per session, 32 KB serialized declaration, tool results 16 KB.
The call → result loop
Once function_calling is true, the model emits a tool_call whenever it wants one of your declared functions run. arguments is always a parsed JSON object, never a JSON string — if the engine cannot produce a valid object the call is rejected instead of reaching you half-formed.
{"type":"tool_call","call_id":"call_9f2c1ab4","name":"lookup_order","arguments":{"order_id":"A-1"},"turn_id":"turn_7"}Run the function and reply with the result:
{"type":"tool_result","call_id":"call_9f2c1ab4","content":"Order A-1 shipped on 12 Aug.","is_error":false}call_idis opaque — echo it, never parse it. It is minted by Oogam, unique within a session, at most 64 characters, and is the only correlation key. Echo it back verbatim; do not parse, re-order or re-mint it, and never match it against a pattern. Today it happens to look likecall_plus 8 lowercase hex characters, which is useful when reading logs, but that shape is an observation and not a promise: a client that pattern-matches it will break the day the format widens, and gains nothing in the meantime. Correlate by equality only.- Correlation is order-independent — guaranteed. With
parallel_tool_calls, a single turn may open several calls at once. Answer them in any order, including completely reversed; eachtool_resultis matched on itscall_idalone. Arrival order carries no meaning and is never used to pair a result with a call. contentmust be a string (serialize JSON yourself) of at most 3,000 Unicode code points, and 16 KB outright. Oversized results are rejected, never truncated — a clipped retrieval silently yields a confidently wrong answer on a live call. The 3,000 figure is the model’s own limit on the decoded string, so it counts Devanagari and emoji the same as ASCII.tool_noticeis an advisory frame the model may send about a call in flight — most usefully to report that it clipped something. It carries acall_idand a reason, is relayed to you byte-for-byte, and requires no reply. Log it: it is how you learn a result did not survive intact.- Set
is_error: trueto tell the model the function failed; it will apologise and move on rather than inventing a result. - Answer within
timeout_ms(default 5,000). After that the call is abandoned and a late result is rejected withcall_already_timed_out. Sendingstop_utterance(barge-in) orresetcancels every outstanding call in the abandoned turn — late results for those get the same code, and are never injected into the turn that replaced them. max_calls_per_turnis a budget per conversational turn, not per session. It refreshes at every turn boundary — the agent finishing its reply, a barge-in, or areset.
Rejection taxonomy
Every rejection is an explicit error event — nothing is ever silently dropped. Shape: {"type":"error","code":"…","message":"…"}.
| Code | Raised when |
|---|---|
invalid_set_tools | set_tools has no tools array, or it is not serializable. |
invalid_tool_schema | A tool is not {type:"function",function:{…}}, or parameters is not an object-typed JSON Schema. |
invalid_tool_name | function.name does not match ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$. |
duplicate_tool_name | Two tools in one declaration share a name. |
too_many_tools | More than 20 tools in one declaration. |
tools_too_large | The serialized tools array exceeds 32 KB. |
invalid_tool_choice | tool_choice is not auto/none/a function object, or names a function absent from the same declaration. |
invalid_filler | filler is not a string. |
tools_not_declared | A tool_result arrived on a session that never sent set_tools. |
invalid_tool_result | tool_result is not an object, or content is not a string. |
unknown_call_id | No such call_id on this session — or it was already answered (a duplicate result is a rejection, not an overwrite). |
call_already_timed_out | The call timed out, or was cancelled by barge-in or reset. The turn has moved on; drop the result. |
result_too_large | content exceeds 3,000 Unicode code points — the limit the model enforces on the decoded string, not bytes and not the serialized frame — or 16 KB outright. Devanagari and emoji are counted the same as ASCII: one character, one unit. We reject rather than truncate, because a clipped result is spoken with the same confidence as a complete one. Summarise long retrievals before returning them. |
unknown_tool | The model asked for a function you did not declare. Rejected at our boundary, so it never reaches you as an unanswerable call. |
max_calls_exceeded | The model tried to exceed the max_calls_per_turn you set for this turn. |
invalid_tool_call | The model produced a nameless call or arguments that are not a JSON object. Never surfaced as a tool_call. |
internal_error | A fault on our side — today only a failure to allocate a unique call_id. Retry the turn; report it if you ever see it. |
unknown_tool, max_calls_exceeded, invalid_tool_call and internal_error are not your client's fault — you receive them for visibility, but there is nothing on your side to fix. The rest are yours to correct.
Node.js example
import WebSocket from "ws";
const ws = new WebSocket("wss://platform.oogam.ai/v1/realtime", {
headers: { Authorization: `Bearer ${process.env.OOGAM_API_KEY}` },
});
ws.on("open", () => {
// 1. Pin language and voice, then wait for voice_ack.
ws.send(JSON.stringify({ type: "set_lang", lang: "hi" }));
ws.send(JSON.stringify({ type: "set_voice", voice: "f1" }));
});
ws.on("message", (data, isBinary) => {
if (isBinary) {
playPcm16(data); // 24 kHz mono PCM16 reply audio
return;
}
const msg = JSON.parse(data.toString());
if (msg.type === "voice_ack") {
// 2. Optional grounding, then speak an opening line.
ws.send(JSON.stringify({ type: "set_context", system: SYSTEM_PROMPT }));
ws.send(JSON.stringify({ type: "say", text: "नमस्ते! मैं आपकी क्या मदद करूँ?" }));
}
if (msg.type === "state") console.log("engine:", msg.state);
if (msg.type === "reply") console.log("agent said:", msg.text);
});
// 3. Stream the caller's mic continuously: 16 kHz mono PCM16 binary frames.
micStream.on("frame", (pcm16Buffer) => ws.send(pcm16Buffer));Browser connection
// Browsers cannot set an Authorization header on a WebSocket.
// Servers: keep your key out of the page — mint a short-lived session
// token server-side and pass that instead.
const ws = new WebSocket(
"wss://platform.oogam.ai/v1/realtime?api_key=sk-setu-..."); // server-side tools only
const ws2 = new WebSocket(
"wss://platform.oogam.ai/v1/realtime?session_token=" + token); // browser apps (recommended)Limits, billing & disconnects
- Billed per minute from your project wallet while connected.
- Concurrency: 5 sessions per API key, 2 Live talk calls per user; max session length 120 minutes (closes with code
1000). Contact us to raise limits. - Per-language capacity: concurrent calls are also admitted per language (Hindi has the most headroom). At capacity,
set_langanswerserror: language_at_capacity— back off and retry shortly rather than hammering. - Voice ids are validated: builtins are exactly
f1–f4/m1–m4; anything else that is not a clone'sv_…id is rejected withinvalid_voiceinstead of silently substituting a default voice. - Close code
4402means the wallet ran out mid-call — top up and reconnect. - Close code
1011("Realtime service unavailable — try again") is transient and retryable: reconnect with 1s → 2s → 4s backoff; it recovers within a minute in practice. Keep the telephony leg open and play a short hold prompt while the engine leg reconnects. - On any drop, reconnect and replay steps 2–4 — session state does not survive a reconnect; the engine keeps no conversation memory across connections.