Outbound calls
Start a session, then dial — the callee joins the agent's room as a SIP participant.
A phone call isn't a different kind of session. You start a normal realtime session (usually server-side, with an API key), then dial: the platform places a SIP call, and when the callee answers they join the same LiveKit room the agent is already in. The agent neither knows nor cares that the voice arrived over the phone network.
import asyncio, os
from cosmo_ai import AudioConfig, NoiseCancellation, RealtimeClient, TranscriptDeltaEvent
async def main() -> None:
client = RealtimeClient(api_key=os.environ["COSMO_API_KEY"])
agent = client.agent(
instructions="You are Alex from Acme, calling to confirm tomorrow's delivery window.",
greeting="Hi, this is Alex calling from Acme about your delivery.",
interruption_sensitivity="low",
audio=AudioConfig(noise_cancellation=NoiseCancellation.VOICE_FOCUS),
)
async with agent.start() as session:
result = await session.dial("+14155550199")
print(f"dialing… dial_id={result.dial_id}")
async for event in session:
match event:
case TranscriptDeltaEvent() if event.is_final:
print(f"[{event.role.value}] {event.text}")
asyncio.run(main())dial() returns as soon as the call is queued — the ring happens out of band, and the conversation shows up on the session's ordinary event stream (user-started-speaking, transcripts, tool calls). TypeScript is the same shape: await session.dial('+14155550199').
A browser client that starts the session only to watch the call — an operator console — should join without a microphone: pass publishMicrophone: false to agent.start() (TypeScript), otherwise the operator's open mic echoes the callee's audio back into the room. The agent's ear is unaffected — it binds to whichever participant carries the voice, here the answered phone leg.
Numbers and caller ID
phone_numbermust be E.164 (+14155550199). The SDK validates the format locally before any network call; the server validates again.caller_number(optional) sets the caller ID shown to the callee. It must be an active number in your workspace's phone pool — the server rejects an unknown or inactive number withcaller_number_not_available. Omit it to use the trunk default.
await session.dial("+14155550199", caller_number="+14155550100")In TypeScript, as in the other SDKs, the session method accepts both: session.dial(phoneNumber, callerNumber?).
Prerequisites and limits
- Your workspace must have phone calls enabled; dialing without it fails with
phone_calls_disabled. Request enablement from the Phone numbers page in the dashboard — Request access files a ticket and the team follows up by email. - Outbound minutes are subject to a weekly per-user limit — exceeding it fails the dial, not the session.
- Dialing requires API-key auth (
realtime:dial); end-user token sessions can't place calls.
Failure modes
dial() raises DialError. Its code says how far the attempt got — a closed
DialErrorCode you can switch on exhaustively — and serverCode, inherited
from the ApiError base, carries the server's own slug when it sent one:
code | Meaning |
|---|---|
invalid_request | not E.164, or a session that cannot be dialed — caught locally, before any request |
request_failed | the request never produced a verdict |
request_rejected | the server refused; serverCode says why |
invalid_response | the answer did not parse |
The slugs on serverCode are an open set — log them, do not switch on them:
serverCode | Meaning |
|---|---|
dial_requires_api_key | the session was started with a minted end-user token, which cannot dial (403) |
phone_calls_disabled | workspace toggle is off (403) |
do_not_call | the number is on the do-not-call list (403) |
caller_number_not_available | caller ID not an active number in the workspace pool (400) |
minute_limit_exceeded | weekly outbound-minute cap reached (403) |
session_not_found / session_not_live | the session ended (or never existed) before the dial (404 / 409) |
session_already_dialed | the session already has an outbound dial (409) |
consent_check_unavailable | the do-not-call check couldn't run — retry (503) |
A failed dial leaves the session itself alive — you can fix the number and dial again.
Agent design for the phone
Phone audio is narrowband, echoey, and full of half-listening humans. Configuration that works well:
interruption_sensitivity: "low"— don't yield the floor to "mm-hm" and line noise (turn-taking).audio.noise_cancellation— denoise the caller's line. On a phone leg every filtering mode lands on the same speech-preserving noise suppressor, not the background-voice isolator WebRTC sessions get.greeting— the agent speaks first the moment the callee answers; a call that opens with silence gets hung up on.- Silence-timeout hooks — a
Saynudge at ~30s and anEndCallat ~90s handle voice mail pickups and walked-away callers even if your process dies mid-call. - Call-control tools —
end_callso the agent can hang up: see In-call tools.
No phone "mode" exists to switch on — a phone agent is an ordinary agent whose configuration names each behavior it needs.
Inbound calls
Inbound calling exists, but it is configured in the dashboard rather than through the SDK. On the workspace's Phone Numbers page you point a number at one catalog agent, and calls to that number reach that agent.
There is no SDK-side counterpart: you can't attach a session you started to an incoming call leg, and there's no webhook that hands one to your process. Everything on this page is about the outbound direction — a session you own, dialing out. If your product needs the agent reachable by phone under your own logic, originate the call from your side with dial().