Calls — Architecture
Voice is chat's harder sibling. The conversation model is nearly the same — AI answers, a human takes over, the two hand off mid-conversation — but the transport is real-time, lossy, and unforgiving: a 200 ms stall that nobody notices in chat is an audible gap in a call.
That single difference drives the whole design.
The one decision everything else follows from
The control plane and the media plane are separate processes, and audio never touches Laravel.
┌──────────────────────────────┐
PSTN ── Plivo ────────┤ CONTROL PLANE (Laravel) │
webhooks │ who is calling, why, state, │
REST │ routing, recording metadata,│
│ billing, agent assignment │
└──────────────────────────────┘
│ enqueue / notify
▼
┌──────────────────────────────┐
PSTN ── Plivo ════════╡ MEDIA PLANE (Python agent) │
WebSocket audio │ 20 ms frames, STT, LLM turn,│
bidirectional │ TTS, barge-in │
└──────────────────────────────┘
Plivo sends 20 ms audio frames over a WebSocket. A voice turn has to
finish inside roughly 500 ms end to end or the caller hears a hole in the
conversation. PHP-FPM/Octane is a request-response runtime with no business
holding a socket open for the length of a call and doing DSP-rate work on it;
Python already hosts the STT, TTS and LLM providers in services/ai-agent.
So Laravel answers webhooks and owns state. Python owns the socket and the turn loop. They meet at the database and a Redis channel, never in the audio path.
This is also what makes the platform scale: the control plane scales like any web tier, and the media plane scales on its own axis — concurrent calls, not requests per second. They fail independently too. A media worker crash drops the calls it was carrying; it does not take call history, routing or billing with it.
Modules
Two, following the platform's existing split between capability and vendor.
| module | type | what it owns |
|---|---|---|
Calls |
channel | the call model, state machine, routing, agents, queues, recordings, transcripts, number inventory, compliance |
CallsPlivo |
channel | one vendor: XML, webhook signatures, media protocol, number purchasing |
Calls never imports anything from CallsPlivo. A second vendor is a new
module implementing the same contracts, and nothing in the core changes —
the same shape as StoreShopify / StoreWooCommerce behind
StoreProviderContract.
Both are enable/disable like any module. Disabling CallsPlivo leaves the
call history intact and stops new calls; disabling Calls removes voice
entirely without touching chat.
Contracts
CallProviderContract — everything a vendor must answer for a live call:
capabilities(): ProviderCapabilities; // declared, never assumed
originate(OutboundCallRequest $request): CallHandle;
hangup(string $providerCallId): bool;
transfer(string $providerCallId, TransferTarget $target, CallPlan $plan): bool;
sendDtmf(string $providerCallId, string $digits): bool;
playAudio(string $providerCallId, string $url, array $options = []): bool;
startRecording(string $providerCallId, array $options = []): ?string;
stopRecording(string $providerCallId): bool;
fetchCall(string $providerCallId): ?array; // reconciliation
verifyWebhook(array $headers, string $url, array $payload): bool;
parseWebhook(string $event, array $payload): CallEventDTO;
extractCallId(array $payload): ?string;
renderInstructions(CallPlan $plan): string; // provider XML/JSON
capabilities() is the one that earns its place. The dialer reads
outboundCallsPerSecond from it rather than hard-coding a limit, and the UI
asks before offering a feature — so enabling a vendor that cannot stream media
does not produce a dialer full of AI calls that answer to silence.
NumberProviderContract — number lifecycle, deliberately separate because a
vendor may do voice without selling numbers, or the reverse:
searchAvailable(NumberSearch $criteria): Collection;
purchase(string $number, array $options = []): PhoneNumberDTO;
release(string $number): bool;
listOwned(): Collection;
complianceRequirements(string $country, string $type): array;
submitCompliance(ComplianceBundle $bundle): ComplianceResult;
Discovery mirrors StoreProviderRegistry: adapters self-register in their
service provider, and the core resolves by identifier.
The call model
A Call is the business object — one conversation with one customer. A CallLeg is one provider-side connection. Plivo bills per leg and a transfer creates a second leg, so collapsing the two would make a transferred call impossible to represent and the cost impossible to attribute.
Call (business)
├─ CallLeg A inbound customer → platform
├─ CallLeg B outbound platform → agent (created on transfer)
├─ CallEvent[] append-only audit of every state change
├─ CallRecording[] provider URL + our copy
└─ CallTranscript utterances with speaker, offset, confidence
States
┌──────────┐
│ queued │ outbound only, waiting on the dialer's rate limit
└────┬─────┘
▼
┌──────────┐ ┌──────────┐
│ ringing │────▶│ no_answer│
└────┬─────┘ │ busy │
▼ │ failed │
┌───────────────┐ └──────────┘
│ in_progress │
└───┬───────┬───┘
│ │
┌────▼───┐ ┌─▼──────────┐
│on_hold │ │transferring│
└────┬───┘ └─┬──────────┘
└───┬───┘
▼
┌──────────┐
│completed │
└──────────┘
Transitions are guarded in one place. Providers report state in their own vocabulary and the adapter maps it in; the core never sees a vendor string.
Handling modes
How a call is being handled is orthogonal to its state, and changes mid-call:
ai · agent · ivr · voicemail · forward · queued_for_agent
The transfer that matters is ai → agent, and it is the same problem chat
solved: the AI decides it cannot help, a human is found, and context comes
with it. The agent's screen opens with the live transcript already there.
ChatBusHandler::transferToHuman is the precedent; calls expose
calls.transferToAgent on the same module bus.
Inbound
caller dials a number
│
▼
Plivo → answer_url ──▶ POST /api/webhooks/{tenant}/calls/{provider}/answer
│ verify X-Plivo-Signature-V3
│ resolve number → tenant → routing rule
│ create Call + CallLeg A
▼
CallPlan → provider XML
│
┌───────────────┼────────────────┐
▼ ▼ ▼
<Stream> <Dial> <Speak>
AI answers to an agent IVR / after-hours
Tenant resolution is by called number. The number is the only thing the
caller and the carrier agree on, and a number belongs to exactly one tenant —
which is also why platform_phone_numbers has to be central. The webhook
arrives before tenancy exists, and resolving it is what decides which tenant to
open.
URLs we mint ourselves also carry a ?t= hint, taken first because it is
cheaper. It is an optimisation, not a trust boundary: the signature is verified
either way and the number lookup still backs it up, so a forged or stale hint
buys nothing.
A callback that resolves to no tenant is logged loudly and answered 404. It is the one failure with no other trace — there is no call record to inspect, because attributing the callback is what would have created one.
Webhooks follow the ingest pattern already proven by orders: verify, persist
raw, return fast, normalise asynchronously. Plivo retries any non-200 and
deduplicates on CallUUID, so handlers are idempotent on that key.
Outbound
Three origins, one path:
- Dialer — a human clicks call in web or the mobile app
- AI scheduled — a workflow or campaign places the call
- API — another module via
calls.originateon the bus
All enqueue a Call in queued and let DialerService release it.
That queue is not decoration. Plivo processes outbound API requests at two calls per second by default. Firing a thousand-call campaign at the API gets most of it rejected; the dialer paces against the provider's documented rate, per tenant, with the limit declared by the adapter rather than assumed by the core.
AI-handled calls
Plivo ══ws══▶ media worker ──▶ STT (streaming) ──▶ agent turn ──▶ TTS ──▶ ws ──▶ caller
▲ │
│ KB + tools
└──── barge-in: clearAudio ◀────────┘
The agent turn is the existing services/ai-agent graph — the same KB
retrieval and tool calling the chat agent uses. A voice call is a different
transport for the same agent, not a second agent.
Three things voice adds that chat does not have:
Barge-in. A caller who interrupts must be heard immediately. Speech
detected while TTS is playing sends clearAudio to drop everything buffered,
then the turn restarts. Without it the bot talks over the caller for the
length of whatever it had queued, which is the single most common way a voice
bot feels broken.
Turn-taking under latency. Silence is a signal. The worker decides end-of-utterance from STT endpointing plus a silence timer, and the budget for STT + LLM + TTS is a few hundred milliseconds. Anything slower needs a filler phrase, which is a product decision the plan carries rather than something the worker invents.
Audio format. Plivo speaks audio/x-l16 at 8/16/24 kHz or audio/x-mulaw
at 8 kHz, in 20 ms frames. Resampling belongs at the edge, once, not scattered
through providers.
Agents
One person, one platform user. call_agents.user_id is the join back to the
same human who takes chats, so an agent is never two identities.
Voice capacity is tracked separately from chat capacity, though, and that is a
deliberate change from the obvious design. Voice is exclusive — an agent
takes one call at a time — while chats are concurrent. A single shared counter
would either let someone deep in three chats get rung mid-sentence, or block a
call because of three idle chat windows. So max_concurrent_calls defaults to
1 and values above it exist for supervisors monitoring, not for holding two
conversations.
call_agents also carries the thing chat has no equivalent of: where to
ring. WebRTC, SIP or a PSTN number. An agent without an endpoint is skipped
by routing entirely — which is why isAvailable() tests for it, and why the
UI says so on both web and mobile. It is the one failure with no other
symptom: the phone simply never rings.
Selection and reservation happen inside one locked transaction. Two calls hitting the same queue a millisecond apart both see the same idle agent, and without the lock both ring them; the caller who loses hears ringing that turns into nothing, which is worse than having waited.
What voice adds beyond that is that a call cannot queue politely. A chat
can wait; a caller hears silence. So a queue has an audible identity — hold
music, position announcements, timeout to voicemail — and those live on
CallQueue alongside the routing strategy.
Numbers, and the KYC problem
Numbers are bought from one platform account and assigned to tenants. That is the commercially useful shape — a tenant should not need its own carrier relationship — but it means the platform is 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. Numbers can also sit pending while documents are approved.
So provisioning is a workflow, not a call:
tenant requests a number
→ platform checks the country's requirements
→ tenant uploads KYC (address proof, ID, business registration)
→ bundle submitted to the provider
→ pending … accepted
→ purchase, assign to tenant, attach to an application
NumberComplianceBundle tracks that per tenant per country, because the
documents outlive any one number and a second number in the same country must
not ask for them again.
Scale
| concern | approach |
|---|---|
| concurrent calls | media workers scale horizontally; a call is pinned to one worker for its life |
| control-plane load | webhooks are verify-persist-202, normalisation is queued |
| provider rate limits | dialer paces per tenant against the adapter's declared limit |
| live state | Redis for what is in flight, Postgres as the record |
| recordings | provider-hosted first, copied to our storage asynchronously |
| transcripts | written during the call, not reconstructed after |
| noisy tenant | per-tenant concurrency caps so one campaign cannot exhaust the pool |
The media plane is the axis that matters. A call occupies a worker for its
duration, so capacity is concurrent calls, not throughput — sizing is
peak concurrent calls ÷ calls per worker, and workers are stateless beyond
the calls they hold.
Isolation
Tenancy here is single-database in practice. config/tenancy.php enables
only the queue bootstrapper; every table lives on the central connection and
the per-tenant databases are empty shells.
That makes isolation a column plus a global scope rather than a separate
database, and for voice it is not a detail — an unscoped query shows one tenant
another tenant's recordings and transcripts of real conversations. The existing
pattern in the codebase is to stamp tenant_id on create and filter by hand at
each call site; filtering by hand is one forgotten where away from a leak, so
BelongsToTenant does both halves and acrossTenants() is the explicit
opt-out for central tooling.
Outside tenant context the scope does not apply, deliberately: the reaper sweeping dead media workers has to see calls across every tenant, because a crashed worker's calls belong to whoever happened to be on it.
What runs on a schedule, and why
Three recurring jobs, each covering a gap that nothing else notices.
Queue sweeper, every ten seconds. "An agent became free" is not always an event we see — wrap-up ending, coming back from away, or being added to a queue all make someone reachable silently. The same job times callers out to the overflow action, which matters as much: a caller left on hold because nobody dequeued them is the worst outcome this module can produce.
Media session reaper, every minute. A crashed media worker generates no
webhook; the provider already hung up. Without this the call sits in_progress
forever, holding an agent's capacity and a concurrency slot, and over a week
that quietly eats a tenant's whole ceiling.
Compliance poller, hourly. Carriers review KYC over days and do not reliably say when they finish. Without polling, a tenant approved on Tuesday still cannot buy a number on Friday and nothing in the UI explains why.
Failure modes this design takes seriously
Voice fails quietly. Almost none of these produce an error anyone would see, so each has a specific answer rather than a general one.
| what goes wrong | what happens instead of silence |
|---|---|
| media plane unreachable | the caller hears a spoken apology, not dead air |
| a transfer fails | the agent is released and the caller returns to the AI |
| answer_url unreachable | the provider's fallback URL speaks a message |
| a callback cannot be attributed | logged loudly; answered 200 so the vendor stops retrying something that will never correlate |
| a routing rule points at a deleted queue | logged, and evaluation falls through to the next rule |
| an unrecognised routing condition | the rule fails closed rather than matching everything |
| a number's webhooks go stale | re-pushable from the UI; the config is stored so drift is detectable |
| a worker dies mid-call | heartbeat lapses, the reaper releases the call and the agent |
| an unmapped provider status | recorded as an event, moves the call nowhere |
What this deliberately does not do
- No custom media server. Plivo terminates PSTN and gives us a WebSocket. Running our own SIP stack is a different company.
- No provider abstraction over XML. Adapters emit their own dialect from a
neutral
CallPlan. Inventing a universal call-control language would fit Plivo and break on the next vendor. - No separate agent identity for voice. One agent, one presence, one capacity.