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:
Put your credentials in a .env file in the folder you run the scripts from:
.env
import unpod loads .env from the current working directory, and AsyncClient() reads every value from it - no load_dotenv() call needed. Plain export lines in your shell work too. Values already set in the shell win over .env.
Set both URLs, each to the bare host https://unpod.ai with no path segment. Each one feeds a different part of the SDK: without UNPOD_SERVICE_BASE_URL the agent and call methods fail, and without UNPOD_BASE_URL the voice-profile and number listings fail. If your team gave you a staging or dedicated host, use that host for both values instead.
Token auth (UNPOD_PLATFORM_TOKEN + UNPOD_ORG_HANDLE) wins over UNPOD_API_KEY when both are set. Analytics and the telephony calls on this page need token auth, so use the token.

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.Pick the mode for the agent’s brain. Both are managed - Unpod runs the brain and you deploy nothing:
  • Prompt mode - the brain is one instruction string. Fastest way to a talking agent.
  • Playbook mode - the brain is a published playbook: branches, slots, and tools you build and edit in the Playbook studio.
step2_agent.py
Output
brain_execution is where the platform runs the brain. It is set for you; leave it alone unless support asks you to change it.An agent has one brain. Re-running create() with the same agent_id and the same brain is safe, but switching an existing agent from Prompt to Playbook (or back) through create() returns 409 Conflict. Change the brain with client.agents.update("my-support", brain=Playbook("PB_...")) instead, or pick a new agent_id - see Configure an agent.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
Each row has two fields, number (E.164) and status:telephony.numbers.list() takes no filters; filter the list in code as above. If nothing is free, provision a number from the dashboard under Dev Platform → Numbers, or detach one from another agent.
4

Attach it to the agent

This script is self-contained: it finds the first free number and attaches it, so you never copy a number by hand.
step4_attach.py
Output
status=assigned confirms the binding. To attach a specific number, pass it directly: await client.agents.numbers.attach("my-support", "+9180...").From here you never pass the number again - calls.create() resolves it from the agent. To move the number to a different agent later, use client.agents.update("other-agent", number="+9180...") - see Configure an agent.
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

  • httpx.ConnectError (Name or service not known / getaddrinfo failed) - the host in UNPOD_BASE_URL / UNPOD_SERVICE_BASE_URL does not exist. Set both to https://unpod.ai (or the host your team gave you).
  • 404, or an HTML page instead of JSON - UNPOD_SERVICE_BASE_URL is missing or has a path on it. It must be the bare host, e.g. https://unpod.ai.
  • 401 Unauthorized - the token is wrong or UNPOD_ORG_HANDLE does not match the org the token belongs to.
  • ValueError asking for UNPOD_PLATFORM_TOKEN - the SDK found no credentials. Check that .env sits in the folder you run python from.
  • 409 Conflict from agents.voice.create() - that agent_id already exists with a different brain. Use agents.update() or a new id.
  • The call stays pending 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

Configure the agent

Change voice or number, background sound, noise cancellation, and more.

Handover & follow-up

Transfer to a human, voicemail messages, automatic callbacks.

Domain dictionaries

Teach the agent your product names and how to say them.

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.