Prerequisites
- Python 3.12+
- An Unpod org with a platform token and at least one phone number - provision a number from the dashboard under Dev Platform → Numbers
- The SDK:
AsyncClient() reads all of it from the environment, and token auth wins
over the API key when both are present.
The six steps
agent_id is the only identifier you re-type. The voice name, the
number, and the call_id each cross exactly one step boundary; number_id
and voice_profile_id you never need at all. Every step after the second
takes the agent id and resolves the rest server-side. This page uses
my-support; name yours whatever your team will recognise.
1
Pick a voice profile
A voice profile is not a voice. It is a pre-benchmarked pipeline with a
price tag - an STT model, a TTS provider and voice, a chat model, and the
measured cost and latency of running all three. Choosing one is closer to
choosing an instance type than choosing a ringtone.The
step1_voices.py
Real output, one entry
name is the handle - Riya is what you pass to create() in the next
step.2
Create the agent
You choose both names here, and they do different jobs.
agent_id is the
one string you type again - attaching a number, placing a call, and reading
results all take this exact string. name is the human label, shown in
dashboards, referenced by nobody’s code.step2_agent.py
Output
Prompt is the shortest path to something that answers: the brain is a bare
instruction string, the platform runs it as a one-node playbook, and there is
nothing to deploy or publish first. One thing in that prompt is doing more work than it looks:
the two-sentence cap. On a phone line, reply length is a latency setting,
not a style preference.Things to know about create():recordingis off by default. Turn it on now; you cannot recover a call you did not record.greetingoverrides the profile’s own greeting line.max_call_duration_sdefaults to 3600, andmax_concurrentdefaults to 1 - raise it before any load test, or your second caller waits.agents.voice.add(agent_id, voice_profile=...)gives the same brain a second voice, because the brain lives on the agent.
3
Find a free number
List first - you can only attach a number the org holds and that nothing
else has claimed.Only rows with
step3_numbers.py
Output
status="not_assigned" can be attached. numbers.list()
also takes optional status and country filters. If nothing is free,
provision a number from the dashboard first.4
Attach it to the agent
step4_attach.py
calls.create() resolves it
from the agent. agents.numbers.detach(number_id) reverses it, and a number
moves to a different agent by detaching and attaching again.5
Place the call
The minimal call is three pieces of information: the agent, the destination,
and Three things people expect to pass, and should not:
data - the per-call context the brain can read. It is what turns
“greet the caller by name if you know it” from a wish into an instruction.step5_call.py
- No
from_number. Caller ID resolves from the number attached to the agent. - Nothing about the voice. The agent already knows which profile it speaks with and which brain answers.
- The result is the queued state, not a finished call. The platform enqueues and returns immediately.
A
from_number of None on the queued record means no number is attached
to the agent, and the dial has nothing to originate from - go back to
step 4.6
Read the result
calls.create() is asynchronous, so you poll until the call reaches a
terminal state - and a call reaches one in two different ways, both of
which you have to check. ended_at is stamped when a connected call hangs
up, but a call that never started - busy, no answer, rejected - finishes
without ever being stamped. Poll on ended_at alone and a busy signal hangs
your script for the full budget.step6_result.py
calls.get() is the whole result: status, end reason, disposition,
duration, recording URL, and the transcript turn by turn. If you want the
orchestration run behind it - room, participants, usage -
client.sessions.get(call.session_id) reads that, and the session_id is
what support will ask for if something looked wrong.
calls.hangup(call_id) ends a live call from your side.Good to know about reading calls
calls.list()leaves transcripts out so a page of 500 calls stays small: on a list rowtranscriptisNone(not loaded), as distinct from[](the call genuinely said nothing). Every row still carriestranscript_turns. Uselistto find the call,getto read it.- Want typed fields instead of raw transcripts? Attach an analytics block to
the agent - an extraction schema that runs on every completed session, read
back with
client.analytics.list_results(agent_id="my-support", limit=100). Attach it before your first call: blocks run forward and never backfill.
Which brain
Everything above usedPrompt because it deploys nothing. All four brains
sit in the same loop, behind the same voice - the real question is how much
you are willing to run. Swapping costs one line:
Before production
Six settings and one habit. Defaults are tuned for a first call, not for a campaign.Troubleshooting
- The call stays
queuedandfrom_numberisNone- no number is attached to the agent. Re-run step 4 and check the attach response. - Your script hangs on a busy line - you are polling
ended_atalone. A never-connected call finishes by terminal status only; use thewait_for_call()pattern from step 6. transcriptisNone- on a list row it means not loaded (usecalls.get()); on a never-connected call there are simply no turns.- No
not_assignednumber in step 3 - provision one from the dashboard under Dev Platform → Numbers, or detach one from another agent.
Next
Run your own brain
A
Runner at the text boundary - your process, your framework.What is a playbook
Branches, slots, and tools instead of one long prompt.
Talk to it in the browser
The same agent over a browser session - no number involved.