Why combine Agora and AssemblyAI's voice agent stack
Agora gives you battle-tested WebRTC: low-latency audio routing across 200+ countries, automatic codec negotiation, jitter buffers, NAT traversal, and SDKs for every client platform. AssemblyAI gives you the three AI layers a voice agent needs, on one API key: Universal-3.6 Pro Realtime to listen, an LLM through LLM Gateway to think, and the AssemblyAI Text-to-Speech API to talk back.
This tutorial builds a server-side bot that joins an Agora RTC channel like any other participant, listens to the caller, and answers out loud. Agora carries the audio; the bot's Python code owns the conversation loop. If you only need live transcripts of an Agora call, start with our Agora transcription tutorial instead.
| Layer | Service | What it does in this bot |
|---|---|---|
| Transport | Agora RTC channel | The bot joins with Agora's Python Server Gateway SDK, subscribes to the caller's audio, and publishes its own audio track |
| Listening | Universal-3.6 Pro Realtime | Streaming speech-to-text with built-in turn detection: Turn events with end_of_turn |
| Thinking | LLM Gateway | OpenAI-compatible chat completions; swap in any model from the available models list |
| Speaking | AssemblyAI Text-to-Speech | One streaming session for the whole call, returning 16 kHz PCM that goes straight into Agora |
| Barge-in | Your code | Real speech during a reply cancels the speech request and clears Agora's outbound buffer |
Running each layer as its own model, with the loop in your code, gives you a transcript of every turn before the LLM sees it, the freedom to change the LLM or the voice without touching the rest, and tool calling in a standard LLM request. On AssemblyAI's English voice-agent benchmark, Universal-3.6 Pro Realtime posts a 5.19% mean word error rate, and on the Pipecat open benchmark it delivers the final transcript a median 307 ms after the speaker stops. The voice comes from AssemblyAI-TTS-1, a model built for conversational voice agents and trained on millions of hours of audio, including real conversational data. It delivers frontier-level quality, runs faster and costs less than other production voice agent TTS models, and scales with unlimited concurrency.
Get a free AssemblyAI API key and run this tutorial: streaming speech-to-text, LLM Gateway, and text-to-speech on one key.
Architecture
The bot runs three loops at once: Agora's SDK thread hands it the caller's audio, a speech-to-text thread streams that audio to Universal-3.6 Pro Realtime, and the main loop turns each finished user turn into a spoken reply.
Caller (browser or mobile app on the Agora SDK)
│ WebRTC
▼
Agora channel
│ 16 kHz PCM frames from the caller
▼
bot.py (Agora Server Gateway SDK, joined as uid 9999)
│
├─► Universal-3.6 Pro Realtime ──► Turn events (end_of_turn)
│
├─► LLM Gateway ──► reply text
│
├─► AssemblyAI Text-to-Speech session ──► 16 kHz PCM
│
▼ push_audio_pcm_data()
Agora channel ──► the caller hears the replyEvery hop runs at 16 kHz, 16-bit, mono PCM. Agora delivers the caller's audio at 16 kHz, Universal-3.6 Pro Realtime accepts it as is, and the text-to-speech session is opened with sample_rate=16000, so the server returns audio at the rate Agora publishes. The model's native output is 24 kHz; the service resamples on its side, so the bot needs no resampling code and no SciPy.
Prerequisites
- Python 3.10+ (Agora's Python Server Gateway SDK requires it)
- A Linux x86-64 server for production (Agora lists Ubuntu 18.04+ and CentOS 7+). The SDK also runs on macOS 13+ for development and testing. There's no Windows build; use WSL2 or a Linux container.
- An Agora project with an App ID, and a temporary RTC token if App Certificate is enabled
- An AssemblyAI API key. LLM Gateway uses the same key; check that your plan includes it.
Quick start
1. Create a project and install
mkdir agora-voice-agent && cd agora-voice-agent
python3 -m venv .venv
source .venv/bin/activate
pip install -U assemblyai agora_python_server_sdk python-dotenvText-to-speech is in the AssemblyAI Python SDK from version 1.8.2, so install with -U. This tutorial was written against agora_python_server_sdk 2.4.10; pin that version (agora_python_server_sdk==2.4.10) if a later release changes the API.
2. Configure credentials
Create a .env file in the project folder:
ASSEMBLYAI_API_KEY=your_assemblyai_api_key
AGORA_APP_ID=your_agora_app_id
# Leave empty if your Agora project has App Certificate disabled
AGORA_TOKEN=
AGORA_CHANNEL=voice-agent-demo
AGORA_BOT_UID=9999If your Agora project has App Certificate disabled, leave AGORA_TOKEN blank. If it's enabled, generate a temporary RTC token in the Agora Console for the same channel name and the bot's UID (9999), as described in Agora's Server Gateway Python quickstart. Temporary tokens expire, so mint them on your server in production.
3. Save the bot and run it
Copy the six code blocks from How it works, in order, into one file named bot.py. Then run:
python bot.pyThe bot joins the channel and waits for someone to speak before it greets them:
Joined Agora channel 'voice-agent-demo' as uid 9999
Waiting for someone to join and speak...
Caller uid 1234 is on the line
Speech-to-text session 7f1c...
Bot: Hi, I just joined the call. What can I help you with?
Caller: Can you hear me okay?
Bot: I can hear you clearly. What would you like to do today?4. Connect a client
Open Agora's Web demo, enter your App ID, the same channel name, and a different UID, and click Join. If App Certificate is enabled, the demo needs its own token for its UID. Speak, and the bot answers through your browser: Universal-3.6 Pro Realtime transcribes you, the LLM writes the reply, and AssemblyAI Text-to-Speech speaks it into the channel.
How it works
The six blocks below make up bot.py, top to bottom.
1. Set up the three AssemblyAI clients
# bot.py
import os
import queue
import string
import threading
import time
import assemblyai as aai
from assemblyai.streaming.v3 import (
BeginEvent,
RealTimeError,
RealTimeEvents,
RealTimeParameters,
RealTimeTranscriber,
RealTimeTranscriberOptions,
TurnEvent,
)
from dotenv import load_dotenv
# Agora Server Gateway SDK (pip install agora_python_server_sdk)
from agora.rtc.agora_base import (
AgoraServiceConfig,
AudioPublishType,
AudioScenarioType,
AudioSubscriptionOptions,
ChannelProfileType,
ClientRoleType,
RTCConnConfig,
RtcConnectionPublishConfig,
)
from agora.rtc.agora_service import AgoraService
from agora.rtc.audio_frame_observer import IAudioFrameObserver
load_dotenv()
API_KEY = os.environ["ASSEMBLYAI_API_KEY"]
AGORA_APP_ID = os.environ["AGORA_APP_ID"]
AGORA_TOKEN = os.environ.get("AGORA_TOKEN", "") # empty if App Certificate is off
AGORA_CHANNEL = os.environ.get("AGORA_CHANNEL", "voice-agent-demo")
AGORA_BOT_UID = os.environ.get("AGORA_BOT_UID", "9999")
# One rate end to end: Agora delivers 16 kHz frames, speech-to-text takes
# 16 kHz, and text-to-speech returns 16 kHz. No resampling in this bot.
SAMPLE_RATE = 16_000
BYTES_PER_MS = SAMPLE_RATE * 2 // 1000 # 16-bit mono: 32 bytes per millisecond
LLM_MODEL = "claude-haiku-4-5-20251001" # any model on LLM Gateway
GREETING = "Hi, I just joined the call. What can I help you with?"
SYSTEM_PROMPT = f"""You are a friendly voice assistant on a live call.
Your replies are spoken aloud: one or two short sentences, no lists or markdown.
You have already greeted the caller with: {GREETING}"""
# Listen: Universal-3.6 Pro Realtime streaming speech-to-text
stt = RealTimeTranscriber(RealTimeTranscriberOptions(), api_key=API_KEY)
# Think: an LLM through LLM Gateway, on the same API key
llm = aai.LLMGateway(api_key=API_KEY)
# Speak: one text-to-speech session for the whole call, at Agora's 16 kHz
tts = aai.TTSSession(
aai.TTSConfig(
voice_id="49c443a4-8a1d-5f7e-b70b-98bdbd9823c3", # Audrey
language="english",
sample_rate=SAMPLE_RATE,
encoding="pcm_s16le",
),
api_key=API_KEY,
)
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
turns = queue.Queue() # finished caller turns, in order
speaking = threading.Event() # set while the bot's reply is playing in the channel
interrupted = threading.Event() # set when the caller talks over the bot
last_spoke_at = 0.0Three clients, one key. The streaming transcriber listens, the LLM Gateway client thinks, and one text-to-speech session speaks for the whole call, taking one request per reply. The voice is set explicitly to Audrey from the voice library, and the session asks for 16 kHz pcm_s16le so its audio drops straight into Agora. LLM_MODEL can be any model on the available models list.
2. Join the channel and hear the caller
# bot.py (continued): joining the channel and hearing the caller
class CallerAudio(IAudioFrameObserver):
"""Receives each remote user's 16 kHz PCM frames, on an Agora SDK thread."""
def __init__(self):
super().__init__()
self.frames = queue.Queue()
self.caller_uid = None
self.joined = threading.Event()
def on_playback_audio_frame_before_mixing(
self, agora_local_user, channelId, uid, frame, vad_result_state, vad_result_bytearray
):
if self.caller_uid is None:
self.caller_uid = uid # follow the first person who speaks
self.joined.set()
if uid == self.caller_uid:
# Copy only: Agora callbacks must not call SDK APIs or do heavy work
self.frames.put(bytes(frame.buffer))
return 1
def chunks(self):
"""Yield 50 ms chunks for speech-to-text. Ends when None is queued."""
buf = bytearray()
while True:
frame = self.frames.get()
if frame is None:
return
buf += frame
if len(buf) >= BYTES_PER_MS * 50:
yield bytes(buf)
buf.clear()
def join_agora(caller):
service = AgoraService()
config = AgoraServiceConfig()
config.appid = AGORA_APP_ID
config.audio_scenario = AudioScenarioType.AUDIO_SCENARIO_AI_SERVER
service.initialize(config)
connection = service.create_rtc_connection(
RTCConnConfig(
client_role_type=ClientRoleType.CLIENT_ROLE_BROADCASTER,
channel_profile=ChannelProfileType.CHANNEL_PROFILE_LIVE_BROADCASTING,
auto_subscribe_audio=1,
audio_subs_options=AudioSubscriptionOptions(
pcm_data_only=1,
bytes_per_sample=2,
number_of_channels=1,
sample_rate_hz=SAMPLE_RATE,
),
),
RtcConnectionPublishConfig(
audio_scenario=AudioScenarioType.AUDIO_SCENARIO_AI_SERVER,
is_publish_audio=True,
audio_publish_type=AudioPublishType.AUDIO_PUBLISH_TYPE_PCM,
),
)
ret = connection.connect(AGORA_TOKEN, AGORA_CHANNEL, AGORA_BOT_UID)
if ret < 0:
raise RuntimeError(f"Agora connect failed: {ret}")
connection.get_local_user().set_playback_audio_frame_before_mixing_parameters(1, SAMPLE_RATE)
connection.register_audio_frame_observer(caller, 0, None) # 0: Agora's own VAD off
connection.publish_audio()
print(f"Joined Agora channel '{AGORA_CHANNEL}' as uid {AGORA_BOT_UID}")
return service, connectionThis is the Agora side of the bridge, written against agora_python_server_sdk 2.4.10. The connection is created with the AUDIO_SCENARIO_AI_SERVER scenario and a PCM publish config, so the SDK creates the bot's outbound audio track for you. set_playback_audio_frame_before_mixing_parameters(1, 16000) asks for mono 16 kHz frames, and the observer's on_playback_audio_frame_before_mixing callback then receives each remote user's audio separately, in 10 ms frames.
Agora calls the observer on its own native thread, and its SDK notes say callbacks must not call SDK APIs or do heavy work. So the callback only copies the frame into a queue. chunks() drains the queue on the speech-to-text thread and batches frames into 50 ms chunks. The bot follows the first participant who speaks, which suits a one-to-one call.
3. Publish the bot's voice into the channel
# bot.py (continued): publishing the bot's voice
class BotVoice:
"""Pushes reply audio into the channel as the bot's audio track."""
def __init__(self, connection):
self.connection = connection
self.pending = bytearray()
def push(self, pcm):
# Agora takes PCM in whole milliseconds (32 bytes at 16 kHz mono)
self.pending += pcm
whole = len(self.pending) - len(self.pending) % BYTES_PER_MS
if whole:
self.connection.push_audio_pcm_data(bytes(self.pending[:whole]), SAMPLE_RATE, 1)
del self.pending[:whole]
def finish(self):
if self.pending: # pad the last partial millisecond with silence
self.push(bytes(BYTES_PER_MS - len(self.pending)))
def playing(self):
return not self.connection.is_push_to_rtc_completed()
def stop(self):
self.pending.clear()
self.connection.interrupt_audio() # drop audio Agora has bufferedpush_audio_pcm_data() takes PCM in whole milliseconds: at 16 kHz mono that's a multiple of 32 bytes, and any other length is refused. Text-to-speech chunks vary in size, so BotVoice carries any partial millisecond over to the next chunk and pads the last one with silence.
In the AI server scenario, the SDK buffers the audio you push and plays it into the channel in real time, so you can push a reply as fast as it's synthesized. is_push_to_rtc_completed() reports when that buffer has played out, and interrupt_audio() drops whatever is still queued.
4. Listen for turns, and detect barge-in
# bot.py (continued): listening and barge-in
BACKCHANNELS = frozenset({
"mhm", "mm", "mmhm", "uh", "uhhuh", "huh", "um", "umm", "hmm", "hm",
"ah", "oh", "yeah", "yep", "yup", "okay", "ok", "right", "alright", "gotcha",
})
_PUNCT = str.maketrans("", "", string.punctuation)
def is_filler(text, min_words):
tokens = text.lower().translate(_PUNCT).split()
return len(tokens) < min_words or all(t in BACKCHANNELS for t in tokens)
def on_begin(client, event: BeginEvent):
print(f"Speech-to-text session {event.id}")
def on_turn(client, event: TurnEvent):
text = event.transcript.strip()
if not text:
return
if speaking.is_set():
if is_filler(text, min_words=2):
return # "mhm", "yeah": let the bot keep talking
interrupted.set() # a real interruption: stop the reply
elif time.monotonic() - last_spoke_at < 1.0 and is_filler(text, min_words=1):
return # a trailing "mhm" right after the bot finished
if event.end_of_turn:
print(f"Caller: {text}")
turns.put(text)
def on_error(client, error: RealTimeError):
print(f"Speech-to-text error: {error}")Universal-3.6 Pro Realtime sends Turn events while the caller talks: partial transcripts first, then one with end_of_turn set when the model decides the caller has finished. Turn detection is built in, so the bot doesn't run a silence timer of its own.
The same events drive barge-in. While the bot is speaking, a transcript with real content means the caller is talking over it, so on_turn sets the interrupted flag. Backchannels such as "mhm" or "yeah" are filtered out first, so the bot keeps talking through them. This is the pattern AssemblyAI's voice agent guide recommends for direct integrations.
5. Think, then speak with barge-in
# bot.py (continued): thinking and speaking
def think(user_text):
messages.append({"role": "user", "content": user_text})
completion = llm.chat.completions.create(
model=LLM_MODEL,
messages=messages,
max_tokens=200,
)
reply = " ".join(c.message.content for c in completion.choices if c.message.content).strip()
messages.append({"role": "assistant", "content": reply})
return reply
def speak(text, voice):
global last_spoke_at
if not text:
return
print(f"Bot: {text}")
interrupted.clear()
speaking.set()
try:
for chunk in tts.speak(text):
if interrupted.is_set():
tts.cancel() # barge-in: the server drops the rest of this reply
break
voice.push(chunk)
else:
voice.finish()
# Synthesis runs faster than real time, so Agora holds a buffer.
# Wait for it to play out, or for the caller to cut in.
while voice.playing() and not interrupted.is_set():
time.sleep(0.05)
if interrupted.is_set():
voice.stop()
print(" (interrupted)")
finally:
speaking.clear()
last_spoke_at = time.monotonic()think() sends the conversation to LLM Gateway and keeps the history in messages. speak() streams the reply through the text-to-speech session and pushes each chunk into Agora. Synthesis runs faster than real time, so most of a reply sits in Agora's buffer while it plays; speak() waits for it to play out. If the caller cuts in, it calls tts.cancel() to stop a reply still being synthesized and voice.stop() to clear what Agora has buffered, so the bot goes quiet within a moment.
6. Run the main loop
# bot.py (continued): the main loop
def main():
caller = CallerAudio()
service, connection = join_agora(caller)
voice = BotVoice(connection)
print("Waiting for someone to join and speak...")
caller.joined.wait()
print(f"Caller uid {caller.caller_uid} is on the line")
stt.on(RealTimeEvents.Begin, on_begin)
stt.on(RealTimeEvents.Turn, on_turn)
stt.on(RealTimeEvents.Error, on_error)
stt.connect(
RealTimeParameters(
speech_model="universal-3-6-pro",
sample_rate=SAMPLE_RATE,
)
)
tts.connect()
threading.Thread(target=stt.stream, args=(caller.chunks(),), daemon=True).start()
try:
speak(GREETING, voice)
while True:
try:
user_text = turns.get(timeout=30)
except queue.Empty:
tts.keep_alive() # the TTS session closes after 2 idle minutes
continue
speak(think(user_text), voice)
except KeyboardInterrupt:
print("\nLeaving the channel...")
finally:
caller.frames.put(None)
stt.disconnect(terminate=True)
tts.terminate()
connection.disconnect()
connection.release()
service.release()
if __name__ == "__main__":
main()The main loop waits for a caller, connects both AssemblyAI sessions, streams the caller's audio in a background thread, greets, and then answers each turn. A text-to-speech session closes after two idle minutes, so the loop sends keep_alive() when the line is quiet; a session lasts up to an hour (see limits). On exit it terminates both sessions and releases the Agora connection.
Tuning
Pick a different voice
tts = aai.TTSSession(
aai.TTSConfig(
voice_id="aai-sara", # copy any voice_id from the voice library
language="spanish",
sample_rate=SAMPLE_RATE,
encoding="pcm_s16le",
),
api_key=API_KEY,
)Browse the voice library for thousands of voices across six languages: English, Spanish, French, German, Italian, and Portuguese. Copy a voice's voice_id and set language to match your callers. Universal-3.6 Pro Realtime transcribes 32 languages; to steer it toward your callers' language, pass language_codes when you connect:
stt.connect(
RealTimeParameters(
speech_model="universal-3-6-pro",
sample_rate=SAMPLE_RATE,
language_codes=["es"], # steer speech-to-text toward Spanish
)
)Adjust turn detection
Start with the defaults. They suit conversational turns, and the bot above doesn't set any turn-detection parameters. If your callers dictate account numbers or email addresses and get cut off mid-entity, the voice agent best practices explain the silence settings and how to widen them only for that part of the call, with a mid-session update, rather than for every turn.
Boost domain-specific words
stt.connect(
RealTimeParameters(
speech_model="universal-3-6-pro",
sample_rate=SAMPLE_RATE,
keyterms_prompt=["Agora", "AssemblyAI", "Acme Cloud", "Priya Raman"],
)
)keyterms_prompt boosts recognition of product names, people, and jargon. See keyterms prompting for limits and tips.
Add tools
Tool calling lives in the LLM request. Pass OpenAI-style tool schemas as tools, run the function the model asks for, and send the result back. This version of think() replaces the one in step 5:
# Replaces think() in bot.py. Add "import json" to the imports at the top.
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name"}},
"required": ["city"],
},
},
}]
def get_weather(city):
return f"Sunny and 22 degrees in {city}." # call a real weather API here
def think(user_text):
messages.append({"role": "user", "content": user_text})
for _ in range(3): # at most three tool rounds per turn
completion = llm.chat.completions.create(
model=LLM_MODEL, messages=messages, tools=TOOLS, max_tokens=200
)
text = " ".join(c.message.content for c in completion.choices if c.message.content).strip()
calls = [call for c in completion.choices for call in (c.message.tool_calls or [])]
if not calls:
messages.append({"role": "assistant", "content": text})
return text
messages.append({
"role": "assistant",
"content": text or None,
"tool_calls": [
{"id": c.id, "type": "function",
"function": {"name": c.function.name, "arguments": c.function.arguments}}
for c in calls
],
})
for call in calls:
args = json.loads(call.function.arguments or "{}")
result = get_weather(**args) if call.function.name == "get_weather" else "Unknown tool"
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
return "Sorry, I couldn't finish that. Could you ask again?"The text and the tool calls can come back in separate choices, so the loop reads every choice. Full pattern in the LLM Gateway tool calling docs.
Start speaking sooner
The bot waits for the whole LLM reply before it speaks. To cut the wait, stream the model's tokens into the text-to-speech session with generate() and finish each reply with flush(); the server starts speaking once it holds enough complete sentences. The text-to-speech quickstart shows the pattern, and optimizing accuracy and latency covers the rest.
Troubleshooting
agora_python_server_sdk install fails. The SDK supports Linux and macOS with Python 3.10+. On Windows, run the bot inside WSL2 or a Linux container. On macOS, install the Xcode command-line tools (xcode-select --install) if pip needs to build anything.
Bot joins but never greets. The bot greets once someone in the channel speaks. Check that your client joined the same AGORA_CHANNEL with a different UID than AGORA_BOT_UID, and that its microphone is unmuted.
Error 1008 from AssemblyAI. The API key is missing or invalid, or the account has a problem such as insufficient funds. Check ASSEMBLYAI_API_KEY in .env. See error handling for every close code.
Audio sounds chipmunky or slowed down. A sample-rate mismatch. The Agora frame parameters, the speech-to-text sample_rate, and the text-to-speech sample_rate must all be 16000, as SAMPLE_RATE sets them.
Bot interrupts itself. An acoustic loop: the caller's microphone picks up the bot's voice from their speakers. Browser clients should request getUserMedia({ audio: { echoCancellation: true } }), and headphones fix it for testing.
Token errors from Agora. With App Certificate enabled, AGORA_TOKEN must be a valid token for this channel name and the bot's UID, and it must not have expired.
Known limitations
- agora_python_server_sdk wraps Agora's native C++ SDK, and its Python API has changed between releases: in the 2.4 line, publishing and PCM pushes moved onto
RTCConnection. This bot uses the 2.4.10 API as shown in the SDK's own examples. If you upgrade, check those examples first. - One caller per bot. The bot follows the first participant who speaks. For group calls, run speech-to-text per participant and decide in your code who the bot should answer.
- Agora also offers a hosted option. Agora's Conversational AI Engine runs the voice pipeline as a hosted REST service. Use this tutorial when you want the conversation loop in your own code, on AssemblyAI's speech-to-text and text-to-speech.
No Windows build. Run inside WSL2 or a Linux Docker container.
Sign up free and run the full bot: Universal-3.6 Pro Realtime, LLM Gateway, and AssemblyAI Text-to-Speech on one key.
Frequently asked questions
What does AssemblyAI provide in an Agora voice agent?
The three AI layers behind the Agora channel: Universal-3.6 Pro Realtime for streaming speech-to-text with built-in turn detection, LLM Gateway for the LLM, and the AssemblyAI Text-to-Speech API for the bot's voice. All three run on one AssemblyAI API key and the assemblyai Python SDK. Agora carries the audio between the caller and the bot.
How do I connect AssemblyAI to Agora?
Run a server-side bot with agora_python_server_sdk. The bot joins the channel, registers an IAudioFrameObserver to receive the caller's 16 kHz PCM, and streams it to Universal-3.6 Pro Realtime with RealTimeTranscriber. When a turn ends, the bot asks the LLM for a reply, speaks it with a TTSSession opened at 16 kHz, and publishes the audio with push_audio_pcm_data().
Can I use Agora's Conversational AI Engine instead?
Yes. It's Agora's hosted pipeline and supports AssemblyAI as its speech-to-text provider. Use this tutorial when you want to own the conversation loop in your code and run speech-to-text, the LLM, and text-to-speech on AssemblyAI.
What audio format does it use with Agora?
16 kHz, 16-bit, mono PCM in both directions. Agora delivers caller audio at 16 kHz, Universal-3.6 Pro Realtime accepts it directly, and the text-to-speech session is opened with sample_rate=16000 and encoding="pcm_s16le", so no resampling happens in the bot.
How does barge-in work?
While the bot is speaking, a Turn event with real words (not a backchannel like "mhm") sets an interrupted flag. speak() then calls cancel() on the text-to-speech session, which stops the request in flight, and interrupt_audio() on the Agora connection, which drops the audio still buffered for the channel.
Do I need an Agora App Certificate?
Only if your Agora project has it enabled. Then the bot needs a token for its channel and UID in AGORA_TOKEN. If App Certificate is disabled, leave AGORA_TOKEN blank.
How much does it cost?
AssemblyAI bills each layer separately. Universal-3.6 Pro Realtime streaming speech-to-text is $0.45/hr, and AssemblyAI Text-to-Speech is $15 per 1M characters, billed on the text you send. For LLM Gateway and free-tier details, see the AssemblyAI pricing page. Agora bills its own usage.