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_active in the central catalogue vetoes the filesystem. A module can be enabled in modules_statuses.json and still not load if its catalogue row is inactive — the symptom is migrate reporting "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:

  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

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_mode never 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:

  1. the number the caller dialled, if it carries its own greeting,
  2. the tenant's ai_greeting setting (Calls → Behaviour),
  3. 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.

Importantmodule_installations is not tenant-scoped. It has no tenant_id, no global scope, and only QueueTenancyBootstrapper is 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.

Importantdecide() 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 in escalate() 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.

  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).

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 objects blindly turns "your account cannot buy these" into "no numbers available".