channels / voice & phone

Voice & phone

Managed phone numbers, the media plane, and what happens on a call.

Voice & Phone

Randal answers the phone, and places calls. A paid Randal Identity gives an agent its own phone number: calls to it are answered by a live conversational agent that can — once the caller proves who they are — read the account's status and queue work for the runner, and the same number can be asked to ring someone on the owner's behalf.

Two paths, one of them retired. The managed phone path described here is the current product: numbers are provisioned for you, audio terminates on Randal's media plane, and minutes are metered through the billing proxy. The older self-host stack (LiveKit + ElevenLabs + a separate STT provider, configured through the voice: block in randal.config.yaml) no longer places or answers callsPOST /voice/call and the gateway's /voice/* routes report unavailable even when fully configured. See Legacy self-host voice for what remains and why.

Voice is entirely optional. A text-only Randal needs none of this.


What runs the call

There is no LiveKit room, no ElevenLabs synthesis step, and no separate speech-to-text provider in the live path. Twilio's media stream is bridged straight to a speech-to-speech Realtime model, which removes the STT → LLM → TTS round-trip that made the old pipeline too slow for a phone call.

ConcernWhat handles it
Phone number, PSTN ingress, media streamTwilio, in a per-org subaccount owned by Randal
Identity behind the number (KYC)Twilio Trust Hub — a secondary customer profile per org
Audio termination and the model bridge@randal/voice-media, an always-on Railway service
ConversationAn OpenAI Realtime-protocol model (gpt-realtime-2.1 by default)
Every trust decision, metering, and record@randal/metering-proxy
Voicemail transcriptionAn HTTP transcription endpoint (gpt-4o-mini-transcribe by default)

Both model endpoints are env vars, not hardcodes. The Realtime default is direct OpenAI because OpenRouter has no realtime path today; the transcription default is OpenAI but OpenRouter's /api/v1/audio/transcriptions is protocol-compatible and works as a drop-in.


Architecture: two planes

The question "which runner does the call run on" dissolves once audio and tools are separated.

Audio never touches the runner. A media stream needs a persistent socket for the length of the call, terminating somewhere always-on with a public address. Conversational voice has roughly 500ms of round-trip budget and the bridge to the model already spends most of it.

Tools do touch the runner. The model runs at the media service; when it needs to read state or start a job it calls the proxy, which reaches the assigned runner through the relay. If that runner is asleep the agent still talks — it just has fewer tools.

AUDIO PLANE — always on
  [Caller] --PSTN--> [Twilio] --stream--> [Media plane] <--audio--> [Realtime model]
                                          (Railway)
                                              │
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─  audio stops here  ─ ─ ─ ─
                                              │
TOOL PLANE — may be offline                   │  service token / per-call token
                                       [Metering proxy]
                                              │
                              ┌───────────────┴───────────────┐
                        account state                    queued work
                              │                                │
                        [Supabase]                          [Relay]
                  jobs · threads · spend            runner dialled out (NAT)
                  (readable while the Mac                      │
                   is asleep)                        [Runner: Mac or Cloud]

The media plane holds no database and no storage credential. It terminates audio and nothing else: dialled-number resolution, spend and concurrency gates, dispositions, and token minting all belong to the proxy.

Routing runs one direction only

Dialled number → identity → runner. Never the reverse. The dialled number is trustworthy; the calling number is spoofable and determines nothing. Several identities on one Mac are therefore unambiguous, and an offline runner leaves the line answering rather than dark.


Getting a number

Managed provisioning is the default. You do not create a Twilio account, buy a number, or configure a webhook.

  1. Buy Randal Identity. Each identity is billed as a subscription add-on and carries both channels — an email address and a phone number. The managed email address is minted immediately.
  2. Verify. Phone numbers are issued only against an approved Trust Hub profile, so the account submits a business or sole-proprietor KYC form once. Submit it from Comms → Identity → Reach Randal in the desktop app, or POST /channels/telephony/verification against the proxy.
  3. Wait for review. Twilio reviews the profile. Outcomes arrive two ways deliberately — a signature-validated status callback (fast) and an hourly poll (reliable) — and both converge on the same state.
  4. The number arrives on approval. The provisioner buys it in the same request that records the approval, points its webhooks at the media plane, and attaches the org's Trust Hub profile. Nothing else to configure.

The reconciler is idempotent by construction, so the Stripe webhook, the hourly sweep, and a manual "provision now" all converge on the same state and a missed webhook self-heals.

Why a number might not be there yet

GET /channels/telephony/verification reports the state, and the surfaces render it in plain words:

ReadinessWhat it means
unconfiguredThe phone plane isn't switched on for this deployment — no media plane URL, or no Trust Hub primary profile. Numbers aren't issued at all.
verification_requiredNobody has submitted the KYC form yet.
verification_pendingSubmitted, under Twilio review. The number arrives when it's approved.
verification_rejectedTwilio declined the profile. The rejection reason comes back with it.

A number bought before the media plane is deployed rings into nothing, and one bought before the profile is approved carries no verified identity — hence the gate.


What a caller gets

Every inbound call is authorized by the proxy before any paid session opens. The dialled number resolves to the identity and its org; the calling number is matched against the identity's allowFrom allowlist only to decide eligibility.

DecisionWhenWhat the caller hears
liveAllowlisted caller, quota and caps clear, no other call in progressDisclosure, then a real conversation
recordUnverified caller, out of quota, no reserve headroom, daily cap hit, or the identity is already on a callDisclosure, then "leave a message" — up to 120 seconds
grace_noticeThe number is being releasedA notice to update their records
refuseThe dialled number isn't one we ownNothing — the call is rejected

Recording costs roughly 50× less than a live session, which is why unverified callers land there: it is both the cheap path and the one that closes the prompt- injection surface.

The disclosure is not optional

Every answered call opens with it, played as TwiML before the stream opens:

"You've reached {identity}, an AI assistant. This call is recorded and transcribed."

In the identity's own voice. The words are pre-rendered through the speech endpoint using the same voice name the Realtime session is about to use, cached on the media plane, and <Play>ed — so the caller hears one voice, not Twilio's stock TTS handing over to a stranger. The render is per (voice, text), so one call warms every later call for that identity.

It degrades, it never disappears. If the speech endpoint is down, the key is wrong, or a cold render is slow, the very same words go out as <Say> in Twilio's voice and the call proceeds. The disclosure carries legal weight and its wording is literal, which is also why the Realtime model is never asked to speak it as its first turn: a generative model paraphrases.

Caller ID is not authentication

An allowlist match makes a caller eligible for a live session — never an authenticated one. Every live call starts in the external access class, where the agent can converse and take a message but holds exactly one tool: verify_passphrase. Nothing reads account state: read-only is not safe, because a stranger who talks their way past the agent could otherwise extract repo names, job history, chat contents and spend without ever touching the machine.

Admin is passphrase-only, mid-call. The caller speaks a passphrase set on the identity; the agent offers verify_passphrase, and a match escalates the call and swaps in a new call-scoped token. Three attempts per call, then the tool goes silent. Only after escalation do these unlock:

ToolWhat it does
read_statusRunner heartbeat, record freshness, running and recent jobs
read_spendRaw dollars used and remaining this period
queue_workQueue a request for the runner to pick up when it wakes

Server-side, every /call-scope/* route requires accessClass === "admin" — a stale client-side tool list buys a caller nothing.

When the runner is asleep

The line still answers and the conversation is still live; the agent reads hosted state instead of the machine and queues work for wake-up. Because record sync is push-only and cursor-based, hosted data is current only as of the Mac's last push — so the agent is instructed to speak with a timestamp:

"Your deploy finished at 4:12. Nothing's run since — but your Mac's been offline about two hours, so I can't see anything newer than that."


Placing a call

Ask the identity to ring someone and it does, from its own number, with the owner's verified Trust Hub profile behind the caller ID.

POST /channels/voice/calls
Authorization: Bearer <the user's session>

{
  "contactId": "…",
  "to": "+15557654321",
  "objective": "Book a table for four at 7pm on Friday and ask if they take a card deposit.",
  "leaveVoicemail": false
}

202 { "callSid": "CA…" } once the carrier has the call.

GET /channels/voice/calls/CA…        # or /channels/voice/calls/latest

200 { "callSid", "status": "in_progress" | "ended", "endReason", "messageId", … }. This is the route the agent's check_call tool polls. The runner waits it out until the call ends, then reads the transcript by messageId, so the agent reports how the call went without being asked twice. An ended call with no messageId never connected.

The objective is the feature

It is required, 10–1000 characters, and refused when absent — not out of pedantry. The objective becomes the agent's instructions and its stopping condition. The agent is told to pursue it, to notice when it is met or plainly unreachable, and then to thank the person and hang up, using an end_call tool it holds for exactly that purpose. Without one, a model on a phone line talks until the other party gives up, which is the behaviour that makes an AI call intolerable.

An outbound call holds end_call and nothing else. There is no passphrase path and no account reads: nobody on the far end of a call we placed has verified anything.

The disclosure, again

Played as TwiML before the stream opens, exactly as inbound:

"This is {identity}, an AI assistant calling on behalf of {owner}. This call is recorded."

Unless we dialled the owner's own number, in which case both the disclosure and the agent's whole register change:

"Hi, it's {identity}, your assistant. This call is recorded."

The recording notice is the part that never moves. The rest does, because "calling on behalf of Drew" — said to Drew — is inaccurate, and naming a responsible third party only makes sense when there is one.

The owner is recognised by the number we dialled, matched against the phone channel's allowFrom list. That is sound only outbound: we chose the number, so there is nothing to spoof. Inbound, caller ID never implies the owner — an allowlist match there buys a live external session and nothing more, and the warm register arrives only after a passphrase escalates the call mid-conversation.

Framing is tone and disclosure wording. It moves no access: an outbound leg holds end_call alone and reads nothing, whoever picked up.

Answering machines

Twilio's machine detection runs synchronously on origination, so who picked up is known before any TwiML is chosen — nothing is ever read aloud to a recorder by accident.

Who answeredleaveVoicemail: falseleaveVoicemail: true
A personDisclosure, then the conversationSame
An answering machineHang up in silence; the disposition is recorded as machine_no_voicemailDisclosure, then one short message serving the objective, then hang up
A fax toneHang upHang up

Where an outbound call lands

In the same inbox an inbound one does, as a trigger — not a quarantine. The quarantine question is "a stranger reached your agent, do you want this?", and an outbound call does not raise it: the owner asked for the call through a signed-in session. The record is a report of something they did, so it is not held for review and it fires no needs-you alert.

It grants nothing extra. Outbound legs stay external with senderVerified false, and the pending-work queue gates on the access class, so nothing the person on the other end said can start a run.

Caps are a circuit breaker

Per identity, over a rolling 24 hours, mirroring the outbound-email caps:

LimitDefaultEnv var
Calls placed200RANDAL_OUTBOUND_MAX_CALLS_PER_DAY
Distinct new numbers250RANDAL_OUTBOUND_MAX_NEW_NUMBERS_PER_DAY

They sit roughly 10× the email equivalents on purpose. A person telling their agent to ring twenty restaurants is doing exactly what this is for; these exist to stop a runaway loop or a compromised account, not to ration anyone. A number the identity has already spoken with — in either direction — is not a new destination.

There is no destination allowlist. Users call restaurants, businesses, anyone. The verified Trust Hub profile is what attributes the traffic to the customer; a permitted-numbers list would stop only the legitimate use.

outbound.suspended on the identity's phone channel refuses every call with 403 until an operator clears it — the same flag, the same shape, and the same purpose as the one a spam complaint sets on outbound email.

Why a call is refused

StatusMeaning
400No objective, one under 10 or over 1000 characters, or a destination that is not E.164
402Out of allowance, no headroom for a minimum viable call, or the daily voice cap is hit
403Outbound is suspended for that identity
429A rolling cap is at its ceiling, or the identity is already on a call
503The phone plane is not configured on this deployment

Everything after the answer is the inbound machinery, unchanged: the same per-call reserve, the same 60-second usage heartbeats, the same transcript upload, the same completion record. The row is direction: 'outbound' and outbound minutes meter at the outbound telco rate.


Voicemail and recordings

Because the agent picks up, there is no beep-and-blob in the normal case. What a live call produces is a structured handoff — caller, what they wanted, what the agent committed to, what it queued, and the retained transcript and audio.

Actual recorded voicemail is the overflow path (already on a call) and the policy path (caller not allowlisted, or a cap is hit). Recordings cap at 120 seconds, are downloaded and transcribed after hangup, and land in the same inbound record as everything else. Runner-offline is not voicemail — it is a live conversation with deferred action.

Recordings and transcripts go to a dedicated private bucket, separate from both artifacts and the channels bucket, and are retained for 45 days.


Limits, caps, and metering

Voice is metered in raw provider dollars, like every other Randal usage.

LimitValueBehavior
Per-call hard cap15 minutesGraceful close, with 5s to flush final audio (outbound too — though the agent usually ends its own call first)
Spoken wrap-up warning13 minutesThe agent is told to start wrapping up
Per-call reserve$3.50 rawHeld on answer; released at completion. Covers a full 15 min at the measured ~$0.229/min — $3.00 did not
Minimum viable call$0.60 raw (~3 min)A live session is refused without this much headroom
Daily cap, per org$15 raw (default)Configurable via RANDAL_VOICE_DAILY_CAP_USD — ~65 min/day at the measured ~$0.22/min
Concurrency, per identity1 live callA second caller gets voicemail
Recording length120 seconds
Passphrase attempts3 per call

What a call actually costs

Realtime bills six rates, and the spread is the whole story. On gpt-realtime-2.1: audio input $32/Mtok, that same token once cached $0.40, text input $4, audio output $64, text output $24. A realtime turn re-sends the whole conversation, so input tokens roughly double every minute while output stays flat — and nearly all of that growth is a cache hit.

Price it flat and a long call looks two to three times more expensive than it is. A measured 200-second call on 2026-09-12 (33,384 input / 9,612 output tokens) read $1.68 under the old flat estimate and about $0.73 priced properly — ~$0.22/min, which is what the $0.20/min in MIN_CALL_RESERVE_RAW_USD assumed all along. The cost is dominated by OUTPUT audio; the input line is mostly cache.

So the media plane reports the per-modality, cached-vs-not split from response.done, and the proxy prices it (priceRealtimeUsage in @randal/core) — pricing lives with billing, not with the service being billed. Cached counts are a subset of their modality, never an addition. A model missing from the rate table, or a breakdown that fails to arrive, bills at the dearest rate rather than at zero: an unnoticed under-charge loses money silently, while an over-charge gets reported and fixed.

Usage heartbeats every 60 seconds keep a crashed media service's unbilled loss under a minute and double as the mid-call cap check. A redial from the same caller while a call is open is treated as a reconnect — newest wins, the stale leg is replaced — rather than as a busy signal.


Choosing the voice and persona

Set per identity, from Comms → Identity or PATCH /channels/contacts/:contactId:

  • voice — one of the Realtime voices: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar (default marin). These ten are the full set the realtime models accept; marin and cedar are the two OpenAI recommends for best quality, and the two that postdate the original eight. The list is shared by the proxy, the media plane, and the pickers, so a voice chosen on one screen is the voice the caller hears — and gpt-4o-mini-tts accepts all ten, so the spoken disclosure matches whichever is picked.

Picking a voice on either surface also plays it.

The clip is rendered by the media plane, out of the very same disclosureAudio cache the call's spoken disclosure comes from — so "the preview sounds like the call" is structural rather than a matter of two configs agreeing, and the speech key exists in exactly one place. POST /voice/preview there takes the sentence from its caller, so VOICE_PREVIEW_TEXT stays defined once in @randal/core (the media plane is a standalone image and deliberately does not depend on core).

The chain is: surface → GET /channels/voice/preview/:voice on the proxy (user session, rate limit, immutable cache headers) → POST /voice/preview on the media plane (service token, render, cache). The corpus is ten clips of one sentence, so a browse costs a render at most once per region, and it is deliberately not metered to the org. Tapping the voice already chosen replays it without a save — that is how you audition the current one. Desktop goes through the gateway relay at /api/channels/voice/preview/:voice; mobile reads the proxy directly, so previews work while the Mac is asleep.

  • personaSummary and toneStyle — the owner's words about who the agent is and how it should sound. They are injected into the call instructions as the owner's description, never the caller's.
  • allowFrom — the numbers eligible for a live session.
  • passphrase — the escalation secret, set on the number (PATCH /channels/contacts/:contactId/channels/:channelId, or the "Call passphrase" field under the number in Identity → Reach Randal on desktop and mobile). Only its SHA-256 is stored, so it can never be read back — the field only sets a new one or clears it. 4–200 characters after normalization (lowercased, whitespace collapsed), which is what the caller's speech is matched against.

Operating the media plane

Skip this section unless you are running your own deployment. The media plane is a sibling of the relay: a standalone Bun service on Railway with numReplicas 1, because Vercel serverless cannot hold a media stream's long-lived inbound WebSocket.

Routes

RoutePurpose
POST /voice/inboundTwilio voice webhook → authorize → TwiML
POST /voice/outboundAnswer webhook for a leg the proxy placed → TwiML
WS /voice/streamMedia frames ⇄ the Realtime bridge
POST /voice/statusCall lifecycle callbacks → finalize
POST /voice/recordingRecording ready → download, transcribe, complete
GET /voice/disclosure/…The pre-rendered disclosure audio Twilio <Play>s
GET /healthLiveness

/voice/disclosure/… is the one unauthenticated route: Twilio's media fetcher carries none of our credentials, so it must be. One disclosure sentence is not sensitive, but the path is an HMAC under a per-process secret rather than a hash of the text, so the URL space is not enumerable from the ten voice names and a predictable sentence.

/voice/outbound cannot be driven by anyone but the proxy. Its URL carries an unguessable origination id that only the proxy minted, that id is inside the signed URL Twilio validates against, and the media plane holds nothing else about the call — the proxy resolves the tenant, the objective and the machine policy from its own row, exactly as it does inbound.

Every Twilio webhook is validated against the subaccount's auth token — the number's owner — with the URL rebuilt from the configured public base, never from Host or X-Forwarded-* headers. Header-derived URLs are the classic validateRequest bypass, and Railway fronts a proxy.

Media plane environment

TWILIO_ACCOUNT_SID=AC…             # parent account; used ONLY to read subaccount
TWILIO_AUTH_TOKEN=…                # auth tokens for signature validation
RANDAL_REALTIME_API_KEY=…          # never leaves this service
RANDAL_REALTIME_URL=wss://api.openai.com/v1/realtime   # any Realtime-protocol endpoint
RANDAL_REALTIME_MODEL=gpt-realtime-2.1
RANDAL_TRANSCRIPTION_URL=https://api.openai.com/v1/audio/transcriptions
RANDAL_TRANSCRIPTION_API_KEY=…     # defaults to the realtime key
RANDAL_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
RANDAL_SPEECH_URL=https://api.openai.com/v1/audio/speech   # the spoken disclosure
RANDAL_SPEECH_API_KEY=…             # defaults to the realtime key
RANDAL_SPEECH_MODEL=gpt-4o-mini-tts # the only speech model that takes all ten voices
RANDAL_PROXY_URL=https://proxy.example.com    # no /v1
RANDAL_MEDIA_SERVICE_TOKEN=…       # this service's credential for /voice/calls/*
RANDAL_MEDIA_PUBLIC_URL=https://media.example.com   # what Twilio signs against
RANDAL_MEDIA_PORT=7720

Proxy environment for the phone plane

Each piece degrades independently to a 503 stub on its own routes rather than taking the proxy down.

TWILIO_ACCOUNT_SID=AC…             # parent; per-org subaccounts are created beneath
TWILIO_AUTH_TOKEN=…
TWILIO_SUBACCOUNT_PREFIX=randal-
TWILIO_PRIMARY_PROFILE_SID=BU…     # approved Trust Hub PRIMARY profile.
                                   # UNSET ⇒ no verification ⇒ no numbers issued.
RANDAL_MEDIA_URL=https://media.example.com   # purchased numbers' webhook target
RANDAL_MEDIA_SERVICE_TOKEN=…       # must match the media plane
RANDAL_CALL_JWT_SECRET=…           # per-call tokens; dedicated, never reused
RANDAL_TRANSCRIPT_BUCKET=voice-transcripts
RANDAL_PROXY_PUBLIC_URL=https://proxy.example.com   # Trust Hub review callback
RANDAL_VOICE_DAILY_CAP_USD=15
RANDAL_OUTBOUND_MAX_CALLS_PER_DAY=200        # per-identity circuit breaker
RANDAL_OUTBOUND_MAX_NEW_NUMBERS_PER_DAY=250  # (defaults; both optional)

Voice previews need nothing here. They are rendered on the media plane and relayed through RANDAL_MEDIA_URL + RANDAL_MEDIA_SERVICE_TOKEN, both already set above — the proxy never holds a speech key, because the media plane already has one and there is no reason for the same credential to exist twice.

TWILIO_PRIMARY_PROFILE_SID is the switch that makes number issuance possible at all: without an approved primary profile there is nothing to hang the per-org secondary profiles under, so readiness reports unconfigured and no numbers are bought.

Running it locally

bun run --cwd packages/voice-media start   # default port 7720

It takes its entire configuration from the environment above — see packages/voice-media/src/env-config.ts for the authoritative contract, and local-development.md for the local loop. docker-compose.voice.yml belongs to the legacy stack below and is not needed for the media plane.


Legacy self-host voice

The voice: block in randal.config.yaml — LiveKit rooms, ElevenLabs/Cartesia/Edge TTS, Twilio BYO credentials, turn detection, video — is the original single-tenant self-host design. It still parses, and the runtime classes still ship in @randal/voice, but it does not carry calls:

  • Live-call speech-to-text was removed pending this rework. POST /voice/call returns unavailable even when LiveKit, Twilio and ElevenLabs are all configured correctly, and so does randal call / randal voice call, which post to it.
  • Legacy voice.stt.provider, voice.stt.model and voice.stt.apiKey keys parse with a startup warning and are ignored. The only live voice.stt field is openrouterModel, which selects the audio-capable OpenRouter chat model used for teach-narration transcription — unrelated to phone calls.
  • voice.video.* (visionModel, publishScreen, recordSessions) is schema only. Nothing reads it. Randal does not currently join Zoom, Google Meet or Teams meetings; earlier revisions of this guide described that as a feature and it was never wired.
  • docker-compose.voice.yml (Redis, LiveKit server, LiveKit SIP bridge) still starts the old local media stack. It is a dev convenience for that retired path.

The single-number, BYO-credentials shape is also the wrong shape for the managed product: a managed number lives in the account record, the credentials belong to Randal, and the runner never sees an auth token. If you do not want voice, remove the voice: block and the - type: voice channel entirely; nothing else changes.


Troubleshooting

SymptomLikely causeFix
No number after buying an identityTrust Hub profile not approvedCheck GET /channels/telephony/verification, or the Comms → Identity panel, for which readiness state you're in
"Phone numbers aren't available yet"TWILIO_PRIMARY_PROFILE_SID or RANDAL_MEDIA_URL unsetComplete the deployment's Trust Hub primary profile and point the proxy at the media plane
Calls go straight to voicemailCaller not in allowFrom, daily cap hit, quota exhausted, or the identity is already on a callThe reason on the call record says which: unverified_caller, daily_cap, quota, insufficient_reserve, busy
Agent won't read status or spendCall never escalatedThe caller must speak the identity's passphrase; caller ID alone never grants admin
Twilio webhooks rejectedPublic base URL mismatchRANDAL_MEDIA_PUBLIC_URL must be exactly what Twilio signs against — it is never derived from request headers
Call ends at 15 minutesPer-call hard capExpected; the agent is warned at 13 minutes to wrap up
An outbound call is refused with 429A rolling cap, or that identity is already on a callThe error.cap and error.windowHours on the response say which limit; one identity carries one conversation at a time
An outbound call hangs up immediatelyAn answering machine or fax answered and leaveVoicemail was falseExpected; the call record's end_reason says machine_no_voicemail or fax_answered
POST /voice/call returns unavailableThe legacy self-host pathExpected and permanent — see Legacy self-host voice

See also