Introducing Naad v1

Developers

Developer hubDocumentationQuickstartModelsAudio & Voice APISpeech-to-SpeechAuthenticationError referenceSolutions

Resources

BlogSystem statusDesktop appsPricingSign inSign up

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:

MethodHowUse when
Bearer headerAuthorization: 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

DirectionFormat
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

MessagePurpose
{"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: f1f4, m1m4). 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

MessageMeaning
session.updatedSent 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_ackVoice is set; safe to say / talk.
context_ackGrounding 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_ackThe say line was accepted; audio follows.
stateEngine state: listening → thinking → speaking. Drive your UI from this.
replyThe agent's text (with the caller's transcript) for captions and logs.
lang_hintThe caller seems to be speaking another language; re-pin with set_lang if you agree.
errorSomething failed mid-session; message says what.

Recommended call flow

  1. Connect (auth above).
  2. set_langset_voice → wait for voice_ack.
  3. set_context with your system prompt (optional but what makes it your agent).
  4. say a greeting so the agent opens the call.
  5. Stream mic frames continuously; play reply frames as they arrive.
  6. On user interruption, send stop_utterance and keep streaming.

Voices & languages for realtime

The realtime engine uses short voice ids f1f4 / m1m4 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 to set_voice. Clones marked realtime_capable: false are batch-only.
  • For the realtime-native catalog (short codes and per-tenant clones) the platform also serves GET /api/naad/sts/voices and GET /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 in set_lang before set_voice.

Function calling (preview)

Every session receives, as its first frame, a capability advertisement:

json
{"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 for timeout_ms: 60000 and the ack tells you it became 15,000):
    json
    {"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}
    On a validation failure you get an error event 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.

Engine → you
{"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:

You → engine
{"type":"tool_result","call_id":"call_9f2c1ab4","content":"Order A-1 shipped on 12 Aug.","is_error":false}
  • call_id is 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 like call_ 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; each tool_result is matched on its call_id alone. Arrival order carries no meaning and is never used to pair a result with a call.
  • content must 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_notice is an advisory frame the model may send about a call in flight — most usefully to report that it clipped something. It carries a call_id and 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: true to 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 with call_already_timed_out. Sending stop_utterance (barge-in) or reset cancels 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_turn is a budget per conversational turn, not per session. It refreshes at every turn boundary — the agent finishing its reply, a barge-in, or a reset.

Rejection taxonomy

Every rejection is an explicit error event — nothing is ever silently dropped. Shape: {"type":"error","code":"…","message":"…"}.

CodeRaised when
invalid_set_toolsset_tools has no tools array, or it is not serializable.
invalid_tool_schemaA tool is not {type:"function",function:{…}}, or parameters is not an object-typed JSON Schema.
invalid_tool_namefunction.name does not match ^[a-zA-Z_][a-zA-Z0-9_]{0,63}$.
duplicate_tool_nameTwo tools in one declaration share a name.
too_many_toolsMore than 20 tools in one declaration.
tools_too_largeThe serialized tools array exceeds 32 KB.
invalid_tool_choicetool_choice is not auto/none/a function object, or names a function absent from the same declaration.
invalid_fillerfiller is not a string.
tools_not_declaredA tool_result arrived on a session that never sent set_tools.
invalid_tool_resulttool_result is not an object, or content is not a string.
unknown_call_idNo such call_id on this session — or it was already answered (a duplicate result is a rejection, not an overwrite).
call_already_timed_outThe call timed out, or was cancelled by barge-in or reset. The turn has moved on; drop the result.
result_too_largecontent 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_toolThe model asked for a function you did not declare. Rejected at our boundary, so it never reaches you as an unanswerable call.
max_calls_exceededThe model tried to exceed the max_calls_per_turn you set for this turn.
invalid_tool_callThe model produced a nameless call or arguments that are not a JSON object. Never surfaced as a tool_call.
internal_errorA 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

Node.js
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

Browser
// 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_lang answers error: language_at_capacity — back off and retry shortly rather than hammering.
  • Voice ids are validated: builtins are exactly f1f4 / m1m4; anything else that is not a clone's v_… id is rejected with invalid_voice instead of silently substituting a default voice.
  • Close code 4402 means 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.