Skip to main content
In this quickstart you will create a named agent with a voice and a brain, attach a phone number to it, place a real outbound call, and read the transcript and recording back in code. About five minutes, and nothing to deploy: the carrier, the speech pipelines, turn detection, barge-in, recording and the call record all run on Unpod’s side. What crosses the boundary is text.

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:
Set your credentials in the environment:
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.
step1_voices.py
Real output, one entry
The name is the handle - Riya is what you pass to create() in the next step.
A reliable order for choosing: get language coverage right first - the only constraint that can break the product; check both what the transcriber understands and what the voice can speak. Then shortlist voices that fit the persona, listen to them in the dashboard rather than picking from a table of model names, and let quality tier, latency and cost break the tie. voice_speed and voice_temperature settle a voice that is close but slightly too fast or too animated.
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():
  • recording is off by default. Turn it on now; you cannot recover a call you did not record.
  • greeting overrides the profile’s own greeting line.
  • max_call_duration_s defaults to 3600, and max_concurrent defaults 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.
step3_numbers.py
Output
Only rows with 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
From here you never pass the number again - 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 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
Three things people expect to pass, and should not:
  • 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
One 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.
That is the whole integration. Your phone rang, an agent spoke, and the record of it came back in code.

Good to know about reading calls

  • calls.list() leaves transcripts out so a page of 500 calls stays small: on a list row transcript is None (not loaded), as distinct from [] (the call genuinely said nothing). Every row still carries transcript_turns. Use list to find the call, get to 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 used Prompt 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 queued and from_number is None - 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_at alone. A never-connected call finishes by terminal status only; use the wait_for_call() pattern from step 6.
  • transcript is None - on a list row it means not loaded (use calls.get()); on a never-connected call there are simply no turns.
  • No not_assigned number 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.