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:

  1. a routing rule whose conditions hold
  2. the number's own configured mode
  3. the module default (CALLS_INBOUND_DEFAULT_MODE, ai out 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 forever
  • overflow_actionvoicemail, ai, forward or hangup
  • business_hours + closed_action — evaluated in the queue's own timezone
  • wrap_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.

  1. Implement CallProviderContract, and NumberProviderContract if it sells numbers.
  2. Register both in the module's service provider against CallProviderRegistry.
  3. 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).