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 inrandal.config.yaml) no longer places or answers calls —POST /voice/calland 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.
| Concern | What handles it |
|---|---|
| Phone number, PSTN ingress, media stream | Twilio, 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 |
| Conversation | An OpenAI Realtime-protocol model (gpt-realtime-2.1 by default) |
| Every trust decision, metering, and record | @randal/metering-proxy |
| Voicemail transcription | An 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.
- 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.
- 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/verificationagainst the proxy. - 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.
- 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:
| Readiness | What it means |
|---|---|
unconfigured | The 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_required | Nobody has submitted the KYC form yet. |
verification_pending | Submitted, under Twilio review. The number arrives when it's approved. |
verification_rejected | Twilio 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.
| Decision | When | What the caller hears |
|---|---|---|
| live | Allowlisted caller, quota and caps clear, no other call in progress | Disclosure, then a real conversation |
| record | Unverified caller, out of quota, no reserve headroom, daily cap hit, or the identity is already on a call | Disclosure, then "leave a message" — up to 120 seconds |
| grace_notice | The number is being released | A notice to update their records |
| refuse | The dialled number isn't one we own | Nothing — 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:
| Tool | What it does |
|---|---|
read_status | Runner heartbeat, record freshness, running and recent jobs |
read_spend | Raw dollars used and remaining this period |
queue_work | Queue 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 answered | leaveVoicemail: false | leaveVoicemail: true |
|---|---|---|
| A person | Disclosure, then the conversation | Same |
| An answering machine | Hang up in silence; the disposition is recorded as machine_no_voicemail | Disclosure, then one short message serving the objective, then hang up |
| A fax tone | Hang up | Hang 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:
| Limit | Default | Env var |
|---|---|---|
| Calls placed | 200 | RANDAL_OUTBOUND_MAX_CALLS_PER_DAY |
| Distinct new numbers | 250 | RANDAL_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
| Status | Meaning |
|---|---|
400 | No objective, one under 10 or over 1000 characters, or a destination that is not E.164 |
402 | Out of allowance, no headroom for a minimum viable call, or the daily voice cap is hit |
403 | Outbound is suspended for that identity |
429 | A rolling cap is at its ceiling, or the identity is already on a call |
503 | The 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.
| Limit | Value | Behavior |
|---|---|---|
| Per-call hard cap | 15 minutes | Graceful close, with 5s to flush final audio (outbound too — though the agent usually ends its own call first) |
| Spoken wrap-up warning | 13 minutes | The agent is told to start wrapping up |
| Per-call reserve | $3.50 raw | Held 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 identity | 1 live call | A second caller gets voicemail |
| Recording length | 120 seconds | |
| Passphrase attempts | 3 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(defaultmarin). These ten are the full set the realtime models accept;marinandcedarare 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 — andgpt-4o-mini-ttsaccepts 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.
personaSummaryandtoneStyle— 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
| Route | Purpose |
|---|---|
POST /voice/inbound | Twilio voice webhook → authorize → TwiML |
POST /voice/outbound | Answer webhook for a leg the proxy placed → TwiML |
WS /voice/stream | Media frames ⇄ the Realtime bridge |
POST /voice/status | Call lifecycle callbacks → finalize |
POST /voice/recording | Recording ready → download, transcribe, complete |
GET /voice/disclosure/… | The pre-rendered disclosure audio Twilio <Play>s |
GET /health | Liveness |
/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/callreturns unavailable even when LiveKit, Twilio and ElevenLabs are all configured correctly, and so doesrandal call/randal voice call, which post to it. - Legacy
voice.stt.provider,voice.stt.modelandvoice.stt.apiKeykeys parse with a startup warning and are ignored. The only livevoice.sttfield isopenrouterModel, 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
| Symptom | Likely cause | Fix |
|---|---|---|
| No number after buying an identity | Trust Hub profile not approved | Check 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 unset | Complete the deployment's Trust Hub primary profile and point the proxy at the media plane |
| Calls go straight to voicemail | Caller not in allowFrom, daily cap hit, quota exhausted, or the identity is already on a call | The reason on the call record says which: unverified_caller, daily_cap, quota, insufficient_reserve, busy |
| Agent won't read status or spend | Call never escalated | The caller must speak the identity's passphrase; caller ID alone never grants admin |
| Twilio webhooks rejected | Public base URL mismatch | RANDAL_MEDIA_PUBLIC_URL must be exactly what Twilio signs against — it is never derived from request headers |
| Call ends at 15 minutes | Per-call hard cap | Expected; the agent is warned at 13 minutes to wrap up |
| An outbound call is refused with 429 | A rolling cap, or that identity is already on a call | The error.cap and error.windowHours on the response say which limit; one identity carries one conversation at a time |
| An outbound call hangs up immediately | An answering machine or fax answered and leaveVoicemail was false | Expected; the call record's end_reason says machine_no_voicemail or fax_answered |
POST /voice/call returns unavailable | The legacy self-host path | Expected and permanent — see Legacy self-host voice |
See also
voice-deployment-split.md— where each voice process runs and whyvoice-go-live-checklist.md— the ops steps still between this design and a connected calllocal-development.md— running the media plane locallyconfig-reference.md— thevoice:schema, including the legacy fields