New AssemblyAI-TTS-1: Text-to-speech for voice agents. Learn more
Insights & Use Cases

Build a raw WebSocket voice agent: streaming STT, LLM and TTS with no SDK

The bare protocol: every frame named, every state transition logged, no SDK between you and the wire. Build a full voice agent on raw WebSockets with Universal-3.6 Pro Realtime, LLM Gateway and AssemblyAI Text-to-Speech, including barge-in and tool calling.

Abstract green cylinder illustration

Written by

Kelsey Foster

Published on

29 October 2026

This tutorial builds a complete voice agent from the bare protocol: every frame named, every state transition logged, and no SDK between you and the wire. Speech comes in over the Universal-3.6 Pro Realtime streaming WebSocket, the reply comes from an LLM over plain HTTPS through LLM Gateway, and the answer goes out over the AssemblyAI Text-to-Speech WebSocket. AssemblyAI Text-to-Speech delivers frontier-level quality, runs faster and costs less than other production voice agent TTS models, and has unlimited concurrency. It's what to build when you want to understand exactly what each service does, or when you're embedding the agent into a custom application that needs full control over each frame.

If you only want to see the speech-to-text wire, start with Raw WebSocket voice agent with AssemblyAI (Python), which covers that one socket in depth. This post picks up where it ends: two sockets, one HTTP call, barge-in and tool calling, all in about 300 lines of Python.

What "raw" means here

With the AssemblyAI SDK Raw WebSockets (this tutorial)
Dependencies assemblyai websockets, httpx, sounddevice
Frames you handle Events and iterators the SDK exposes ✔ Every frame on both sockets, by name
Partial transcripts ✔ ✔ live, overwriting line
Tool calling ✔ in the LLM request ✔ get_weather in a plain HTTP request
Barge-in cancel() on the session ✔ a Cancel frame, dropping the audio tail until Cancelled
Backchannels ("mhm") while the agent talks You add the filter ✔ filtered, so they don't interrupt
Error handling SDK exceptions ✔ Error frames and close codes, read directly

If you want the fastest path to a working agent, the SDKs wrap all of this for you; the text-to-speech quickstart shows both. If you want to ship into a product where you own every byte, or port the agent to a language with no SDK, build on this one: every frame the protocols express is handled or named here.

Architecture

                 binary PCM frames (16 kHz)          JSON: Begin, SpeechStarted, Turn, Termination
 microphone ───────────────────────────▶ wss://streaming.assemblyai.com/v3/ws ───────────┐
                                                                                          │ final Turn
                                                                                          ▼
                                     POST https://llm-gateway.assemblyai.com/v1/chat/completions
                                          (messages + tools ─▶ reply, or tool_calls to run)
                                                                                          │ reply text
                                                                                          ▼
 speaker ◀─────────────────────────────── wss://tts.us.assemblyai.com/v1/ws ◀── Generate, Flush, Cancel
          JSON Audio frames (base64 PCM, 24 kHz)   plus FlushDone, Cancelled, Termination, Error

Three connections, one API key. The speech-to-text socket carries your audio up and transcripts down, and its model decides when the user has finished a turn. The LLM call is a stateless HTTPS request that carries the conversation and the tool definitions. The text-to-speech socket stays open for the whole conversation: each reply is one request on it, and a Cancel stops a reply mid-sentence.

Prerequisites

  • Python 3.10+
  • A microphone — headphones strongly recommended (terminal apps don't get OS-level echo cancellation)
  • An AssemblyAI API key. The same key authenticates all three connections. LLM Gateway may need a paid account; if it isn't enabled on yours, point LLM_URL and the headers at any OpenAI-compatible endpoint and keep the rest.

On macOS, install PortAudio for sounddevice:

brew install portaudio

Quick start

Save the seven sections below, in order, as one file named agent.py, then install the three libraries and run it:

pip install -U "websockets>=14" httpx sounddevice
export ASSEMBLYAI_API_KEY=your_key_here
python agent.py

The agent greets you, then you'll see every frame flow through the terminal as you talk:

[tts] session 285ab483-997d-439d-a207-411b373a4ae1: 24000 Hz pcm_s16le
Listening. Use headphones, then press Ctrl+C to stop.
[stt] session 6b1e0f62-0c5a-4d8e-9f0e-2c4a8d1e7b13
[tts] reply 0: 3120 ms of audio
[stt] speech started
[user] What's the weather like in Lisbon today?
[tool] get_weather({'city': 'Lisbon'})
[agent] It's sunny in Lisbon today, with a high around 22 degrees.
[tts] reply 1: 3440 ms of audio
[stt] speech started
[user] And what about Madrid?
[tool] get_weather({'city': 'Madrid'})
[agent] Madrid is sunny too, with a high around 22 degrees, and it should stay dry all afternoon.

[agent] barge-in: stopping speech
[user] Actually, never mind, thanks.
[tts] cancelled after 1.84 s
[agent] No problem. Have a great day!
[tts] reply 3: 1680 ms of audio
^C[stt] terminated cleanly
[tts] terminated cleanly

The reply 3 after a cancel is expected: flush_id skips a number after every Cancel, which is why the program reads ids from the frames instead of counting them.

The program, frame by frame

1. Configuration: three endpoints, one key

# agent.py: a voice agent on raw WebSockets. No AssemblyAI SDK.
import asyncio
import base64
import json
import os
import string
import threading
from urllib.parse import urlencode

import httpx
import sounddevice as sd
import websockets

API_KEY = os.environ["ASSEMBLYAI_API_KEY"]
HEADERS = {"Authorization": API_KEY}  # the raw key, with no "Bearer" prefix

MIC_RATE = 16000
MIC_CHUNK = 800  # 50 ms of 16 kHz audio per binary frame

STT_URL = "wss://streaming.assemblyai.com/v3/ws?" + urlencode({
    "speech_model": "universal-3-6-pro",
    "sample_rate": MIC_RATE,
})
TTS_URL = "wss://tts.us.assemblyai.com/v1/ws?" + urlencode({
    "voice_id": "49c443a4-8a1d-5f7e-b70b-98bdbd9823c3",  # Audrey
    "language": "english",
    "sample_rate": 24000,
    "encoding": "pcm_s16le",
})
LLM_URL = "https://llm-gateway.assemblyai.com/v1/chat/completions"
LLM_MODEL = "claude-haiku-4-5-20251001"  # any model from the LLM Gateway model list

SYSTEM_PROMPT = (
    "You are a friendly voice assistant. Answer in one or two short spoken "
    "sentences, with no markdown or lists. Use get_weather for weather questions."
)
GREETING = "Hi there! Ask me about the weather anywhere in the world."

Both WebSockets authenticate with the raw API key in the Authorization header at connect time. Everything else rides on the query string. Speech-to-text takes speech_model (singular) and the sample_rate of the audio you will send; 16-bit PCM is the default encoding. Text-to-speech takes the voice, the language and the output format. Set voice_id explicitly: the sample uses Audrey's ID, and the voice library lists the rest. 24 kHz pcm_s16le is the model's native output, so nothing is converted on the way.

2. Speech-to-text, client to server: binary audio

async def send_microphone(stt):
    """Stream the microphone to the STT socket as binary frames."""
    loop = asyncio.get_running_loop()
    frames = asyncio.Queue()

    def on_audio(indata, frame_count, time_info, status):
        loop.call_soon_threadsafe(frames.put_nowait, bytes(indata))

    with sd.RawInputStream(samplerate=MIC_RATE, channels=1, dtype="int16",
                           blocksize=MIC_CHUNK, callback=on_audio):
        while True:
            await stt.send(await frames.get())  # bytes -> binary frame

Audio is the only binary frame in either protocol. Send ~50 ms chunks of 16 kHz, 16-bit mono PCM as they come off the microphone, no faster than real time. The sounddevice callback runs on an audio thread, so it hands each chunk to the event loop with call_soon_threadsafe.

Speech-to-text, client → server What it does
binary frame Raw PCM audio at the sample_rate you connected with
{"type": "Terminate"} Finalizes the open turn, sends Termination, closes the session
{"type": "UpdateConfiguration", ...} Changes prompt, keyterms_prompt and turn settings mid-session (not used here)
{"type": "ForceEndpoint"} Ends the current turn now (not used here)

3. Speech-to-text, server to client: Begin, SpeechStarted, Turn, Termination

# "yes" and "no" are left out on purpose: they are real answers. Edit for your domain.
BACKCHANNELS = frozenset({
    "mhm", "mm", "mmhm", "uh", "huh", "um", "hmm", "ah", "oh",
    "yeah", "yep", "okay", "ok", "right", "gotcha",
})


def is_backchannel(text):
    words = text.lower().translate(str.maketrans("", "", string.punctuation)).split()
    return len(words) < 2 or all(w in BACKCHANNELS for w in words)


async def read_transcripts(stt, agent):
    """Handle every STT frame: Begin, SpeechStarted, Turn and Termination."""
    async for raw in stt:
        msg = json.loads(raw)
        kind = msg.get("type")

        if kind == "Begin":
            print(f"[stt] session {msg['id']}")
        elif kind == "SpeechStarted":
            print("[stt] speech started")  # barge-in waits for words, below
        elif kind == "Turn":
            text = msg.get("transcript", "").strip()
            if not text:
                continue
            if agent.speaking:
                if is_backchannel(text):
                    continue  # "mhm" while the agent talks is not an interruption
                await agent.interrupt()
            if msg.get("end_of_turn"):
                print(f"\n[user] {text}")
                agent.turns.put_nowait(text)
            else:
                print(f"[user ...] {text}", end="\r", flush=True)
        elif kind == "Termination":
            print(f"[stt] closed after {msg.get('audio_duration_seconds')} s of audio")
            return
        # Ignore frame types you do not handle; the protocol can add more.

Turn frames carry the transcript so far. While the user is talking they arrive with end_of_turn false, and the program overwrites one terminal line with each. When the model decides the user is done, a Turn arrives with end_of_turn true: that transcript goes on the queue for the LLM. Turn detection runs inside Universal-3.6 Pro Realtime, so there's no silence timer to tune; leave the turn settings at their defaults unless you have a reason to change them.

Barge-in is gated on words rather than on SpeechStarted. While the agent is speaking, a Turn made only of backchannels ("mhm", "okay", "yeah") or shorter than two words is dropped, so the agent doesn't stop mid-sentence every time the user murmurs agreement. Anything longer interrupts. This is the same filter the voice agent best practices recommend for direct-WebSocket integrations; edit the word list for your domain.

Speech-to-text, server → client Carries What to do
Begin id, expires_at Log the session id for support requests
SpeechStarted timestamp Speech onset; wait for words before interrupting
Turn transcript, end_of_turn, words Partial: update the UI. Final: send to the LLM
Termination audio_duration_seconds The session closed cleanly after your Terminate

4. A speaker you can clear

class Speaker:
    """Plays raw 16-bit PCM as it arrives. clear() drops whatever hasn't played."""

    def __init__(self, sample_rate):
        self._buf = bytearray()
        self._lock = threading.Lock()
        self._stream = sd.RawOutputStream(samplerate=sample_rate, channels=1,
                                          dtype="int16", callback=self._fill)
        self._stream.start()

    def _fill(self, outdata, frame_count, time_info, status):
        n = len(outdata)
        with self._lock:
            chunk = bytes(self._buf[:n])
            del self._buf[:n]
        outdata[:len(chunk)] = chunk
        outdata[len(chunk):] = b"\x00" * (n - len(chunk))  # silence when idle

    def play(self, pcm):
        with self._lock:
            self._buf += pcm

    def clear(self):
        with self._lock:
            self._buf.clear()

    @property
    def busy(self):
        return len(self._buf) > 0

    def close(self):
        self._stream.stop()
        self._stream.close()

Text-to-speech audio arrives several times faster than real time, so the program can't play it the moment it lands. Speaker keeps a buffer that the audio callback drains at playback speed. Barge-in needs two things from it: clear(), which throws away audio that has arrived but not played, and busy, which tells the transcript handler whether the agent is still talking.

5. Text-to-speech: Generate, Flush, Cancel and every server frame

class Agent:
    """Owns the TTS socket: sends Generate/Flush/Cancel and plays the audio."""

    def __init__(self, tts, http, sample_rate):
        self.tts = tts
        self.http = http
        self.speaker = Speaker(sample_rate)
        self.turns = asyncio.Queue()
        self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]
        self.in_flight = False  # a request was flushed and its FlushDone hasn't arrived
        self.dropping = 0       # Cancels sent whose Cancelled hasn't arrived yet

    @property
    def speaking(self):
        return self.in_flight or self.speaker.busy

    async def say(self, text):
        self.in_flight = True
        await self.tts.send(json.dumps({"type": "Generate", "text": text}))
        await self.tts.send(json.dumps({"type": "Flush"}))

    async def interrupt(self):
        print("\n[agent] barge-in: stopping speech")
        self.dropping += 1
        self.in_flight = False
        self.speaker.clear()
        await self.tts.send(json.dumps({"type": "Cancel"}))


async def read_speech(tts, agent):
    """Handle every TTS frame after Begin."""
    async for raw in tts:
        msg = json.loads(raw)
        kind = msg.get("type")

        if kind == "Audio":
            if agent.dropping:
                continue  # the tail of a cancelled reply: drop it
            agent.speaker.play(base64.b64decode(msg["audio"]))
        elif kind == "FlushDone":
            agent.in_flight = False
            print(f"[tts] reply {msg['flush_id']}: {msg['audio_duration_ms']} ms of audio")
        elif kind == "Cancelled":
            agent.dropping -= 1
            print(f"[tts] cancelled after {msg['audio_duration_seconds']} s")
        elif kind == "Termination":
            print(f"[tts] closed: {msg['total_input_char_length']} characters, "
                  f"{msg['audio_duration_seconds']} s of audio")
            return
        elif kind == "Error":
            raise RuntimeError(f"TTS {msg['error_code']} {msg['error_type']}: {msg['error']}")
        # WordBoundaries is on by default on the raw wire; this agent ignores it.


async def keep_tts_alive(agent):
    """The TTS session closes after two idle minutes; KeepAlive resets the timer."""
    while True:
        await asyncio.sleep(60)
        if not agent.speaking:
            await agent.tts.send(json.dumps({"type": "KeepAlive"}))

One text-to-speech session serves the whole conversation. Each reply is one request: a Generate with the text, then a Flush, which concludes the request and guarantees everything you sent is spoken. Short text with no Flush stays silent, so never skip it. The server answers with Audio frames, base64 raw PCM with no header, and then a FlushDone once the request's audio has all been sent.

Barge-in is a Cancel. It stops the reply in flight and discards anything queued, and the server answers with Cancelled. A short tail of Audio frames that were already on the way can land before Cancelled does, which is why dropping counts outstanding cancels and the reader skips audio until they're answered. The next reply takes a new flush_id and needs no wait.

Text-to-speech frame Direction What it does
Begin server → client Always first: session id, expires_at and the effective configuration
Generate client → server Appends text, up to 2,000 characters per frame, joined exactly as sent
Flush client → server Concludes the request; answered by one FlushDone
Audio server → client Base64 audio in audio, tagged with its flush_id
FlushDone server → client All of that request's audio has been sent (not yet played); carries audio_duration_ms
WordBoundaries server → client Per-word timings, on by default on the raw wire; useful for captions
Cancel / Cancelled both Barge-in: stop the reply in flight and drop queued text
KeepAlive client → server Restarts the two-minute idle timer, and nothing else
Terminate / Termination both Clean end: unflushed text is spoken, then a summary arrives and the socket closes with 1000
Error server → client Sent before every failure close; error_code equals the close code that follows

6. The LLM over HTTP, with tool calling

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get the current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]


def get_weather(city):
    return {"city": city, "conditions": "sunny", "high_c": 22}  # swap in a real API


async def think(http, messages):
    """One LLM turn over plain HTTP, running tool calls until the model answers."""
    while True:
        response = await http.post(LLM_URL, headers=HEADERS, json={
            "model": LLM_MODEL,
            "messages": messages,
            "tools": TOOLS,
            "max_tokens": 300,
        })
        response.raise_for_status()
        message = response.json()["choices"][0]["message"]
        calls = message.get("tool_calls") or []
        if not calls:
            answer = message.get("content") or ""
            messages.append({"role": "assistant", "content": answer})
            return answer

        messages.append({"role": "assistant", "content": message.get("content"),
                         "tool_calls": calls})
        for call in calls:
            args = call["function"]["arguments"]
            args = json.loads(args or "{}") if isinstance(args, str) else args
            print(f"[tool] {call['function']['name']}({args})")
            if call["function"]["name"] == "get_weather":
                result = get_weather(**args)
            else:
                result = {"error": "unknown tool"}
            messages.append({"role": "tool", "tool_call_id": call["id"],
                             "content": json.dumps(result)})


async def reply_loop(agent):
    """Each final user turn becomes one LLM call and one TTS request."""
    while True:
        text = await agent.turns.get()
        agent.messages.append({"role": "user", "content": text})
        answer = await think(agent.http, agent.messages)
        print(f"[agent] {answer}")
        await agent.say(answer)

The LLM is the only stage that isn't a socket. LLM Gateway speaks the OpenAI chat completions format, so the request is a JSON body with model, messages and tools, and you can switch models by changing one string from the available models. Tool calling lives entirely in this request: when the model wants weather, it answers with tool_calls instead of text, the program runs get_weather, appends the result as a tool message, and asks again until the model replies in words. The reply loop then hands that text to the text-to-speech socket.

Keep replies short in the system prompt. A voice agent reads its answer aloud, and a short answer starts sounding sooner and gives the user an earlier chance to cut in.

7. Wiring it together, and shutting down cleanly

async def wait_for_termination(ws):
    async for raw in ws:
        if json.loads(raw).get("type") == "Termination":
            return


async def main():
    async with websockets.connect(STT_URL, additional_headers=HEADERS) as stt, \
               websockets.connect(TTS_URL, additional_headers=HEADERS) as tts, \
               httpx.AsyncClient(timeout=30) as http:

        # TTS speaks first: Begin, or an Error in its place.
        begin = json.loads(await tts.recv())
        if begin["type"] == "Error":
            raise SystemExit(f"TTS {begin['error_code']}: {begin['error']}")
        config = begin["configuration"]
        print(f"[tts] session {begin['id']}: {config['sample_rate']} Hz {config['encoding']}")

        agent = Agent(tts, http, config["sample_rate"])
        await agent.say(GREETING)

        tasks = [asyncio.create_task(job) for job in (
            send_microphone(stt),
            read_transcripts(stt, agent),
            read_speech(tts, agent),
            reply_loop(agent),
            keep_tts_alive(agent),
        )]
        print("Listening. Use headphones, then press Ctrl+C to stop.")
        try:
            await asyncio.gather(*tasks)
        except asyncio.CancelledError:
            pass
        except websockets.ConnectionClosedError as e:
            print(f"socket closed: {e}")  # close code and reason
        finally:
            for task in tasks:
                task.cancel()
            agent.speaker.close()
            # Terminate both sessions and wait for their summaries.
            for name, ws in (("stt", stt), ("tts", tts)):
                try:
                    await ws.send(json.dumps({"type": "Terminate"}))
                    await asyncio.wait_for(wait_for_termination(ws), timeout=5)
                    print(f"[{name}] terminated cleanly")
                except (websockets.ConnectionClosed, asyncio.TimeoutError):
                    pass


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

main() opens both sockets and reads the text-to-speech Begin before sending anything, so a bad key or parameter surfaces as a readable Error instead of a failed send(). It plays the speaker at the sample rate that Begin reports, speaks a greeting, and runs the five tasks side by side. On Ctrl+C it sends Terminate on both sockets and waits for each Termination, which is how you know the last transcript was finalized and every character you sent was accounted for.

Open your first sockets

Get a free API key and stream to Universal-3.6 Pro Realtime and AssemblyAI Text-to-Speech in minutes, with one key for both sockets.

Sign up free

How barge-in works

Interruptions touch both sockets and the speaker, in this order:

  1. While audio is buffered or a reply is in flight, agent.speaking is true.
  2. The user talks. Speech-to-text sends a Turn with two or more words that aren't all backchannels.
  3. interrupt() clears the speaker buffer, so playback stops at once, and sends Cancel on the text-to-speech socket.
  4. Any Audio frames that were already on the way are dropped until Cancelled arrives.
  5. When the user's turn ends, the final transcript goes to the LLM like any other turn, and the new reply is spoken under a new flush_id.

Error handling

The two sockets report failures differently. Text-to-speech sends an Error frame before every failure close, with error_code (the close code that follows), a stable error_type, the param at fault, and whether a retry can succeed. Speech-to-text closes the socket with a close code and a reason, which the websockets library raises as ConnectionClosedError. The text-to-speech codes you are most likely to meet:

Close code Meaning Recovery
1008 Unauthorized: missing, invalid or expired key, or an account problem Fix the key or account; retrying unchanged fails again
3006 Bad input (a parameter, a malformed frame, a Generate over 2,000 characters), or two minutes idle Read param and fix the request; for inactivity_timeout, send KeepAlive
3008 The session reached its one-hour lifetime Open a new session
3009 Too many new sessions this minute Wait retry_after_seconds, then reconnect
3010 Text or control frames sent too fast, such as a Flush per token Flush per sentence or per reply
3011 Audio not read fast enough Keep the reader loop reading; buffer in your app
3012 The voice_id can't be used Check the ID against the voice library
3005 / 1012 Temporarily unavailable, at capacity, or restarting Back off and reconnect

The full list, with every error_type, is in the text-to-speech error handling and limits docs. For a production agent, catch each failure, log the session id from Begin, and reopen only the socket that closed: the conversation history lives in agent.messages, so a fresh text-to-speech session can pick up the next reply.

Tuning the agent

  • Voice and language: change voice_id and language in TTS_URL. AssemblyAI-TTS-1 speaks every voice in all six of its languages, and language takes the full English name, such as spanish.
  • Speech-to-text context: add prompt or keyterms_prompt to STT_URL, or send an UpdateConfiguration frame mid-call when the conversation moves to names, dates or account numbers.
  • The model: change LLM_MODEL. A small, fast model keeps the gap between the user's last word and the agent's first word short.
  • Faster first audio: stream the LLM's tokens into several Generate frames and send a Flush at each sentence end. The server starts speaking once the first sentence is complete. See optimizing accuracy and latency.

Common issues

The agent keeps interrupting itself. Mic feedback: the microphone hears the agent and speech-to-text transcribes it. Use headphones, or move the client to a browser, which applies echo cancellation to the microphone.

Audio sounds garbled or pitched up/down. A sample-rate mismatch. The microphone must match the sample_rate on STT_URL (16 kHz here), and the speaker must play at the sample_rate that the text-to-speech Begin reports (24 kHz here).

The agent never speaks. Text was sent with Generate but never flushed. Short text is buffered until a Flush, and a session that never flushes stays silent until the idle timeout closes it.

3006 close after a quiet spell. Two minutes passed with no frame on the text-to-speech socket. keep_tts_alive() sends a KeepAlive every 60 seconds while the agent is quiet; keep it running.

1008 close on connect. Your API key is missing, expired, or wrong. Grab a fresh one from the AssemblyAI dashboard and re-check the environment variable.

Build the full loop

Speech-to-text, LLM Gateway and Text-to-Speech on one API key. Sign up, paste the seven sections, and talk to your agent.

Get your API key

Frequently asked questions

Can I build an AssemblyAI voice agent without an SDK?

Yes. Universal-3.6 Pro Realtime and AssemblyAI Text-to-Speech are both plain WebSocket protocols with JSON control frames, and LLM Gateway is an OpenAI-compatible HTTPS endpoint. Any language with a WebSocket client and an HTTP client can run the full speech-to-text, LLM and text-to-speech loop, authenticated with one API key in the Authorization header.

Which WebSocket frames does a raw voice agent need to handle?

On the speech-to-text socket you send binary audio and Terminate, and you receive Begin, SpeechStarted, Turn and Termination. On the text-to-speech socket you send Generate, Flush, Cancel, KeepAlive and Terminate, and you receive Begin, Audio, FlushDone, WordBoundaries, Cancelled, Termination and Error. Ignore any frame type you don't recognize, because both protocols can add new ones.

How do I handle barge-in over raw WebSockets?

When a speech-to-text Turn arrives with real words while the agent is speaking, stop local playback and send {"type": "Cancel"} on the text-to-speech socket. Drop any Audio frames that arrive before the matching Cancelled, since they belong to the reply you cancelled. Filter backchannels such as "mhm" first, so the agent doesn't stop every time the user murmurs agreement.

Where does tool calling happen in a raw WebSocket voice agent?

In the LLM request. Send OpenAI-style tools with your chat completion to LLM Gateway; when the response contains tool_calls, run each function, append the results as tool messages, and call the model again until it answers in text. Then send that text to the text-to-speech socket.

Why does the text-to-speech socket stay silent after I send text?

Short text is buffered until you send {"type": "Flush"}. Flush concludes the request, guarantees everything you sent is spoken, and is answered by one FlushDone. Send it once per reply or per sentence; flushing every token makes speech choppy and can exceed the control-frame rate limit.

How long can a text-to-speech WebSocket session stay open?

Up to one hour, and the server closes it after two minutes with no frame in either direction. Send {"type": "KeepAlive"} while the agent is waiting on a quiet user, and open a new session for conversations longer than an hour. Rate limits count new sessions per minute, so one long-lived session per conversation is the intended pattern.