To build an outbound cold-calling voice agent with AssemblyAI, you need four pieces: a compliance gate that blocks the call before it is placed, a telephony path that carries the audio, an agent that runs the script, and a tool layer that writes the outcome back to your CRM. The conversation runs on three AssemblyAI services with one API key: Universal-3.6 Pro Realtime hears the prospect, an LLM through LLM Gateway runs the script and the tools, and AssemblyAI Text-to-Speech speaks the replies. Twilio dials and carries the call.
This is an implementation guide. It assumes you have already decided to build an outbound dialer and want to know exactly which calls to make, in what order, and where the sharp edges are. Every code sample below goes into one Python file, server.py, in the order shown. If you want the shortest path to a working outbound call first, start with the outbound-call quickstart and come back.
Scope this to structured qualification, not open-ended persuasion
Before any code: the agents that work in production are the ones with a defined script and a closed set of outcomes. A qualification agent asks two or three known questions, branches on the answers, and ends by writing one of six dispositions. That is a state machine with a voice on it.
An agent asked to "handle objections naturally" and talk a prospect into a meeting is an open-ended persuasion problem, and it degrades fast on real calls. Build the structured version. The rest of this guide assumes a script with defined branches and explicit disposition logic.
This is the same shape as the transactional voice agent patterns that work elsewhere: support lookups, scheduling, order taking. Outbound qualification is one of them; it just dials first. Sales engagement platforms in the B2B prospecting category, Apollo.io among them, are built on exactly this structured-outcome model.
What you're building
CRM / lead list
│
▼
compliance gate ──► blocked (DNC, calling window, policy) ──► CRM
│ passes
▼
Twilio Calls API ──► answering machine? ──► hang up, log no_answer
│ human
▼
Media Stream ──► your server ──► Universal-3.6 Pro Realtime (8 kHz mu-law in, turns out)
──► LLM Gateway + tools (script, tool calls) ──► CRM
──► AssemblyAI Text-to-Speech (8 kHz mu-law out) ──► prospect
│
▼
Twilio status callbacks ──► disposition reconciliationThe compliance gate runs first, and it runs before the dial, not during the call. Everything downstream assumes the number is fine to call right now.
Install the dependencies and save your credentials in .env. One AssemblyAI key covers speech-to-text, LLM Gateway and Text-to-Speech. LLM Gateway may require a paid account; to use your own OpenAI key instead, point the AsyncOpenAI client in Step 4 at OpenAI.
pip install -U "assemblyai>=1.8.2" openai fastapi uvicorn "websockets>=14" twilio python-dotenv python-multipartASSEMBLYAI_API_KEY=your_assemblyai_key
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_FROM_NUMBER=+15551234567
PUBLIC_URL=https://your-subdomain.ngrok-free.appStep 1: Build the compliance gate first
Compliance is where outbound programs lose money, and the expensive failure mode is a legal one. US telemarketing law, including the TCPA, sets statutory damages per violating call, and an automated dialer makes a lot of calls quickly. Other countries and many US states add rules of their own. What follows is general implementation guidance, not legal advice: have counsel define the rules, and keep them in configuration your code reads.
Checks that belong before every dial:
- Internal suppression list. Anyone who has ever asked you to stop.
- Do-not-call registries. Scrub against the national registry and any state registries that apply, on a current subscription, not a stale export.
- Calling window. US federal rules limit telemarketing calls to 8 a.m. to 9 p.m. in the recipient's local time, and some states are stricter. Derive local time from a stored address.
- Consent and disclosure rules. Some states require every party's consent to record, and some require AI or automated-call disclosure. Disclose both in the first sentence, and let counsel decide where the campaign doesn't call at all.
# server.py
import asyncio
import base64
import json
import os
import re
import string
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from urllib.parse import urlencode
from zoneinfo import ZoneInfo
import assemblyai as aai
import websockets
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Response, WebSocket
from openai import AsyncOpenAI
from twilio.request_validator import RequestValidator
from twilio.rest import Client as TwilioClient
load_dotenv()
# Calling rules your counsel owns. Keep them in config, not in code.
POLICY = json.load(open("calling_policy.json"))
# e.g. {"window_start_hour": 8, "window_end_hour": 21, "excluded_states": [...]}
@dataclass
class Lead:
id: str
phone: str # E.164
first_name: str
company: str
state: str # two-letter code from your CRM
timezone: Optional[str]
record: bool = False
internal_suppression = set() # load from your CRM; honor_dnc() adds to it live
def is_on_dnc_registry(phone):
"""Check your DNC provider. Raise if it can't answer, so the gate fails closed."""
raise NotImplementedError("wire this to your DNC scrubbing provider")
def gate(lead):
"""Return (allowed, reason). Fail closed on every unknown."""
if lead.phone in internal_suppression:
return False, "internal_dnc"
try:
if is_on_dnc_registry(lead.phone):
return False, "registry_dnc"
except Exception:
return False, "dnc_check_unavailable" # fail closed
if not lead.timezone:
return False, "unknown_timezone" # fail closed
local = datetime.now(ZoneInfo(lead.timezone))
if not POLICY["window_start_hour"] <= local.hour < POLICY["window_end_hour"]:
return False, "outside_calling_window"
if lead.state in POLICY["excluded_states"]:
return False, "excluded_state"
return True, "ok"The rules live in calling_policy.json, next to server.py, where counsel can review and change them without a deploy:
{
"window_start_hour": 8,
"window_end_hour": 21,
"excluded_states": []
}Two rules matter more than the code. Fail closed: an unknown timezone or an unreachable DNC service means do not dial. And make the gate the only path to the dialer, so no retry queue, no manual re-run, and no "just this campaign" flag can route around it.
The agent also needs a runtime escape hatch. If a prospect asks to be removed mid-call, that has to land in your suppression list within seconds, not on the next batch sync. That is a tool call, covered in Step 4.
Universal-3.6 Pro Realtime, LLM Gateway and Text-to-Speech run on one AssemblyAI API key. Get a key and place a test call to yourself in an afternoon.
Step 2: Place the call and bridge the audio
Twilio dials, and your server carries the conversation. Twilio's Calls API places the call and, once someone answers, fetches TwiML that opens a Media Stream: a WebSocket that carries the call audio between Twilio and your server in both directions.
Dial through the gate
/dial/{lead_id} is the only way a call gets placed. It runs the gate, then asks Twilio to dial with answering machine detection, a status callback for Step 5, and a time limit on the call:
API_KEY = os.environ["ASSEMBLYAI_API_KEY"]
PUBLIC_URL = os.environ["PUBLIC_URL"] # e.g. https://your-app.ngrok.app
TWILIO_AUTH_TOKEN = os.environ["TWILIO_AUTH_TOKEN"]
twilio = TwilioClient(os.environ["TWILIO_ACCOUNT_SID"], TWILIO_AUTH_TOKEN)
app = FastAPI()
LEADS = {} # lead id -> Lead; swap for your CRM
CRM = [] # stand-in for CRM writes; one dict per write
def crm_write(kind, **fields):
CRM.append({"kind": kind, **fields})
print(json.dumps({"crm": kind, **fields}, default=str))
@app.post("/dial/{lead_id}")
async def dial(lead_id: str):
lead = LEADS.get(lead_id)
if not lead:
raise HTTPException(404, "unknown lead")
allowed, reason = gate(lead) # the only path to the dialer
if not allowed:
crm_write("blocked", lead_id=lead.id, reason=reason)
return {"dialed": False, "reason": reason}
call = await asyncio.to_thread(
twilio.calls.create,
to=lead.phone,
from_=os.environ["TWILIO_FROM_NUMBER"],
url=f"{PUBLIC_URL}/twilio/answer?{urlencode({'lead_id': lead.id})}",
machine_detection="Enable", # Twilio reports human or machine before /answer
status_callback=f"{PUBLIC_URL}/twilio/status",
status_callback_event=["initiated", "answered", "completed"],
record=lead.record,
time_limit=300, # a ceiling on any one call, in seconds
)
crm_write("dialed", lead_id=lead.id, call_sid=call.sid)
return {"dialed": True, "call_sid": call.sid}Record only when the opener will disclose it: the record flag on the lead drives both the Twilio recording and the disclosure sentence in Step 3.
Answer only humans
With machine_detection="Enable", Twilio decides whether a person or a machine answered before it fetches your TwiML, and sends the answer as AnsweredBy. A machine gets a hang-up and a no_answer disposition. A person gets a Media Stream, with the lead ID passed through as a stream parameter:
@app.post("/twilio/answer")
async def answer(request: Request, lead_id: str):
form = await request.form()
if form.get("AnsweredBy", "human") not in ("human", "unknown"):
crm_write("disposition", lead_id=lead_id, disposition="no_answer")
return Response('<Response><Hangup/></Response>', media_type="application/xml")
host = PUBLIC_URL.split("://", 1)[1]
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="wss://{host}/media-stream">
<Parameter name="lead_id" value="{lead_id}" />
</Stream>
</Connect>
</Response>"""
return Response(twiml, media_type="application/xml")If you'd rather leave voicemail, branch on AnsweredBy here and play a short recorded message. Keep voicemail scripts in front of counsel too; the rules for prerecorded messages differ from live calls.
The bridge
Each call gets one Universal-3.6 Pro Realtime socket, one Text-to-Speech session and one message history. Both AssemblyAI ends speak Twilio's format: speech-to-text takes encoding=pcm_mulaw at sample_rate=8000, and the Text-to-Speech session returns pcm_mulaw at 8000 Hz, resampled on the server from its native 24 kHz. Nothing is transcoded. AssemblyAI Text-to-Speech is built for conversational voice agents, with frontier-level quality, faster and cheaper than other production voice agent TTS models, and unlimited concurrency.
STT_URL = "wss://streaming.assemblyai.com/v3/ws?" + urlencode({
"speech_model": "universal-3-6-pro",
"encoding": "pcm_mulaw", # Twilio Media Streams: 8 kHz G.711 mu-law
"sample_rate": 8000,
"voice_focus": "near-field",
})
TTS_CONFIG = aai.TTSConfig(
voice_id="49c443a4-8a1d-5f7e-b70b-98bdbd9823c3", # Audrey
language="english",
encoding="pcm_mulaw",
sample_rate=8000, # resampled on the server from the native 24 kHz
)
def free_slots():
"""Two open times from your calendar, ISO 8601 with offset."""
return ["2026-11-03T15:00-05:00", "2026-11-04T11:00-05:00"]
class Call:
"""One outbound call: one STT socket, one TTS session, one history."""
def __init__(self, twilio_ws, stt_ws, tts):
self.twilio_ws, self.stt_ws, self.tts = twilio_ws, stt_ws, tts
self.stream_sid = self.call_sid = self.lead = None
self.messages, self.prospect_turns, self.offered_slots = [], [], []
self.reply_task = None
self.hang_up_after_reply = False
def log(self, kind, **fields):
print(json.dumps({"call": self.call_sid, "kind": kind, **fields}, default=str))
def begin(self, start):
self.stream_sid, self.call_sid = start["streamSid"], start["callSid"]
self.lead = LEADS[start["customParameters"]["lead_id"]]
self.offered_slots = free_slots()
recorded = " and that this call is recorded" if self.lead.record else ""
prompt = SCRIPT.format(company=self.lead.company, first_name=self.lead.first_name,
recorded=recorded, slot_a=self.offered_slots[0],
slot_b=self.offered_slots[1], **CAMPAIGN)
opener = (f"Hi, is this {self.lead.first_name}? Quick heads up, I'm an AI assistant "
f"calling from {self.lead.company}{recorded.replace('that ', '')}.")
self.messages = [{"role": "system", "content": prompt},
{"role": "assistant", "content": opener}]
self.reply_task = asyncio.create_task(self.say(opener))
def agent_speaking(self):
return self.reply_task is not None and not self.reply_task.done()
async def say(self, text):
# Tell the speech model what the agent just asked, so a spelled email or a
# short "the second one" is transcribed as the answer to that question.
await self.stt_ws.send(json.dumps({"type": "UpdateConfiguration",
"agent_context": text[-1750:]}))
# Synthesis runs faster than playback: pace mu-law at 8,000 bytes a second
# with a small lead, so this task lives exactly as long as the prospect hears it.
loop = asyncio.get_running_loop()
start, queued = loop.time(), 0.0
async for chunk in self.tts.speak(text): # raw 8 kHz mu-law, no header
await self.twilio_ws.send_text(json.dumps({
"event": "media", "streamSid": self.stream_sid,
"media": {"payload": base64.b64encode(chunk).decode()},
}))
queued += len(chunk) / 8000
lead = queued - (loop.time() - start)
if lead > 0.2:
await asyncio.sleep(lead - 0.2)
await asyncio.sleep(max(0.0, queued - (loop.time() - start)))
async def respond(self, prospect_text):
reply = await run_turn(self, prospect_text)
await self.say(reply)
if self.hang_up_after_reply:
await asyncio.to_thread(twilio.calls(self.call_sid).update, status="completed")
async def barge_in(self):
"""The prospect started talking: stop the reply and empty Twilio's buffer."""
if self.agent_speaking():
self.reply_task.cancel() # stops the LLM call, the tools or the playback
await self.tts.cancel() # drops synthesis of the request in flight
await self.twilio_ws.send_text(json.dumps(
{"event": "clear", "streamSid": self.stream_sid}))Four things the bridge gets right:
- The agent speaks first. begin() runs on Twilio's start event: it loads the lead, fills the script, and opens with the AI disclosure, plus the recording disclosure when the call is recorded.
- Paced playback. Synthesis runs faster than real time, and Twilio buffers whatever you send. say() paces the μ-law at the wire rate, 8,000 bytes a second, about 200 ms ahead of the prospect, so the reply task lives exactly as long as the prospect hears it and barge-in is never late.
- Barge-in in three moves. barge_in() cancels the reply task (the LLM call, the tools or the playback, wherever it is), calls tts.cancel() to stop synthesis of the request in flight, and sends Twilio a clear event to empty its playback buffer.
- One session per call. The Text-to-Speech session opens when the call connects and serves every line, so no turn waits for a handshake. Rate limits count new sessions per minute, so calls already in progress don't count against them. See Limits.
Don't let "mhm" stop the agent
Prospects who are half-listening make listening noises: "mhm", "yeah", "okay". If every one of them counts as barge-in, the agent stops mid-sentence over and over and sounds broken. While the agent is talking, short or all-filler turns are treated as backchannel and dropped. Once the agent is quiet, every word counts as an answer, so a bare "no" or "sure" after a question still lands.
# "mhm" while the agent talks is listening, not interrupting. "yes" and "no"
# are left out on purpose: on a sales call they are real answers.
BACKCHANNELS = frozenset({"mhm", "mm", "mmhm", "uh", "uhhuh", "huh", "um", "umm",
"hmm", "hm", "ah", "oh", "yeah", "yep", "okay", "ok",
"right", "alright", "gotcha", "sure"})
MIN_WORDS = 2 # shorter utterances while the agent talks count as filler
def is_backchannel(call, text):
"""True if this turn should not interrupt the agent."""
if not call.agent_speaking():
return False # once the agent is quiet, every word is an answer
tokens = text.lower().translate(str.maketrans("", "", string.punctuation)).split()
return len(tokens) < MIN_WORDS or all(t in BACKCHANNELS for t in tokens)"Yes" and "no" stay out of the filler list on purpose: on a sales call they're real answers. The voice agent best practices cover tuning the list for your domain.
Finally, the Media Stream endpoint ties it together: Twilio audio in, turns out, barge-in on the first words of a real turn, and a reply on end_of_turn.
@app.websocket("/media-stream")
async def media_stream(twilio_ws: WebSocket):
await twilio_ws.accept()
async with websockets.connect(
STT_URL, additional_headers={"Authorization": API_KEY}
) as stt_ws, aai.AsyncTTSSession(TTS_CONFIG, api_key=API_KEY) as tts:
call = Call(twilio_ws, stt_ws, tts)
async def from_twilio():
async for raw in twilio_ws.iter_text():
msg = json.loads(raw)
if msg["event"] == "start":
call.begin(msg["start"])
call.log("start", lead=call.lead.id, tts_session=tts.begin.id)
elif msg["event"] == "media":
await stt_ws.send(base64.b64decode(msg["media"]["payload"]))
elif msg["event"] == "stop":
await stt_ws.send(json.dumps({"type": "Terminate"}))
break
async def from_assemblyai():
prospect_speaking = False
async for raw in stt_ws:
msg = json.loads(raw)
if msg.get("type") != "Turn":
continue
transcript = msg.get("transcript", "").strip()
end_of_turn = msg.get("end_of_turn", False)
if transcript and is_backchannel(call, transcript):
prospect_speaking = prospect_speaking and not end_of_turn
continue
if transcript and not prospect_speaking:
prospect_speaking = True # first words of a real turn
await call.barge_in()
if end_of_turn:
prospect_speaking = False
if transcript:
call.prospect_turns.append(transcript)
call.log("prospect", text=transcript)
call.reply_task = asyncio.create_task(call.respond(transcript))
async def keep_tts_alive():
while True:
await asyncio.sleep(60)
await tts.keep_alive() # the session closes after 2 idle minutes
keepalive = asyncio.create_task(keep_tts_alive())
listener = asyncio.create_task(from_assemblyai())
try:
await from_twilio()
finally:
for task in (keepalive, listener, call.reply_task):
if task:
task.cancel()Turns come from the speech model, which decides when the prospect has finished from what they said as well as from silence. Prospects pause mid-sentence when they're half-listening, and an agent that treats every pause as a turn boundary interrupts constantly and gets hung up on. Leave the turn-detection parameters at their defaults unless you've measured a reason to change them.
Step 3: Write the script as a system prompt
Keep it short, keep the branches explicit, and put the disclosure first. Nothing in this prompt should require the model to improvise a claim. The disclosure itself is spoken by code in begin(), so it can't be skipped; the prompt only tells the model it has been said.
SCRIPT = """You are a scheduling assistant calling on behalf of {company}.
You have already said you are an AI assistant{recorded}.
OPENER (under 15 seconds):
Confirm you're speaking to {first_name}. State why you're calling in one
sentence. Ask for permission to continue.
DISCOVERY (maximum 2 questions, then stop):
1. Does {team_function} sit with you or someone else?
2. How are you handling {problem_area} today?
PITCH (two sentences, no more):
{company} provides {one_line_description}. Teams use it to {primary_outcome}.
CALL TO ACTION:
Offer exactly these two times: {slot_a} or {slot_b}. Never offer an open-ended
"sometime next week." Read the email address back and wait for a yes before
you call book_meeting.
OBJECTIONS: map, don't argue:
"Not interested" -> thank them, call log_disposition, say goodbye.
"Send me an email" -> confirm the address, call log_disposition, say goodbye.
"Not the right person" -> ask who is, call log_disposition, say goodbye.
"Bad time" -> offer a callback, call mark_callback, say goodbye.
After book_meeting succeeds, confirm the time, call log_disposition with
"booked", and say goodbye.
STOP IMMEDIATELY:
If the person asks not to be called again, in any wording, call honor_dnc
before saying anything else. Then confirm and say goodbye.
NEVER state a price, a customer name, a statistic, or a commitment that did
not come from a tool result in this conversation. If you do not have the
value, say you will follow up with it.
This is a phone call: one or two short sentences per reply, no lists, no markdown."""
CAMPAIGN = { # one campaign's fill-ins
"team_function": "revenue operations",
"problem_area": "call QA",
"one_line_description": "automated QA scoring for sales calls",
"primary_outcome": "review every call instead of a sample",
}That "NEVER state" clause is not optional. It is the anti-fabrication rule, and on a call that books meetings and quotes terms it is the difference between a lead and a liability.
The two times to offer come from your calendar at the start of the call, so the agent can only propose slots that exist. For the voice, pick an ID from the voice library and set it in TTS_CONFIG. Text-to-Speech speaks six languages (English, Spanish, French, German, Italian and Portuguese), and it doesn't detect the language, so set language per campaign. Universal-3.6 Pro Realtime transcribes 32 languages on the input side.
Step 4: Tools are what make the call worth placing
A cold call that ends without a written outcome is a wasted dial. Four tools cover a qualification agent: book_meeting, log_disposition, honor_dnc and mark_callback. They're OpenAI-style function definitions sent with every LLM request through LLM Gateway, and they run in your code, next to your CRM credentials.
Parameter hints are the accuracy lever
parameters is a JSON Schema object, and the keywords in it tell the model what a complete value looks like. Use enum for every fixed set, dispositions above all: a model that invents a seventh disposition value corrupts your funnel reporting silently.
DISPOSITIONS = ["booked", "not_now", "not_interested", "wrong_person", "send_email", "dnc"]
def fn(name, description, properties, required):
return {"type": "function", "function": {
"name": name, "description": description,
"parameters": {"type": "object", "properties": properties, "required": required},
}}
TOOLS = [
fn("book_meeting",
"Book a meeting. Call this only after the prospect has agreed to one of the "
"two offered times and confirmed their email. Do not call it to check availability.",
{"email": {"type": "string", "format": "email",
"description": "Prospect's work email, as they spelled it out."},
"starts_at": {"type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}",
"description": "One of the two offered times, exactly as given."}},
["email", "starts_at"]),
fn("log_disposition", "Record how the call ended. Call it once, at the end.",
{"disposition": {"type": "string", "enum": DISPOSITIONS},
"note": {"type": "string", "description": "One sentence, facts the prospect said."}},
["disposition"]),
fn("honor_dnc", "The prospect asked not to be called again, in any wording.",
{}, []),
fn("mark_callback", "The prospect asked to be called back at a better time.",
{"when": {"type": "string", "description": "When they asked for, in their words."}},
["when"]),
]Argument hallucination is the risk that matters here
On an agent that books meetings and writes CRM records, the dangerous failure is a confidently fabricated argument: an email address the prospect never said, a time nobody agreed to. A schema describes a good value; it doesn't enforce one. So run_tool() checks every argument before anything is written. The email must have the right shape and must match what the prospect actually said, spelled out or formatted. The meeting time must be one of the two slots the call offered. A failed check returns a structured error the model can act on, such as asking the prospect to spell the address again.
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[a-z]{2,}$", re.I)
def heard(call, value):
"""True if the prospect said this value: 'j dot smith at acme dot co' or 'j.smith@acme.co'."""
spoken = re.sub(r"\b(at|dot)\b", "", " ".join(call.prospect_turns).lower())
norm = lambda s: re.sub(r"[^a-z0-9]", "", s.lower())
return bool(norm(value)) and norm(value) in norm(spoken)
def run_tool(call, name, args):
"""Run one tool call. Every argument is checked before anything is written."""
lead = call.lead
if name == "book_meeting":
email, starts_at = args.get("email", ""), args.get("starts_at", "")
if not EMAIL_RE.match(email) or not heard(call, email):
return {"error": "email_not_confirmed",
"message": "Ask the prospect to spell the email again."}
if starts_at not in call.offered_slots:
return {"error": "slot_not_offered",
"message": f"Only these times are open: {', '.join(call.offered_slots)}."}
crm_write("meeting", lead_id=lead.id, email=email, starts_at=starts_at)
return {"status": "booked", "starts_at": starts_at}
if name == "log_disposition":
if args.get("disposition") not in DISPOSITIONS:
return {"error": "invalid_disposition", "allowed": DISPOSITIONS}
crm_write("disposition", lead_id=lead.id, call_sid=call.call_sid, **args)
call.hang_up_after_reply = True
return {"status": "logged"}
if name == "honor_dnc":
internal_suppression.add(lead.phone) # takes effect before the next dial
crm_write("disposition", lead_id=lead.id, call_sid=call.call_sid, disposition="dnc")
call.hang_up_after_reply = True
return {"status": "suppressed"}
if name == "mark_callback":
crm_write("callback", lead_id=lead.id, when=args.get("when", ""))
crm_write("disposition", lead_id=lead.id, call_sid=call.call_sid, disposition="not_now")
call.hang_up_after_reply = True
return {"status": "callback_scheduled"}
return {"error": "unknown_tool"}Pair that with the anti-fabrication clause in the prompt. The code stops the bad write; the prompt stops the agent from saying the thing out loud.
Run the tool loop in the LLM request
Each prospect turn appends a user message and calls the LLM with the tools. If the response contains tool_calls, your code runs each one, appends the results as tool messages, and calls the LLM again. A response with text and no tool calls is the reply.
llm = AsyncOpenAI(base_url="https://llm-gateway.assemblyai.com/v1", api_key=API_KEY)
LLM_MODEL = "claude-haiku-4-5-20251001" # any model on Available models that supports tools
MAX_TOOL_ROUNDS = 4
async def run_turn(call, prospect_text):
"""One prospect turn: call the LLM with tools until it answers in plain text."""
call.messages.append({"role": "user", "content": prospect_text})
for _ in range(MAX_TOOL_ROUNDS):
response = await llm.chat.completions.create(
model=LLM_MODEL, messages=call.messages, tools=TOOLS, max_tokens=300,
)
# Some models return text and tool calls as separate choices: read them all.
text = " ".join(c.message.content for c in response.choices if c.message.content)
tool_calls = [tc for c in response.choices for tc in (c.message.tool_calls or [])]
if not tool_calls:
call.messages.append({"role": "assistant", "content": text})
return text
call.messages.append({"role": "assistant", "content": text or None,
"tool_calls": [tc.model_dump() for tc in tool_calls]})
filler = asyncio.create_task(call.say(text)) if text else None # keep the line warm
for tc in tool_calls:
args = json.loads(tc.function.arguments or "{}")
result = run_tool(call, tc.function.name, args)
call.log("tool", name=tc.function.name, args=args, result=result)
call.messages.append({"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result)})
if filler:
await filler
return "Sorry, I'll have someone follow up by email. Thanks for your time."Three details hold this up in production. Read every choice: some models return the spoken text and the tool call in separate choices, and reading only choices[0] drops one of them. Bound the rounds, so a model that keeps calling tools can't hold the prospect in silence. And speak while tools run: if the model wrote a line alongside the tool call ("let me get that booked"), it plays while the CRM write happens, so the line never goes quiet. If a tool can take longer than a sentence, put a timeout on it and return a structured error; the order lookup agent shows the same pattern with a warm transfer to a human. See the tool calling docs for the full request and response shapes.
When log_disposition, honor_dnc or mark_callback runs, the agent says goodbye and respond() ends the call through Twilio's REST API.
Step 5: Status callbacks close the loop
Tools handle the in-call writes. Twilio's status callbacks handle everything about the call itself: whether it was answered, by whom, and how long it lasted. That's where disposition reconciliation and cost-per-conversation reporting come from. The status_callback URL set in Step 2 receives a signed POST for each event you asked for.
Verify every delivery before you trust it. Twilio signs each request with your auth token in the X-Twilio-Signature header, and the twilio package checks it:
validator = RequestValidator(TWILIO_AUTH_TOKEN)
seen = set() # use your database in production
@app.post("/twilio/status")
async def status(request: Request):
form = dict(await request.form())
signature = request.headers.get("X-Twilio-Signature", "")
if not validator.validate(f"{PUBLIC_URL}/twilio/status", form, signature):
raise HTTPException(403, "bad signature")
key = (form.get("CallSid"), form.get("CallStatus"))
if key in seen: # Twilio retries: make the write idempotent
return Response(status_code=204)
seen.add(key)
crm_write("call_status", call_sid=form.get("CallSid"), status=form.get("CallStatus"),
duration_s=form.get("CallDuration"), answered_by=form.get("AnsweredBy"))
return Response(status_code=204)Twilio retries failed deliveries, so make the handler idempotent: a double-written "booked" disposition is a bad afternoon for whoever owns the pipeline report. Then start the server and expose it:
uvicorn server:app --port 8000
ngrok http 8000Add a lead to LEADS (or wire LEADS to your CRM), call POST /dial/{lead_id} with your own phone number on the lead, and be the first prospect.
Why transcription accuracy decides the outcome
A cold call collects exactly the values that are hardest to transcribe: a name, a company, a work email, a callback number. Get the email wrong and the meeting invite bounces. Get the callback number wrong and the whole call was for nothing.
Here is how Universal-3.6 Pro Realtime measures on AssemblyAI's English voice-agent benchmark, which scores 12,460 scripted voice-agent scenarios rather than clean read speech. Lower is better.
| Metric | Universal-3.6 Pro Realtime | Deepgram Flux EN | ElevenLabs Scribe v2 | Deepgram Nova-3 |
|---|---|---|---|---|
| Word error rate | 5.19% | 13.50% | 7.78% | 8.64% |
| Entity error rate | 14.4% | 30.1% | 18.5% | 26.1% |
| Names | 10.9% | 29.0% | 14.8% | 24.3% |
| Codes / IDs | 10.0% | 46.1% | 12.0% | 27.6% |
| Phone numbers | 2.4% | 11.5% | 3.4% | 4.5% |
Read the phone-number row against a dialer's actual job. A 2.4% error rate versus 11.5% for Deepgram Flux EN is the difference between a callback list you can dial and one somebody has to clean by hand. The names row is the same story for the CRM record. On Pipecat's open benchmark, the same model posts a 307 ms median time to final transcript. More comparisons are on the benchmarks page.
Give the speech model the agent's question
Before every line the agent speaks, say() sends it to the speech-to-text socket as agent_context in an UpdateConfiguration message. The model then knows what kind of answer is coming, which is what lets "yeah, it's j dot smith at acme dot co" come back as j.smith@acme.co. It helps most on short replies and spelled-out entities, which is most of a qualification call. For vocabulary you know in advance, such as your product and competitor names, add keyterms_prompt; see prompting and keyterms. The voice agent architecture guide goes deeper on how the pieces fit.
Run a recording of a qualification call through the playground, spell an email at it, and check the transcript before you write a line of bridge code.
Unit economics
Each call has four line items: streaming speech-to-text for as long as the socket is open, LLM Gateway by model usage, Text-to-Speech by input characters, and Twilio's carrier minutes. Universal-3.6 Pro Realtime streaming is $0.45 per hour, so a 90-second qualification call uses about $0.011 of speech-to-text. Text-to-Speech is $15 per 1M characters of input, so the cost scales with how much the agent says. LLM rates by model are on the pricing page; add them with your own model choice and average reply length, then add your carrier rate.
Two things move the number in production. Sessions you never close keep billing, so terminate the speech-to-text socket and close the Text-to-Speech session on every exit path, as the bridge does. And dials that never connect still burn carrier minutes, so your cost per conversation is meaningfully higher than your cost per dial; model both. For the comparison you actually care about, plug in your own fully loaded SDR cost per hour.
What to measure
Instrument three rates from day one, and hold them separately. Every call ends with a disposition, so they come straight out of log_disposition plus Twilio's status callbacks.
| Rate | What it measures | Where it comes from |
|---|---|---|
| Connection rate | Dials that reach a person | Twilio status callbacks plus AnsweredBy |
| Conversation rate | Connections that last past 30 seconds; the honest read on your opener | CallDuration from the completed callback |
| Book rate | Conversations that end with a meeting on the calendar | log_disposition with booked |
Set your baselines from your own first few thousand dials rather than from industry figures. Also track the two operational metrics nobody thinks about until they hurt: gate block rate by reason (a spike in unknown_timezone means your lead data degraded) and tool error rate (a spike in email_not_confirmed means the email step needs work, and a spike in timeouts means your CRM is slowing calls down).
Where to go next
The shortest working path: get the outbound-call quickstart ringing your own phone, then add the gate, the script and the four tools from this guide, and call yourself again. Build the compliance gate in front of the dialer before any real lead goes in, and don't let anything route around it.
The Text-to-Speech quickstart covers the session API, the voice agent best practices cover speech-to-text settings for agents, and Available models lists the LLMs that support tools.
If you are modeling tens of thousands of dials a month, or you have compliance requirements specific to your industry, talk to someone who has helped teams size this.
Frequently asked questions
How do I build an AI cold-calling agent?
Build four pieces: a compliance gate that checks suppression lists, do-not-call registries and calling windows before any dial; a telephony path, where Twilio's Calls API dials and a Media Stream carries the audio to your server; an agent that runs a structured script; and tools that write the outcome to your CRM. With AssemblyAI, Universal-3.6 Pro Realtime transcribes the prospect, an LLM through LLM Gateway runs the script and the tools, and AssemblyAI Text-to-Speech speaks the replies in Twilio's 8 kHz μ-law, all on one API key.
Is AI cold calling legal?
It depends on where you call and how. In the US, automated and AI-voiced calls fall under the TCPA, the national and state do-not-call registries, federal and state calling-time rules, state call-recording consent laws, and a growing set of state AI-disclosure rules; other countries have their own regimes. Build the checks as hard gates that fail closed, disclose the automated call and any recording in the first sentence, honor opt-outs immediately, and have counsel review the program before launch. This is general implementation guidance, not legal advice.
How much does it cost to run a cold-calling voice agent?
The per-call cost is streaming speech-to-text for the length of the call, the LLM tokens for the script and tool calls, Text-to-Speech billed on input characters, and carrier minutes. Universal-3.6 Pro Realtime streaming is $0.45 per hour, about $0.011 for a 90-second call, and Text-to-Speech is $15 per 1M characters; current LLM Gateway rates are on AssemblyAI's pricing page. Dials that never connect still cost carrier minutes, so model cost per conversation as well as cost per dial.
How does the agent place outbound calls?
Twilio's Calls API places the call with calls.create(), pointing url at an endpoint that returns TwiML with <Connect><Stream>. When a person answers, Twilio opens a Media Streams WebSocket to your server. Your server forwards the prospect's 8 kHz μ-law audio to Universal-3.6 Pro Realtime with encoding=pcm_mulaw and sample_rate=8000, and sends replies from an AssemblyAI Text-to-Speech session opened with pcm_mulaw at 8000 Hz.
What speech-to-text accuracy do I need for cold calling?
The number that matters is entity accuracy on phone audio, because a cold call exists to capture names, emails and callback numbers correctly the first time. On AssemblyAI's English voice-agent benchmark of 12,460 scripted voice-agent scenarios, Universal-3.6 Pro Realtime records a 2.4% error rate on phone numbers and 10.9% on names, against 11.5% and 29.0% for Deepgram Flux EN. A wrong digit in a callback number voids the call.
How do I stop the agent from inventing meeting times or CRM values?
Use two controls together. In code, validate every tool argument before writing it: the meeting time must be one of the slots your calendar offered on this call, and the email must match what the prospect actually said. In the prompt, add an anti-fabrication clause telling the agent never to state a price, name, statistic or confirmation that didn't come from a tool result in this conversation. The code blocks the bad write; the prompt stops the agent from saying it.
How do I write call outcomes back to my CRM?
Two mechanisms, used together. In-call, a log_disposition tool with an enum on the disposition field writes the outcome the moment the conversation ends, and honor_dnc adds the number to your suppression list immediately. After the call, Twilio's status callbacks POST the call status, duration and answering-machine result to your server. Verify the X-Twilio-Signature header on every delivery and make the handler idempotent, since Twilio retries.
Which languages can a cold-calling voice agent speak?
AssemblyAI Text-to-Speech speaks six languages: English, Spanish, French, German, Italian and Portuguese. The API doesn't detect the language, so set it per session to match the campaign. On the input side, Universal-3.6 Pro Realtime transcribes 32 languages, so the agent can follow prospects who answer in a language it doesn't speak and route them to a person who does.