Calls
Inbound and outbound voice. The assistant answers, a human takes over, and the conversation carries across the handoff.
For why it is built the way it is, see Calls — Architecture. This page is how to run it.
Two modules
| module | what it is | enable |
|---|---|---|
Calls |
the capability: calls, routing, agents, queues, numbers | required |
CallsPlivo |
one vendor | required, until a second adapter exists |
Both ship disabled. Enable Calls first — CallsPlivo refuses to register
without it and says so in the log rather than failing the boot.
Disabling CallsPlivo stops new calls and leaves history intact. Disabling
Calls removes voice entirely without touching chat.
Setting it up
1. Credentials
Voice vendors are contracted by the platform, not by tenants. Credentials live
once, encrypted, in call_provider_accounts — never in tenant settings, because
an auth token that reached a tenant screen would let anyone place calls billed
to the platform.
PLIVO_AUTH_ID=...
PLIVO_AUTH_TOKEN=...
Environment variables are the bootstrap path; the stored account takes precedence once one exists.
Verify from Calls → Settings → Test. It is the only way an operator learns a rotated token broke voice before a customer does.
2. Public callback URL
CALLS_WEBHOOK_BASE_URL=https://api.example.com
Must be reachable from the internet, and must match what numbers are configured with. These two drifting apart is the classic cause of inbound silently dying — no error, no webhook, just calls that stopped arriving.
If the platform URL changes, re-push it: Numbers → the refresh icon.
3. The media plane, for AI calls
Audio never touches Laravel. The Python worker pool in services/ai-agent holds
the WebSocket and runs the STT → LLM → TTS turn.
CALLS_MEDIA_WS_URL=wss://media.example.com
CALLS_MEDIA_AUDIO_FORMAT=audio/x-l16
CALLS_MEDIA_SAMPLE_RATE=16000
Leave CALLS_MEDIA_WS_URL empty and AI-handled calls speak an apology and hang
up rather than answering to silence. The settings screen says so explicitly,
because a caller hearing nothing is the failure this module works hardest to
avoid.
4. A number
Numbers → Get a number. Numbers are rented on the platform account and assigned to a tenant, so a tenant never needs its own carrier relationship.
Several countries will not release one without accepted regulatory paperwork — see Compliance.
5. Queue workers
The module dispatches to two queues, calls and calls-media. Both are wired
up already — calls is declared in the module's module.json and folded into
Horizon's default supervisor automatically, and calls-media has its own
supervisor because archiving a recording means downloading a whole audio file
and must not hold a slot a queued call is waiting for.
What is not automatic is that something has to be running:
php artisan horizon # or queue:work --queue=calls,calls-media
php artisan schedule:work # the queue sweeper and the reaper
Without a worker, outbound calls stay queued and never dial, and webhooks are accepted but never normalised. Without the scheduler, callers waiting for an agent are never connected and dead media workers are never reaped.
6. Enable it for the tenant
Enabling a module is two steps, and the second one is easy to miss.
php artisan module:enable Calls marks it available on the platform — it
registers the service provider, so routes and migrations exist. It does not
give any tenant access to it. A tenant with no modules enabled has an empty
navigation and gets 403 from every module API, which presents as "everything
404s after login".
The per-tenant half goes through ModuleManager::enable(), which the admin UI
calls when an operator switches a module on. From the console:
$tenant = App\Models\Tenant::find('acme');
$manager = app(App\Core\Services\ModuleManager::class);
$manager->enable('communications', $tenant); // dependency first
$manager->enable('calls', $tenant);
$manager->enable('calls-plivo', $tenant);
Order matters. enable() refuses a module whose declared dependencies are
not yet enabled for that tenant, and returns false rather than throwing.
Calls depends on Core and Communications, so enabling it alphabetically
in a loop fails — calls comes before communications.
Then seed permissions and check the central catalogue actually allows it:
php artisan module:sync # module.json -> DB
php artisan module:seed-permissions --tenant=acme
modules.is_activein the central catalogue vetoes the filesystem. A module can be enabled inmodules_statuses.jsonand still not load if its catalogue row is inactive — the symptom ismigratereporting "Nothing to migrate" for a module whose tables plainly do not exist.
7. Someone to answer
At minimum: a queue, and an agent with at least one device to ring.
An agent is a person, not a device, and the same person is routinely at a laptop and carrying a phone. Both ring at once, and whoever picks up first gets the call:
| device | how it is reached | goes stale? |
|---|---|---|
| browser softphone | WebRTC endpoint, registered while the tab is open | yes — dropped after agent_registration_tolerance_seconds (90) |
| mobile app | the same WebRTC endpoint, re-asserted every 30s while online | yes — same window |
| the agent's own number | fallback_number, dialled over the carrier |
no — a number is reachable whether the app is open, backgrounded or killed |
The two kinds are deliberately asymmetric. A browser exists only while it is connected, so a registration nobody has refreshed is not believed. A phone number needs no heartbeat at all.
An agent with no live device and no fallback number is invisible to routing.
Their calls ring nowhere, and the trail says agent_unreachable rather than
leaving you to infer it.
One number is never dialled: the one that is calling. If a caller turns out to be an agent — they rang their own support line, or a colleague's phone is on their record — the carrier cannot bridge a handset to itself. That leg dies instantly with no hangup cause, burning the whole ring window, so it is dropped and logged as
agent_endpoint_is_the_caller. Their browser still rings, so the call is still answerable at a desk.
What a tenant can change
Most of how this module behaves is a deployment default set by an environment variable. A tenant can override a subset of those from Calls → Settings, and the override wins for that tenant only.
Precedence is: the tenant's stored value, then the environment default. A tenant who has never opened the settings screen answers the phone exactly as the deployment says, so adding an override cannot change anyone else.
| setting | env default | what it changes |
|---|---|---|
inbound_default_mode |
CALLS_INBOUND_DEFAULT_MODE |
who answers when no rule matches |
ai_greeting |
CALLS_AI_GREETING |
the first thing a caller hears |
ai_mode |
CALLS_AI_MODE |
realtime, turn-based, or auto |
agent_ring_seconds |
CALLS_AGENT_RING_SECONDS |
how long every one of an agent's devices rings |
voicemail_after_seconds |
CALLS_VOICEMAIL_AFTER_SECONDS |
how long a caller waits before overflow |
max_concurrent_calls |
CALLS_MAX_CONCURRENT |
ceiling on simultaneous live calls |
outbound_rate_per_second |
CALLS_OUTBOUND_RATE_PER_SECOND |
dialler pacing; 0 uses the adapter's own limit |
voice_turn_timeout_seconds |
CALLS_VOICE_TURN_TIMEOUT |
wait for the caller to start speaking |
voice_speech_end_timeout |
CALLS_VOICE_SPEECH_END_TIMEOUT |
pause that counts as "finished" |
voice_max_silent_turns |
CALLS_VOICE_MAX_SILENT_TURNS |
silences tolerated before handing to a person |
Every one of these is read. CallSettingsResolutionTest fails if a key is
editable and nothing consults it, and the expectation is an exact empty list —
adding a name to it to make the test pass is not the fix.
Two properties worth knowing, because both were once wrong:
A setting nothing reads is caught by a test. Every key the settings screen
offers must be read back through CallSettingsResolver somewhere in the
module, or CallSettingsResolutionTest fails. Nine of these keys were once
saved, echoed back, redisplayed on reload — and read by nobody, so the
deployment default won silently.
Settings are never read without a tenant. The scheduled sweepers walk every
tenant's calls in one pass with no tenant initialised, where the tenant scope
on the settings table does nothing. Asking for settings there yields the
environment defaults, never some other tenant's row; code that needs a specific
tenant's values names it with CallSettingsResolver::forTenant().
Routing
Precedence, most specific first:
- a routing rule whose conditions hold
- the number's own configured mode
- the module default (
CALLS_INBOUND_DEFAULT_MODE,aiout of the box)
A tenant that has configured nothing still answers the phone. Routing is something to grow into, not a prerequisite for having a working line.
Rules
Evaluated by priority, and at equal priority a rule naming a specific number beats a catch-all — which is what people expect when they add "and route this one line differently".
Conditions:
| key | matches |
|---|---|
time_of_day |
{start, end, timezone, days[]} |
days |
days of the week |
caller_pattern |
regex against the E.164 caller |
caller_prefix |
leading digits |
is_known_customer |
whether the caller resolved to a customer |
date_range |
{from, to} — holiday routing |
All conditions must hold. An unrecognised key fails the rule closed — a rule nobody understands should not silently match everything.
Actions: ai, agent, queue, ivr, voicemail, forward, reject.
Queues
A caller on hold hears music and no explanation, so a queue is defined by what bounds that wait:
max_wait_seconds— then the overflow action, rather than waiting foreveroverflow_action—voicemail,ai,forwardorhangupbusiness_hours+closed_action— evaluated in the queue's own timezonewrap_up_seconds— an agent who just hung up is still writing notes; ringing them immediately is how dispositions end up blank
Strategies: longest_idle (default), least_busy, priority, round_robin,
skill.
Outbound
Three origins, one path — the dialer, a campaign, or calls.originate on the
module bus. All queue first.
That queue is not decoration. Plivo processes outbound API requests at two calls per second by default. Fire a thousand-call campaign at the API and most of it comes back rejected — not queued, rejected — and the operator sees a campaign that "didn't run" with no obvious cause.
CALLS_OUTBOUND_RATE_PER_SECOND=0 # 0 = use the adapter's declared limit
CALLS_MAX_CONCURRENT=20 # per tenant, so one campaign cannot
# starve everyone else's inbound
How an outbound call is routed
Inbound and outbound are decided by different routers, and the distinction matters more than it looks.
InboundRouter decides from the called number — rules, then that number's
configuration, then the module default. On an outbound call the called number is
the customer, so none of that applies. Running outbound through it matches
nothing, falls to the module default, and silently overwrites the mode the
dialer asked for.
OutboundRouter reads the intent back instead, because it was fixed when the
call was placed:
| placed with | when the far end answers |
|---|---|
mode: agent (click-to-call) |
bridge to the agent endpoint of whoever pressed dial |
mode: ai (campaign, scheduled) |
hand to the media plane |
mode: agent, no agent resolvable |
return to the tenant's own line |
That last row is deliberate. Falling through to AI would open a media session
the call was never meant to have, and the customer who just answered would hear
an apology. The agent is resolved from assigned_agent_id, then from the
initiated_by user's agent record — so the person pressing dial must be
registered as an agent with an endpoint, or there is nothing to bridge to.
calls.inbound_default_modenever applies to an outbound call. If a campaign call is answering as voicemail, something is routing it through the inbound path.
Numbers are normalised before dialling
Operators type local numbers. Carriers accept E.164 and nothing else, and the rejection is unhelpful — a ten-digit Indian mobile with no country code comes back as "Calls to this destination region are barred", which reads like an account permission problem.
The dialer normalises using the country of the tenant's own outbound line:
| entered | dialled | why |
|---|---|---|
8113907802 |
918113907802 |
local number, line is Indian |
08113907802 |
918113907802 |
national trunk 0 does not travel |
00918113907802 |
918113907802 |
00 is the other way of writing + |
+91 81139-07802 |
918113907802 |
formatting stripped |
A country the platform has no calling code for is passed through untouched rather than guessed at — a wrong prefix dials a stranger. Every normalisation is logged with what was entered and what was dialled.
Campaigns
Scheduled outbound AI calls with a knowledge base and tools — the same agent the chat assistant uses, over a different transport.
A campaign cannot be started without a calling window. Dialling someone at three in the morning does not produce an unhappy customer, it produces a carrier suspending the number. The window is read in the campaign's own timezone.
Also configurable: max_attempts, retry_after_minutes, retry_on_machine,
leave_voicemail.
The agent's console
Agents answer in the browser. There is no separate softphone to install and no desk phone to configure — an agent who is logged in and available is a destination the router can reach.
One floating surface, not a control per theme
The call bar is rendered once from the authenticated layout, gated on the
calls module being enabled, and floats above whatever page the agent is on.
That is deliberate rather than convenient. Six themes ship their own header, and putting this control in each of them would couple every theme to this module and give six chances to diverge. It also has to survive navigation: an agent on a call looks up the customer's last order, and a call surface that lived in a page would end the moment they did.
What the bar says
| state | meaning |
|---|---|
Connecting your softphone… |
registering with the carrier |
Ready for calls |
registered; calls can arrive |
Softphone problem |
registration failed — the agent is not reachable |
Offline |
the agent has made themselves unavailable |
Updating… |
a presence change is in flight |
The bar is present in all of them, including Offline. An earlier version
returned nothing when the agent was offline, which removed the only control
that could bring them back.
Availability is a claim; reachability is a fact
An agent has four statuses — online, away, busy, offline — and the
toggle sets them. But "online" is a row we wrote, and nothing about writing it
makes a phone ring. Routing asks a stricter question:
$this->status === AgentStatus::Online
&& $this->current_calls < $this->max_concurrent_calls
&& ($this->wrap_up_until === null || $this->wrap_up_until->isPast())
&& $this->endpoint_address !== null
&& $this->isReachable();
isReachable() is the part that matters for browser agents. A WebRTC endpoint
exists only while the browser holds a socket open to the carrier — close the
tab and the registration lapses, while the row still cheerfully says
online. So the softphone re-asserts on a timer, and an agent whose last
assertion is older than CALLS_AGENT_REGISTRATION_TOLERANCE (90s) is skipped.
The tolerance only has to outlast one missed beat — a GC pause, a blip — without outlasting a closed laptop. Too long and callers are sent to a tab that is gone; too short and an agent flickers out of the pool between beats.
An agent with a fallback_number is reachable regardless: if the browser is
dark, the call rings a real phone instead.
What an agent sees when a call arrives
GET /calls/agents/me/active-call is the screen pop, and it carries more than
a ringing indicator:
- who is calling — the matched customer, not just a number
- why it reached them — the assistant's own summary when it handed over
- what has already been said — the transcript so far
All three were already in the payload and none of it was on screen; a call showed a duration and nothing else. It is collapsible, because the agent navigating away to look up an order is the normal case, not the exception.
Handing a caller on
Transfer goes to a colleague or to a queue, from the call itself:
POST /calls/{uuid}/transfer { agent_id | queue_id, summary? }
The colleague list is other agents who are online — a.id !== agent.id — so
the one destination that cannot help is not offered. The optional summary
travels with the call, so the next person starts where the last one stopped
rather than asking the caller to repeat themselves.
The AI-to-agent handoff
The transfer that matters. The assistant decides it cannot help, a human is found, and the transcript comes with it — the agent's screen opens with the conversation already on it, on web and on mobile.
Over the module bus:
$bus->call('calls.transferToAgent', [
'call_uuid' => $uuid,
'queue_id' => $queueId, // or agent_id
'summary' => 'Wants to change the delivery address on order 1042.',
]);
If nobody is free the caller is parked rather than dropped, and the queue sweeper connects them when someone frees up. If the transfer itself fails, the agent is released and the caller returns to the assistant — never to silence.
Who is calling
Every inbound call is matched to a customer before anything decides what to do
with it. The number is the identity — it is the one thing a phone call always
carries — so CallerIdentityService asks Customers over the bus:
Calls ──customers.findOrCreate{phone, source:"call"}──▶ Customers
The customer id is stored on the call, so a caller who has phoned five times is
one person rather than five strangers, and anything the call learns can reach
the record of whoever said it. A caller_identified event records whether they
were new, and how many orders they already have.
It is a soft dependency, deliberately. A tenant without the Customers
module — or with it disabled — still gets a working call; the caller is simply
anonymous, which is what they were before this existed. A failure is logged as
caller_not_identified against the call rather than swallowed, so a tenant who
expects their callers to be recognised can find out why they are not.
The assistant is told who it is speaking to as a separate system message, not folded into the persona: a name is a fact about this call, and editing a persona should not be able to delete it by accident.
What the caller hears first
The greeting comes from the most specific source that has one:
- the number the caller dialled, if it carries its own greeting,
- the tenant's
ai_greetingsetting (Calls → Behaviour), - the shipped default.
Placeholders are resolved last, once there is something to resolve them against:
| Placeholder | Becomes |
|---|---|
{first_name} |
the caller's first name |
{customer_name} |
the same, falling back to their full name |
{company} |
app.name |
An unknown caller takes the punctuation with them, so one greeting works both ways:
"Hello {first_name}, thanks for calling."
known → "Hello Priya, thanks for calling."
unknown → "Hello thanks for calling."
Substituting an empty string would give "Hello , thanks for calling.", and on
a phone every character is read aloud — the stray comma is audible.
The AI answering a call
Two ways, chosen by CALLS_AI_MODE:
| mode | how | trade-off |
|---|---|---|
realtime |
a media worker holds the audio socket | barge-in, sub-second turns. Needs CALLS_MEDIA_WS_URL |
turn_based |
the provider transcribes and speaks; only text crosses the wire | a round trip per turn, no interrupting — but no streaming worker and no speech provider to pay for |
auto (default) |
realtime when a media service is configured, else turn-based | callers get a working assistant either way |
Giving it a brain
Plivo supplies the ears and the mouth; the model is yours to configure, and nothing answers until one exists. There is no default provider and no key in the environment — an unconfigured platform escalates every call to a human, which is safe but is not an assistant.
Providers live in the AI module's installation record, not in calls, and
not in .env. Voice is a transport: it uses the same provider, agent and
knowledge base as chat, so configuring it once covers both.
Via the API (the UI writes the same thing):
# the model
curl -X POST "$API/ai/providers" -H "Authorization: Bearer $TOKEN" \
-d '{"id":"mistral","name":"Mistral AI","type":"openai_compatible",
"base_url":"https://api.mistral.ai/v1",
"models":["ministral-8b-latest","ministral-3b-latest","mistral-small-latest"],
"default_model":"ministral-8b-latest"}'
curl -X PUT "$API/ai/providers/mistral/credentials" -H "Authorization: Bearer $TOKEN" \
-d '{"api_key":"...","model":"ministral-8b-latest"}'
type is openai_compatible or anthropic. Anything speaking the OpenAI chat
API works by pointing base_url at it — Mistral, Groq, Together, vLLM, Ollama.
Choosing a model
Latency is a feature on a phone call in a way it is not in chat: the caller hears the silence. Cost matters too, because a call is many turns.
| model | ~cost / M tokens | notes |
|---|---|---|
ministral-3b-latest |
~$0.04 | fastest and cheapest; looser with instructions |
ministral-8b-latest |
~$0.10 | the default — ~0.9 s/turn, follows the voice prompt reliably |
mistral-small-latest |
~$0.20/$0.60 | better reasoning, noticeably more per call |
Measured on the real voice prompt, both 3b and 8b answered in ~0.9 s and
emitted TRANSFER_TO_AGENT correctly. 8b is the default because the voice
prompt asks for two things a small model drops first — no markdown, and an
exact handoff token — and both failures are audible to the caller.
Speech, for later
The turn-based path uses the provider's speech recognition and synthesis, so
STT/TTS providers are not needed for the phone to answer. They are configured
the same way (/ai/stt/providers, /ai/tts/providers) and matter for the media
plane and other features. Mistral's Voxtral serves both:
| kind | type | model |
|---|---|---|
| STT | voxtral |
voxtral-mini-latest |
| TTS | voxtral_tts |
voxtral-mini-tts-latest |
voxtral-mini-realtime-latest is a speech-to-speech model and is the obvious
candidate when the media worker lands.
Important —
module_installationsis not tenant-scoped. It has notenant_id, no global scope, and onlyQueueTenancyBootstrapperis enabled, so every tenant reads the same row. Configuring a provider configures it for the whole platform, and one tenant's API key bills every tenant's calls. Fine on a single-tenant deployment or a UAT box with a throwaway key; fix before charging anyone.
What turn-based actually does
<GetInput inputType="dtmf speech"> — the provider runs its own speech
recognition, posts the transcription to …/webhook/calls/plivo/ai-turn, we ask
the agent, and the reply comes back as the prompt of the next <GetInput>. The
loop needs no audio handling on our side at all.
It is the same agent, knowledge base and tools as chat, writes the same transcript, and escalates to a human through the same routing. When the media plane arrives only the transport changes.
What it is not: a conversation. A turn costs a round trip and the caller cannot interrupt. It feels like a good IVR.
Escalation
A bot that cannot hand over is worse than no bot, so there are three routes out:
- the caller presses 0 — checked before the model is consulted, because someone asking for a human should not wait for a round trip
- the caller says "agent", "human", "representative", "operator"…
- the assistant decides it cannot help and replies
TRANSFER_TO_AGENT
All three take the escalation path, not the ordinary one. This distinction is the whole feature working or not: the ordinary decision is what put the caller with the assistant in the first place, so re-running it hands them straight back to the bot they just asked to leave. The caller says "agent", hears the greeting again, says "agent" again — for as long as they stay on the line.
InboundRouter::escalate() therefore removes AI from every branch:
- AI rules are skipped rather than matched, so their match counters keep describing what actually answered the phone
- an AI setting on the number falls through
- the default ladder is queue, then voicemail
Voicemail is a real answer here. The caller asked for a person, and a message
reaches one; returning them to the assistant never does. If no active queue
exists, the escalation is logged as escalation_without_queue so the operator
can see that their configuration is incomplete.
Everything else is the ordinary path: the same queue, agent selection and overflow as any other call — and the transcript is already written, so the agent's screen opens with the conversation on it.
If the model itself is unreachable while the call is up, the caller is told and escalated rather than left on a dead line.
Important —
decide()is deliberately unchanged, so an AI rule still wins for an ordinary inbound call. If you add a routing action that answers with the assistant, add it to the skip list inescalate()too, or you have rebuilt the loop.
Tuning
| variable | default | what it changes |
|---|---|---|
CALLS_VOICE_SPEECH_END_TIMEOUT |
2 |
pause that counts as "finished". Plivo accepts 2–10s and rejects the whole <GetInput> outside it — see below |
CALLS_VOICE_TURN_TIMEOUT |
10 |
how long to wait for the caller to start |
CALLS_VOICE_MAX_REPLY_CHARS |
400 |
long replies are worse on the phone — the caller cannot skim |
CALLS_VOICE_HINTS |
— | domain words the recogniser would otherwise mishear |
The assistant is prompted to answer in one or two spoken sentences with no
markdown and no emoji, and replies are stripped anyway, because the prompt is
not obeyed reliably. Every character is read aloud, so **important** would be
spoken as "asterisk asterisk important" — and asked to reply with the single
word "ready", ministral-8b returned **Ready.** 🚀. Markdown, emoji, dingbats
and arrows are removed; ordinary punctuation and currency symbols are left
alone, since stripping those would mute the pauses that make a spoken sentence
legible.
A timeout outside the provider's range kills the call
Worth stating plainly, because it cost two live calls and looks like nothing in
the logs: these are not soft preferences. Plivo accepts speechEndTimeout and
digitEndTimeout in 2–10s and executionTimeout in 5–60s, and a value
outside that range does not get clamped or ignored — it invalidates the whole
<GetInput>.
When that happens Plivo discards the document and jumps straight to the
fallback answer URL, in the same second. The caller hears the greeting, then
the fallback apology, and hangs up. Meanwhile the answer webhook has already
returned 200 and logged plan_rendered, so nothing upstream looks wrong; the
only signal is an answer_fallback event whose payload is a fresh answer
callback rather than a GetInput result, and an action URL that is never
requested at all.
speechEndTimeout="1" — one below the floor — did exactly this. The adapter now
clamps every timeout into the provider's documented range, so no operator
setting can render a number unanswerable.
What happens on a bridged call
Worth reading once, because three separate bugs lived in these few seconds.
caller dials the number
│
├─ answer_url → we route, assign an agent, return <Dial>
│ (this happens in well under a second)
│
├─ the network delivers the call to the agent's handset
│ 15-25s for an Indian mobile. The caller hears ringback
│ throughout, and CALLS_AGENT_RING_SECONDS must be longer
│ than this or the leg is abandoned as the phone starts ringing
│
├─ agent answers → the two legs are bridged and talk
│
└─ either side hangs up
│
└─ the <Dial> action callback fires
answered → we return <Hangup>; the call ends cleanly
not answered → we return the voicemail plan
Two things make that last step work:
The action callback must be authoritative. The <Dial> verb is rendered
with redirect="true" so the XML we return in response to the callback is what
executes. With redirect="false" the callback is advisory — the provider
ignores the response and runs whatever verb comes next, which means a caller
still on the line after their agent hung up hears the next thing in the plan.
Static fallback verbs after <Dial> cannot work for this reason: they fire on
success too.
"Answered" has to be read from the right field. A dial-completion callback
is not a hangup callback. Plivo describes the dialled leg with DialBLeg*
fields and sends no Duration or HangupCause at all, so reading the hangup
fields yields nulls and an answered bridge looks like a failure. An adapter for
a new provider must map this explicitly — see
PlivoCallProvider::parseDialCallback.
Compliance and KYC
Numbers are bought on one platform account, which makes the platform the regulatory holder of record.
Plivo's India rules make this concrete: renting an India number requires a
compliance application in accepted status, and a purchase without one is a
400.
So provisioning is a workflow:
request a number
→ the country's requirements are read from the provider
→ the tenant uploads KYC (ID, address proof, business registration)
→ the bundle is submitted
→ pending … accepted
→ purchase, assign, point at our webhooks
The gate is checked before anyone is charged, and the error names the missing document rather than surfacing a carrier error nobody can act on.
NumberComplianceBundle is scoped per tenant per country, because the documents
outlive any single line — a second Indian number must not send the operator back
through the upload flow.
Applications are polled hourly; providers review over days and do not reliably announce when they finish.
Recording
Off by default. Recording consent is jurisdiction-specific and defaulting it on would be wrong in most of them. A tenant turns it on from Calls → Settings; the environment variable is the deployment's floor.
CALLS_RECORDING_ENABLED=false
CALLS_ARCHIVE_RECORDINGS=true # copy off the provider onto our storage
CALLS_RECORDINGS_DISK=s3
CALLS_RECORDING_NOTICE="This call is recorded for quality and training purposes."
How a call gets recorded
recording_enabled is stamped on the call when it is created, so everything
downstream reads one decision off one row.
An AI call answers over a <Stream>, and there is no recording attribute on
it — the plan simply cannot express recording. So the carrier's Record API
is used instead, from a queued job dispatched by the answer webhook. Queued,
because that webhook is holding a silent line and a round trip to the carrier
before the greeting is a delay the caller hears.
Recording the call rather than a leg is also the better shape: an AI call
that later hands over to a person is one conversation and should be one file.
Because of that, the dial verb no longer passes record when the call is
already being recorded — a queue's own record_calls flag still records the
agent leg on calls that are not.
The notice is prepended to the plan, not folded into the greeting: consent
announced after the conversation has started is not consent, and an agent or
forwarded call has no greeting to fold it into. consent_announced on the
recording is set from whether the call was being recorded, so a voicemail left
on a call that was not being recorded correctly reports that nobody was told.
Transcription
transcription_enabled gates whether utterances are stored. The assistant
still works with the running transcript for the duration of the call; nothing
is persisted afterwards.
Recordings start on the provider's storage and are copied asynchronously. Vendors expire them on their own schedule, and a compliance request two years from now should not depend on a retention policy we do not control.
The disk has to be durable
CALLS_RECORDINGS_DISK must name object storage. This is not a preference.
The archiver used to write to the local disk, and the pods that run it mount
no volumes — so storage/app was the container's own filesystem, and every
rollout destroyed the copies while the row went on reporting
status=archived with a storage_path that resolved to nothing. Losing the
file is bad; recording that we hold a file we do not hold is worse, because it
is what stops anyone noticing.
Archiving is now refused unless the disk can be shown to be durable, and the
recording stays available with the provider copy as the source of truth. A
local driver is refused — and so is a disk name with no entry in
filesystems.disks, because a driver we cannot resolve is not evidence of
anything, and reading it as "not local, therefore safe" would be the same false
confidence in a new place. A deployment whose
local disk really is a mounted volume opts in:
CALLS_RECORDINGS_ALLOW_LOCAL=true
If a recording that claims to be archived turns out to be missing when someone
plays it, the row is corrected back to available and the archive job is
dispatched again. Provided the carrier still holds the audio, the next listener
gets our copy.
Retention
CALLS_RECORDINGS_RETENTION_DAYS=365 # 0 keeps them indefinitely
The clock starts when we take our own copy, not when the call happened —
before that there is nothing of ours to expire. A daily sweep
(calls:purge-expired-recordings) deletes the audio and marks the recording
deleted.
The row outlives its audio deliberately. That a call was recorded, how long it ran, and when the recording was destroyed is exactly what a retention question asks about, and it is the part that costs nothing to keep.
Playing one back
GET /calls/{uuid}/recordings/{id}/audio
Served through the API rather than by linking the carrier's URL — that copy is
the one we do not control, and handing it to a browser is what archiving exists
to stop depending on. The endpoint returns our copy when we hold one and
proxies the carrier's when we do not, so nothing that plays audio has to know
which. Access is scoped through the call, because call_recordings carries no
tenant_id of its own.
Module bus API
| method | mode | what it does |
|---|---|---|
calls.originate |
async | place an outbound call, paced |
calls.transferToAgent |
sync | hand a live call to a human, with context |
calls.hangup |
sync | end a live call |
calls.getCall |
sync | a call with its legs, events and transcript |
Every method returns ['success' => bool, …] and never throws across the bus —
a caller on the other side cannot catch our exception types, so a thrown error
would become an opaque failure with no diagnosis.
Diagnosing a call
Every action on a call is written twice: to the application log, and to
call_events.
The log answers "what is the platform doing right now" during an incident. The event trail answers "why did this specific call do that" months later, from the tenant's own UI, without anyone opening a log file. Both matter and neither substitutes for the other.
Calls → History → a call → Timeline shows which routing rule fired, when the agent was assigned, what the provider reported, and anything that failed.
Auth tokens and signatures are redacted before either destination.
Deploying it
Everything below was learned by deploying this to a real cluster and pointing a real number at it. Each item is something that produced no visible error.
Run the tenant migrations
Module tables are created by php artisan migrate once the module is enabled,
but the platform's tenant-scoped tables are a separate path:
php artisan migrate --force
php artisan migrate --path=database/migrations/tenant --force
for m in modules/*/backend/database/migrations; do
php artisan migrate --path="$m" --realpath --force
done
Skip the second line and notifications and tenant_settings are missing, so
/navigation, /settings, /products and /customers all return 500 — while
/calls works fine, which makes it look like a Calls problem when it is not.
Check tenantTypes if a module will not enable
ModuleManager::enable() matches frontend.navigation.tenantTypes against
exactly three values: super_admin, reseller, standard. Anything else
matches nothing and the module is silently refused for every tenant.
A module meant for everyone should declare no tenantTypes at all — that
is what Orders, Products and Communications do. Only reseller-scoped modules
restrict.
Roll every service to the same build
The API, frontend, horizon, scheduler and reverb are separate deployments. Roll only some and the frontend can be serving pages that predate the API it talks to — new navigation pointing at routes the bundle does not contain.
When setting images by hand, name the container. A wildcard also rewrites the
wait-for-db init container, which must stay on postgres:16-alpine:
kubectl -n commstate set image deployment/api api=$REGISTRY/api:$TAG # right
kubectl -n commstate set image deployment/api '*'=$REGISTRY/api:$TAG # wrong
Signature verification behind a proxy
Providers sign the absolute URL they were configured to call. Behind an ingress
that terminates TLS, PHP sees the forwarded request and reports http://
unless the application trusts X-Forwarded-Proto — which this one does not
configure.
The Calls module normalises the scheme from CALLS_WEBHOOK_BASE_URL before
verifying, so it is correct regardless. Anything else in the platform that
verifies a signature against $request->fullUrl() has the same exposure and is
worth auditing.
When something is wrong
Every one of these was seen for real. Start with the call's own event timeline (Calls → History → the call → Timeline); it records every decision.
| symptom | cause to check first |
|---|---|
| every callback rejected, 403 in the logs | signature verified against the wrong scheme — see Deploying it |
| inbound never arrives, nothing logged at all | the number's webhooks — Numbers → refresh. A provider application name with a space is rejected and leaves the number pointing at its old app |
| caller hears a long ring before the agent's phone rings | PSTN delivery latency, not the platform. It sits on top of CALLS_AGENT_RING_SECONDS, so that timeout must exceed it — 15-25s is normal for an Indian mobile |
| agent answers to a dead line | the ring timeout expired while the network was still delivering the call. Raise CALLS_AGENT_RING_SECONDS |
| caller hears an apology after a good conversation | verbs after <Dial> run when the agent hangs up first. The dial's action callback must be authoritative (redirect="true"), not a static fallback |
| a call routed somewhere unexpected | Calls → Routing rules. A catch-all rule outranks the number's own configuration, by design |
| click-to-call answers the customer with an apology | the call is being routed by the inbound router. Outbound must go through OutboundRouter, which reads the mode it was placed with |
| outbound fails "destination region are barred" | the number was not E.164. Check the Normalised the dialled number log line for what was actually dialled |
| click-to-call has nobody to bridge to | the user who pressed dial is not registered as an agent, or has no endpoint address |
| an agent's phone never rings | their endpoint address — an agent without one is skipped by routing entirely |
| "no agent available" when one is online | they are still reserved by an earlier call that never received a hangup, or still in wrap-up |
| calls answer to silence | CALLS_MEDIA_WS_URL; the Settings screen says when it is unset |
| callers wait forever | is anyone online, and does the queue have an overflow action |
| a campaign barely dials | the calling window, and the provider's per-second limit |
| a number cannot be bought | Settings → regulatory applications |
calls stuck in_progress |
a media worker died; the reaper clears them within a minute |
| outbound calls never dial | is a queue worker running for calls |
| history never updates after a call | same — webhooks are accepted but normalised on the queue |
| module missing from the tenant entirely | enabled on the platform but not for the tenant, or tenantTypes names an unrecognised value |
What is not built yet
Written down because the alternative is finding out from a customer. Several of these have a complete backend and no way to reach it.
| gap | state |
|---|---|
| Outbound campaigns | 9 working endpoints, no page and no nav entry |
| Business hours | CallQueue::isOpen, closed_action and business_hours all work; the queue form has no fields for them, so a queue created in the UI is always open |
| Routing conditions | time_of_day, caller_prefix, is_known_customer and the rest are evaluated; the rule editor cannot set them, so UI-made rules ship with empty conditions |
| IVR | CallPlanBuilder::ivr exists and works; ivr is not in the routing action dropdown |
| Voicemail inbox | messages are recorded and reachable only by opening that one call; no inbox, no unheard state, no notification, no transcription |
| Analytics | one /calls/stats endpoint returning 12 fields, of which the dashboard renders 4; no time series, no per-agent or per-queue breakdown, no export |
| AI order awareness | the assistant is told the caller's name and the prior transcript, and nothing else — no order history, even though the profile is already fetched |
| Customer timeline | a finished call, its disposition and its summary never reach the customer record; customers.recordInteraction exists and is never called from here |
| AI cost | carrier cost is tracked per leg; STT, TTS and LLM usage is not recorded anywhere, so a call's true cost is unknowable |
| Durable recordings | archiving refuses a non-durable disk, so until object storage is configured the carrier's copy is the only one |
Configuration reference
| variable | default | notes |
|---|---|---|
CALLS_DEFAULT_PROVIDER |
(auto) | resolved from the account, or the only adapter |
CALLS_INBOUND_DEFAULT_MODE |
ai |
the floor when nothing else matches |
CALLS_AI_GREETING |
(a sentence) | spoken by the media worker, so it can be interrupted |
CALLS_AGENT_RING_SECONDS |
45 |
must exceed PSTN delivery latency, not just the agent's reaction time |
CALLS_VOICEMAIL_AFTER_SECONDS |
45 |
bounds a silent wait |
CALLS_OUTBOUND_RATE_PER_SECOND |
0 |
0 = the adapter's declared limit |
CALLS_MAX_CONCURRENT |
20 |
per tenant |
CALLS_WEBHOOK_BASE_URL |
APP_URL |
must be publicly reachable |
CALLS_VERIFY_SIGNATURES |
true |
only ever false locally |
CALLS_MEDIA_WS_URL |
(empty) | required for AI calls |
CALLS_MEDIA_HEARTBEAT_TOLERANCE |
60 |
past this a worker is presumed dead |
CALLS_RECORDING_ENABLED |
false |
consent is jurisdictional; a tenant overrides it in Settings |
CALLS_RECORDING_NOTICE |
(a sentence) | prepended to the plan, so it is the first thing a caller hears |
CALLS_TRANSCRIPTION_ENABLED |
true |
gates whether utterances are stored, not whether the assistant works |
CALLS_ARCHIVE_RECORDINGS |
true |
copy off the carrier onto our own storage |
CALLS_RECORDINGS_DISK |
FILESYSTEM_DISK |
must be object storage; a local driver is refused |
CALLS_RECORDINGS_ALLOW_LOCAL |
false |
only for a deployment whose local disk really is a mounted volume |
CALLS_RECORDINGS_RETENTION_DAYS |
365 |
0 keeps indefinitely; the clock starts when we hold our own copy |
CALLS_AGENT_REGISTRATION_TOLERANCE |
90 |
how stale a browser softphone's heartbeat may be before it is skipped |
CALLS_VOICE_TURN_TIMEOUT |
10 |
wait for the caller to start speaking |
CALLS_VOICE_SPEECH_END_TIMEOUT |
2 |
Plivo's floor; below it the whole <GetInput> is rejected |
CALLS_VOICE_MAX_SILENT_TURNS |
2 |
silences before handing to a person |
CALLS_STUCK_TOLERANCE |
300 |
how long a transient state may last before the platform finishes the call |
PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN |
— | platform account |
PLIVO_APPLICATION_ID |
(auto) | created and reused if empty |
PLIVO_APPLICATION_NAME |
Commstate-Calls |
letters, numbers, -, _ only — a space is rejected and leaves inbound unrouted |
PLIVO_CURRENCY |
USD |
Plivo reports cost without one; the account's billing currency applies |
CALLS_MSG_AGENT_UNAVAILABLE |
(a sentence) | played only when the agent genuinely did not answer |
CALLS_QUEUE_BATCH_SIZE |
50 |
callers dequeued per sweep |
CALLS_DIALER_RETRY_SECONDS |
3 |
jittered backoff when the pacer defers a call |
PLIVO_CALLS_PER_SECOND |
2 |
Plivo's documented default |
Adding a provider
A second vendor is a new module, not an edit to the core.
- Implement
CallProviderContract, andNumberProviderContractif it sells numbers. - Register both in the module's service provider against
CallProviderRegistry. - Declare what it can do in
capabilities()— including its real outbound rate limit, which the dialer paces against.
Nothing in Calls changes. Two rules an adapter must hold to: never leak vendor
vocabulary upward (parseWebhook maps into CallStatus), and never make the
core aware of transport (renderInstructions emits whatever dialect the vendor
wants from a neutral CallPlan).
What to get right, from experience
Each of these was a real bug in the Plivo adapter, and each produced no error.
- Signature verification must use the URL the provider signed, not the one the framework reports. Behind a TLS-terminating proxy those differ by a scheme, and the HMAC differs completely — every genuine callback is rejected exactly like a forged one.
- A dial-completion callback is not a hangup callback. Read the fields the vendor actually sends for it; do not fall through to the hangup fields and treat their absence as "unanswered".
- Distinguish busy, no-answer and failed from completed. A campaign has to tell "try later" from "this number is dead", and all of them arrive through the same callback.
- Declare the real outbound rate limit in
capabilities(). The dialer paces against it; an optimistic number produces rejections, not throughput. - Say whether the action callback is authoritative. If the provider ignores the response to an action URL unless a flag is set, set it — otherwise the call continues to the next verb regardless of what the platform decided.
- Validate what the vendor accepts in identifiers. Plivo restricts
application names to
[A-Za-z0-9_-]and rejects anything else, which leaves a number assigned but unrouted. - Inbound and outbound are different questions. One asks who should take the call; the other already knows. Do not share a router between them.
- Never assume a query succeeded because the HTTP status was 200. Some
endpoints return an error object in a 200 body; reading
objectsblindly turns "your account cannot buy these" into "no numbers available".