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_URLand 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.
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.
How barge-in works
Interruptions touch both sockets and the speaker, in this order:
- While audio is buffered or a reply is in flight,
agent.speakingis true. - The user talks. Speech-to-text sends a
Turnwith two or more words that aren't all backchannels. interrupt()clears the speaker buffer, so playback stops at once, and sendsCancelon the text-to-speech socket.- Any
Audioframes that were already on the way are dropped untilCancelledarrives. - 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_idandlanguageinTTS_URL. AssemblyAI-TTS-1 speaks every voice in all six of its languages, andlanguagetakes the full English name, such asspanish. - Speech-to-text context: add
promptorkeyterms_prompttoSTT_URL, or send anUpdateConfigurationframe 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
Generateframes and send aFlushat 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.
Speech-to-text, LLM Gateway and Text-to-Speech on one API key. Sign up, paste the seven sections, and talk to your agent.
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.