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.originate on 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.