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. Someone to answer
At minimum: a queue, and an agent with an endpoint.
An agent without an endpoint address is invisible to routing. Their calls ring nowhere and nothing reports it, which is why both the web and mobile screens call it out.
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
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 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.
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.
CALLS_RECORDING_ENABLED=false
CALLS_ARCHIVE_RECORDINGS=true # copy off the provider onto our storage
CALLS_RECORDINGS_DISK=s3
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.
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.
When something is wrong
| symptom | look at |
|---|---|
| inbound calls never arrive | the number's webhooks — Numbers → refresh |
| calls answer to silence | CALLS_MEDIA_WS_URL; Settings says if it is unset |
| an agent's phone never rings | their endpoint address — an agent without one is skipped |
| 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 |
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 |
25 |
|
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 |
PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN |
— | platform account |
PLIVO_APPLICATION_ID |
(auto) | created and reused if empty |
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).