Live calls

One websocket per call. You push caller audio and play what comes back. Voice activity detection, turn taking, barge-in, transcription, the LLM and text-to-speech all run server side. You do not orchestrate anything.

This is the endpoint to use for callsThe REST endpoints are the building blocks underneath. Wiring them together by hand reimplements turn detection, barge-in and filler injection — worse, and for no benefit. Use REST for batch work and custom pipelines.

Connect

wss://api.latency.cam/ws/stream?api_key=sk-lat-...

The key goes in the query string, not a header. Browsers cannot set headers on a WebSocket, so a header-only scheme would rule out browser clients entirely. A bad key gets a JSON error frame with code: "unauthorized" and then close code 1008, so you see a reason instead of an opaque handshake failure.

Connect to api.latency.cam, not to the websiteThe websocket is served on the API host, which is a different origin from the docs you are reading. GET /health needs no key and tells you whether it is answering before you open a socket.

1. Configure, first frame

Audio sent before this is refused.

{
  "type": "configure",
  "language": "hi",
  "script": "roman",
  "audio_format": "mulaw_8000",
  "voice": "Hindi (Female)",
  "llm": "fast",
  "system_prompt": "You are a delivery helpdesk. Reply in Hindi, under 12 words.",
  "silence_ms": 500,
  "interrupt_min_words": 3,
  "use_fillers": true,
  "greeting": true
}

You get back {"type":"configured","config":{...}} echoing everything that was applied, so you can assert on it.

FieldDefaultNotes
languagehiSet it from your CRM. Beats auto-detect on both speed and accuracy.
scriptromanFor Hindi, Roman text sounds noticeably better than Devanagari. Other languages use native.
voicefemale for the languageExact string from /v1/models, e.g. Tamil (Male). 20 preset voices, or a cloned voice id, all verified.
audio_formatmulaw_8000Applies in both directions. This is what telephony gives you.
silence_ms500Silence that ends a turn. Lower feels snappier but clips people who pause mid-sentence.
interrupt_min_words3How much speech is needed to cut the bot off. Keep it at 3 or more: Indian callers backchannel with "haan" and "ji" constantly and those must not interrupt. 0 means any sound does.
use_fillerstrueLeave it on. This is the single biggest factor in how fast the agent feels.
auto_language_detectfalseSafety net for mid-call switches. Costs about 200ms on the turn it runs.
normalize_numberstrueLeave it on, or "15250" gets read as digits instead of "pandrah hazaar do sau pachaas".

2. Stream caller audio

Binary frames, 20 milliseconds each — 160 bytes for 8kHz mulaw. Send them roughly one every 20ms, in real time. Do not dump a whole file at once: turn detection would see it as one enormous utterance and never fire.

3. Read events

A normal turn produces these, in order:

speech_started      caller began talking
turn_end            500ms of silence, turn closed        { turn, audio_ms }
filler_played       cached audio already sent to you     { id, category }
transcript.final    { text, turn, language, stt_ms, model }
llm.reply           { text, turn, llm_ms }
bot_audio_started   { turn, ttfa_ms, text }   binary frames start now
bot_audio_done      synthesis for this turn finished
turn_complete       { turn, total_ms, stt_ms, llm_ms, tts_ms }

Binary frames arriving between bot_audio_started and bot_audio_done are raw audio in your configured format, with no header. Write them straight to the call.

Barge-in

interrupted        { turn, reason }   the caller has taken the turn
speech_abandoned   { turn, reason: "barge_in" }   old synthesis stopped

Barge-in fires about 200ms after the caller starts speaking, not after they finish. That distinction matters: waiting for end-of-turn means the bot talks over the caller for their whole sentence plus the silence timeout, which is the most obvious way a voice agent feels broken.

Every event carries a turn number. When you see interrupted, drop any audio you have buffered for the previous turn — that is what the number is for.

Other events

EventMeaning
turn_emptyNo speech recognised. Nothing was sent to the LLM, so no reply is coming.
language_detectedOnly with auto_language_detect. Carries confidence and the language it switched from.
errorCarries stage of stt, llm or tts plus the real exception. The call stays open and the turn is abandoned — play a fallback line.
goodbye, pongReplies to hangup and ping.

Frames you can send

FrameWhat it does
configureOnce, first.
sayMake the bot speak arbitrary text. This is how you script an opening line or read out a CRM lookup result.
resetClear the conversation history without dropping the connection.
ping / hangupKeepalive, and a clean end.

The turn budget, measured

From the caller finishing their sentence, measured over wss next to the service, so excluding the round trip from India:

    3ms   filler starts playing        <- what the caller actually perceives
   40ms   transcription done           language identified for free
  627ms   reply ready                  587ms of it, and the one stage that leaves our machines
  628ms   numbers rewritten            "15250" -> "pandrah hazaar do sau pachas"
  724ms   first bot audio out          time to first audio 96ms, real-time factor 0.83

Measured wait 724ms. Perceived wait 3 milliseconds, because the filler is already playing. That gap is the entire trick, and it is why use_fillers defaults to on.

The reply model is the largest stage, and it is the one that leaves our machines587ms of the 724. The parts we control — transcription at 40ms, synthesis at 96ms — are now the small ones, so the honest way to make a turn faster from here is a closer or a smaller reply model rather than more work on the audio path. Synthesis runs at a real-time factor of 0.83, meaning it produces speech faster than the speech plays, so a reply cannot fall behind the caller however long it is.

A complete client

import asyncio, json, os, websockets

KEY = os.environ["LATENCY_API_KEY"]
URL = f"wss://api.latency.cam/ws/stream?api_key={KEY}"
FRAME = 160          # 20ms of 8kHz mulaw

async def main():
    async with websockets.connect(URL, max_size=None) as ws:
        await ws.send(json.dumps({
            "type": "configure",
            "language": "hi",
            "script": "roman",
            "audio_format": "mulaw_8000",
            "system_prompt": "You are a delivery helpdesk. Reply in Hindi, under 12 words.",
        }))

        current_turn, abandoned = 0, set()

        async def pump():
            # In production this is your telephony provider's media stream.
            with open("caller.mulaw", "rb") as fh:
                while chunk := fh.read(FRAME):
                    await ws.send(chunk)
                    await asyncio.sleep(0.02)      # real-time pacing matters

        asyncio.create_task(pump())

        async for msg in ws:
            if isinstance(msg, bytes):
                if current_turn not in abandoned:
                    play_to_caller(msg)            # raw audio, no header
                continue

            ev = json.loads(msg)
            if ev["type"] == "bot_audio_started":
                current_turn = ev["turn"]
            elif ev["type"] == "interrupted":
                abandoned.add(current_turn)        # stop the old reply
                stop_playback()
            elif ev["type"] == "transcript.final":
                print("caller:", ev["text"])
            elif ev["type"] == "error":
                say_fallback_line()

asyncio.run(main())

Known limitations

  • Live captions have a warm-up. transcript.partial arrives roughly every 400ms while the caller is still speaking, but not for the first second: below about a second of audio there is too little to identify the language from, and a partial in the wrong script is worse than no partial. Naming your languages in allowed_languages removes even that.
  • Speech detection struggles in noisy places. It calibrates on the first few hundred milliseconds of each call, which makes turn taking reliable, but it does not reliably tell speech from sustained background noise — a loud shop or a television in the room will trigger it. Our most common real-world failure, and next on the list.
  • The server decides when a turn ended. That keeps the protocol simple and means a naive client cannot break turn taking, but you cannot override it.