← Back to blog
BLOG · SEP 5, 2026 · 39 MIN READ

Hospital Helpline Voice Agent — Implementation Plan

IMPLEMENTATION PLAN · v5 · 6 SEP 2026

Two human operators on a Com1PBX handle two calls at a time. This plan puts an Asterisk-backed voice agent in front of extensions 122 and 123 — booking appointments in the caller's language, transferring everything else, and never leaving a patient without a human.

  • 2 → 0+
    concurrent calls
  • 5–0 wks
    to cut-over
  • ~₹0
    per call
  • 0
    open questions

3 review rounds on this rewrite · every Asterisk and Sarvam API verified against source


Summary

Summary The plan in one picture — before and after

Today

Today: two helpline numbers reach Com1PBX, extensions 122 and 123, and two operators who can only take two calls at a time — a third caller waits or hangs up. Two public helpline numbers Com1PBX unchanged ext 122 · ext 123 Two operators 2 calls at a time Caller #3 waits… or hangs up

Bookings are written by hand; anything else is transferred by the operator.

With the agent

With the agent: the same two numbers and the unchanged Com1PBX now route through Asterisk to a voice agent that books, cancels and reschedules, hands anything else to the right department extension, and falls back to the operators on ext 122 or 123 for emergencies, requests for an operator, or any failure. Two public helpline numbers Com1PBX unchanged one route change Asterisk health check first Voice agent Sarvam · Tamil · English · Hindi 10+ calls at once Books · cancels · reschedules SMS confirmation Anything else → department extension Emergency · "operator" · any failure → ext 122/123 operators stay — as the fallback

The PBX, every extension, and both operators stay exactly where they are.

What changes

One route on Com1PBX, and one box on the hospital LAN.

What stays

The PBX, the extensions, the departments, and the operators — now the fallback tier.

When

Five to seven weeks in five phases; a pilot on one number before both switch.

1. What we are building

A hospital runs two public helpline numbers. Both land on a self-hosted Com1PBX and ring extensions 122 and 123, where two human operators answer. Two operators = two concurrent calls; everyone else waits or drops.

Most calls are appointment bookings. The rest need a hospital department.

We put a Python voice agent behind Asterisk in front of 122/123 that:

The operators are not removed. They become the fallback tier (§10): every error path, every "I want a person", every emergency lands on 122/123. Their headcount, hours, and pay are unchanged through Phase 5; any staffing decision is the hospital's, and only after three months of live data.

What we need from you before Phase 1 starts

2. The one decision that shapes everything

Asterisk is the media layer. The agent never speaks SIP, and there is no second telephony system.

The earlier version of this plan put LiveKit between the PBX and the agent. That meant SIP → WebRTC → SIP: a media server, an SFU, a Redis, Opus transcoding in both directions, and a watchdog process whose only job was to paper over a race in that design. None of it served a caller on a telephone.

What the agent actually needs is: raw audio in, raw audio out, and a way to transfer the call. Asterisk provides exactly that:

Com1PBX  ──SIP/RTP──▶  Asterisk  ──AudioSocket (TCP, raw PCM)──▶  Python agent
   ▲                                                                   │
   └────────── transfer: bridge to ext 130 ◀───── ARI control ─────────┘

Three properties fall out of this, and each one removes a problem the LiveKit design had to work around:

  1. The formats match end to end. AudioSocket carries signed 16-bit mono PCM at 8 kHz in 320-byte / 20 ms frames. Sarvam's realtime STT accepts linear16 at sample_rate=8000, and its streaming TTS emits the same. There is no Opus, no WebRTC, no resampling — telephony audio goes into the model as-is.
  2. We control when the call is answered. A channel handed to an ARI application arrives unanswered. The agent answers only once it is genuinely ready to speak. LiveKit answered on dispatch, before the agent joined, which is precisely why v4 needed a watchdog sidecar to rescue orphaned callers. That failure mode cannot occur here.
  3. Transfers are native. We are inside a PBX. Connecting the caller to extension 130 is a bridge, not a SIP REFER negotiated with a proprietary PBX of unknown behaviour.

What we give up, plainly. LiveKit's agents framework supplied a working conversation loop for free: session and state management, interruption handling, and a plugin layer that wired the speech services together. Turn detection is not among the losses — Sarvam's server-side VAD replaces it outright, and better, since LiveKit's turn-detector did not support Tamil at all (§8). What we now write ourselves is the orchestration loop — realistically 200–300 lines, plus the barge-in handling in §8, which is the fiddliest part of this build because Sarvam's streaming TTS has no server-side cancel. We also give up a ready-made multi-node scaling story; at this hospital's volume that is theoretical, but it would matter at ten times the size. Set against that: fewer moving parts to operate, no watchdog, native transfers, and a stack an Indian hospital can actually hire for. The trade is deliberate, not free.

It also merges what used to be two plans. v4's Plan B was "put an Asterisk box on the LAN if Com1PBX cannot do an IP-auth trunk." Asterisk is now the primary, and it can reach Com1PBX by registration or by static trunk — so the single largest unknown in the old plan stops being a blocker.

In plain terms. PJSIP is Asterisk's SIP stack — the part that talks to Com1PBX and to extensions 122/123/130. ARI (Asterisk REST Interface) is Asterisk's control API: a WebSocket plus REST that our agent uses to answer, bridge and transfer calls. Stasis is the mechanism that hands a ringing call over to that external control instead of running the normal dialplan. AudioSocket is the plain TCP stream of raw audio. None of this is caller-visible; it is how our software and the PBX talk to each other.

Target Asterisk 22 LTS. 20 LTS exits full support on 2026-10-19 — roughly six weeks out — and this project's own timeline can cross that date, so do not pin 20. 18 introduced AudioSocket but is end-of-life; ARI's AudioSocket external media requires 20.4.0 or later, and DTMF-over-AudioSocket arrived in later 20.x/22.x point releases (evidence points at roughly 20.14.0 / 22.4.0, not 20.0) — VERIFY the exact minimum point release in Phase 1 before pinning a build.

3. Cost and commercials

Running cost (Sarvam usage). Rates from docs.sarvam.ai, 2026-09-06 — VERIFY against the account at signup.

Running cost — Sarvam usage

What does each call cost?

150
3.0
₹3.58
per call
₹16,100
per month (30 days)

Cost per call, by component

STT ₹0.75 TTS ₹2.70 LLM ₹0.13

Rates used

Saaras STT₹30 / hour of audio
Bulbul v3 TTS₹30 / 10,000 characters
sarvam-105b LLM₹29.28 / ₹73.20 per 1M tokens (in / out)

Sarvam usage only — excludes SMS (~₹0.20/message), the on-prem host, and any Com1PBX per-channel licence. Rates VERIFY against the account at signup; one Sarvam page quotes TTS at ₹15.

Per 3-minute call, on three stated assumptions — STT is fed audio only while the caller is speaking (~50% of call time; the agent's own speech is never sent to STT); TTS synthesises ~900 characters of agent speech; the LLM consumes ~1,000 input and ~170 output tokens per minute of call (3,000 / 510 over three minutes):

Working Cost
STT 1.5 min ÷ 60 × ₹30 ₹0.75
TTS 900 ÷ 10,000 × ₹30 ₹2.70
LLM (3,000 × ₹29.28 + 510 × ₹73.20) ÷ 1M ₹0.13
₹3.58 per call

Monthly = calls/day × ₹3.58 × 30. All three assumptions are re-baselined against measured traffic in Phase 4. Q8 (volume) is open, so this cannot be a monthly number yet — finance sign-off is blocked on Q8, not on this estimate.

The tier decision is a capacity decision, not just a price one. Sarvam's published per-tier limits:

Limit Starter Pro Business
Concurrent streaming-STT connections 20 100 100
Concurrent streaming-TTS connections 60 (30 on bulbul:v3) 200 1000
sarvam-105b chat requests / minute 40 60 120

At 10 concurrent calls with a conversational turn every ~10 seconds, the agent generates roughly 50–70 chat requests per minute — past Starter's 40/min and brushing Pro's 60/min. The STT and TTS connection limits are comfortable at this size; the chat rate limit is the binding constraint, and it is the one to negotiate before Phase 4. Recommendation: budget for Business, not Pro. Pro's 60 req/min ceiling sits inside our own 50–70/min projection at 10 concurrent calls, so Pro can still throttle on a busy morning. Business (120/min) clears it with headroom. The price delta between Pro and Business is not published and must be obtained in Phase 0 (Q15) — this recommendation is a capacity judgement, and finance cannot act on it until the number exists. Confirm against real volume (Q8) too. Beyond Business, limits are by arrangement with Sarvam.

One-time and fixed.

Item Estimate
On-prem host (Asterisk + agent + Postgres), 4 vCPU / 16 GB, no GPU ~₹70k–1.2L one-time, or a LAN VM at ~₹6–10k/month
Com1PBX SIP-trunk / per-channel licence, if any VERIFY with the PBX vendor
SMS confirmations ~₹0.20/message on the existing gateway
Build fee, Phases 0–5 [vendor to fill in before circulation]
Support after the 4-week warranty window (§16) [vendor to fill in: monthly rate + response SLA]

Price-change and lock-in exposure. Sarvam is the only metered dependency, and the agent talks to it over three ordinary WebSocket/HTTP APIs — swapping any one of STT, LLM or TTS is a module change, not a rebuild. The telephony side is plain Asterisk and standard SIP: no proprietary platform, no per-minute telephony vendor, and reverting to the humans is one route change on Com1PBX (§15).

4. Target architecture

System architecture

Voice agent in front of Com1PBX

Call path from the phone network through Com1PBX into Asterisk and the Python agent, with Sarvam as the only internet egress. PSTN / PRI / GSM unchanged Com1PBX ext 101…199 (unchanged) ext 122/123 — human fallback Only change: route the two helpline DIDs to the Asterisk trunk SIP + RTP · alaw · LAN bridge to ext 130 (transfer) health check fails → operators ON-PREM — HOSPITAL LAN Asterisk 20/22 LTS dialplan · PJSIP · ARI ARI (control) answer · bridge · transfer AudioSocket (TCP) slin 8 kHz, 320-byte frames Python agent ARI client · AudioSocket server · /health HTTPS Booking service (FastAPI) + Postgres the ONLY internet egress api.sarvam.ai realtime STT · chat · streaming TTS

Com1PBX

The one edit is routing the two helpline DIDs to the Asterisk trunk; every other extension is untouched.

unchanged new (on-prem) external

How one call is wired. The dialplan hands the channel to the Stasis application helpline. The agent receives StasisStart, creates a mixing bridge, and asks Asterisk for an external media channel with encapsulation=audiosocket, transport=tcp, format=slin, and a UUID it generated in the data parameter. Asterisk then connects outward to the agent's TCP server and sends that UUID as its first frame — which is how the audio socket is matched to the call. The agent answers, then both channels go into the bridge, and audio flows — in that order, because adding an unanswered channel to a bridge is a known cause of a connected-but-silent call.

Four services on the call path, not seven — Asterisk, the agent, the booking API, Postgres — plus the usual observability stack (Appendix A). The LiveKit server, Redis, the LiveKit SIP service and the watchdog sidecar are all gone.

Why on-prem: the PBX is on the LAN, audio must not cross the public internet twice, and patient conversations should not sit on a third party's cloud. Only the Sarvam API calls leave the building (an Indian company; processing location and retention to be confirmed in their DPAVERIFY, §13).

5. Call flows

Call flows

1 / 8

Caller ID: arrives in the SIP From / P-Asserted-Identity on the trunk and is read from the ARI channel object. On Indian PRI/GSM legs the CLI is often withheld or rewritten by the PBX on the internal hop — VERIFY in Phase 1 what Com1PBX actually forwards. The agent confirms the number if present and asks for it if absent.

6. Components

# Component Choice Notes
1 PBX existing Com1PBX one new SIP trunk to Asterisk + a DID route + a failover destination; nothing else changes
2 Media + telephony Asterisk 22 LTS (20 LTS exits full support 2026-10-19) terminates SIP/RTP, transcodes alaw↔slin, runs the dialplan and ARI. Not 18 — end-of-life, and no DTMF over AudioSocket
3 Media transport AudioSocket over TCP slin, 8 kHz, mono, 320-byte / 20 ms frames; 3-byte header (type + big-endian length). Types: 0x00 terminate, 0x01 UUID, 0x03 DTMF, 0x10 audio
4 Control plane ARI (/ari/events WebSocket + REST) answer, bridge, originate, hang up. ari-py is unmaintained — use aiohttp/websockets directly, or asyncari
5 Agent Python 3.11, asyncio one process: ARI client + AudioSocket TCP server + /health endpoint
6 STT Sarvam saaras:v3-realtime (v4-realtime available) wss://api.sarvam.ai/speech-to-text-realtime/ws, encoding=linear16, sample_rate=8000, language_code=auto, stream_type=fast. Server-side VAD
7 LLM Sarvam sarvam-105b-conversations OpenAI-compatible at https://api.sarvam.ai/v1 — use the openai SDK. Tool calling in the standard shape. sarvam-m and sarvam-30b are removed; they will 4xx
8 TTS Sarvam bulbul:v3 streaming wss://api.sarvam.ai/text-to-speech/ws, output_audio_codec=linear16, speech_sample_rate=8000 — straight back into AudioSocket
9 Booking API FastAPI + Postgres own tables first; adapter to the HIS (Hospital Information System) behind an interface; nightly pg_dump to a second host, 30-day retention, one restore drill before cut-over
10 Notifications the hospital's existing SMS gateway (or MSG91) confirmation + reminder
11 Ops Docker Compose, Prometheus + Grafana, Loki thresholds in §17
12 Recording Asterisk MixMonitor → local disk built in Phase 3, used from Phase 4; continues in production for quality review, 30-day retention (§13), only on a "yes" to the consent line. Recording lives in Asterisk, not in the agent

7. Concurrency and capacity

The two-call ceiling was a property of having two humans. Nothing in this stack reproduces it. What does bound concurrency, in order of how likely it is to bite:

Layer Ceiling How to set it
Sarvam chat rate limit 40 / 60 / 120 requests per minute (Starter / Pro / Business) The binding constraint (§3). Confirm the tier in Phase 0 — 10 concurrent calls generate ~50–70 turns/min.
Sarvam streaming-STT connections 20 / 100 / 100 one per live call; comfortable at this size
Sarvam streaming-TTS connections 60 / 200 / 1000 (30 on bulbul:v3 at Starter) one per live call
Com1PBX trunk channels whatever the PBX allows from Erlang B below; start at 10
Asterisk order of 50–150 concurrent calls on 4 vCPU doing alaw↔slin far beyond this hospital; not a real limit here
Agent process one asyncio process, one task per call — no blocking calls in the hot path The AudioSocket listener port and the Stasis app registration are single-owner resources, so a second instance is not a simple win. Resilience comes from a supervisor restart plus orphan reconciliation (§10), not from a warm spare. Splitting into a listener + worker pool is a later optimisation, not Phase 1. The arithmetic is comfortable — ~1,000 event-loop wakeups/sec at 10 calls, roughly 10–15% of one core — but it holds only while nothing blocks: one synchronous library call, blocking DNS, or disk I/O in a log handler stalls all ten calls' audio at once. Treat "no blocking calls in the hot path" as a design rule, and monitor event-loop lag (§17), not just per-call drift

Trunk sizing — §7

How many SIP channels does the trunk need?

120
3.0

Offered load: 6.0 Erlangs

Recommended
10
channels for <5% blocking
13
channels for <1% blocking

Offered load vs. recommended channels

0 Erlangs 10 channels of headroom

Blocked calls fall through to the human operators (§10), so 5% is an acceptable target for the pilot. Peak volume is open question Q8.

Blocked calls are not lost — the PBX sends them to 122/123 (§10) — so 5% is an acceptable pilot target. Tighten once real volume is known (Q8).

Latency budget (a hypothesis to be measured in Phase 1 — Sarvam publishes no first-token figures): 20 ms audio frame cadence → server VAD end-of-turn at silence_duration_ms (500 ms default, expect to tune down) → STT final → LLM first token → TTS first audio chunk. Target under 1.5 s from the caller finishing to the agent starting.

Three structural advantages over the previous design are worth naming, because they are why this budget is achievable at all: there is no Opus encode/decode, no WebRTC jitter buffer, and no media-server hop. Asterisk transcodes alaw→slin once (a table lookup), and those 8 kHz PCM bytes go straight into Sarvam's socket unchanged.

If sarvam-105b-conversations proves too slow on first token, the fallback is a smaller Sarvam chat model for the turn loop — the booking logic does not need a 105B model, and the model id is one line of config.

8. Languages, turn-taking, DTMF

9. Transfer design

One mechanism covers both cold and warm transfer: originate the department leg, bridge it, drop out.

  1. The agent announces the transfer in the caller's language.
  2. It moves the caller into a holding bridge (music on hold) and originates a new channel to PJSIP/130@com1pbx.
  3. Cold: as soon as the department answers, the caller is bridged to it and the agent removes its own media channel and leaves.
  4. Warm: before bridging, the agent speaks a one-line handover to the department only ("Tamil-speaking patient asking about a lab report"), then bridges and leaves.

The two differ by one step, so warm transfer costs almost nothing extra to build.

Why not POST /channels/{id}/redirect. ARI does offer a direct redirect, and because the inbound leg is PJSIP and ext 130 is also PJSIP the technologies match, so it should work. But redirect depends on Com1PBX handling an Asterisk-initiated redirect cleanly, and Com1PBX is a proprietary PBX whose REFER behaviour we cannot check from here. Originate-and-bridge depends on nothing but its ability to accept a normal inbound call — which we will already have proven by then. Test redirect in Phase 2; if it works cleanly it is one fewer channel to hold open, but do not plan on it.

Rules in both cases:

10. Failure modes and the human fallback

This is the section that gets sign-off. The operators are tier 2, and reaching them must not depend on the agent being healthy.

Failure modes

A channel handed to Asterisk's Stasis application arrives unanswered. The agent answers only once its audio path is up — so a broken agent never produces an answered call with dead air. The fallback runs before that, in the dialplan.

Showing 11 of 11

If Com1PBX cannot provide a failover destination (Q2 = no). The dialplan health check still covers an unhealthy agent, but trunk-down and all-channels-busy would then ring nobody. Contingency: have Com1PBX ring a hunt group containing 122/123 if the Asterisk trunk does not answer within N seconds — a no-answer timeout rather than a failure destination, which almost every PBX supports. Prove it in Phase 2. Do not exit Phase 0 with Q2 unresolved.

Operational rule: the agent must never be the only thing between a patient and a human. Every path above ends at 122.

11. Booking domain model

12. Conversation design

13. Security, privacy, compliance

14. If Com1PBX will not give us a trunk

In v4 this was Plan B — a whole parallel architecture, because LiveKit could not register to a PBX. Asterisk can, so this shrinks to a configuration choice:

Either way the rest of the plan is unchanged, which is the point. Q1 is no longer a blocker, only a configuration question.

15. Delivery phases

Total: roughly 5–7 weeks, most of it waiting on hospital-side data and the PBX admin, not on code. The build is smaller than v4's; the schedule is unchanged because the schedule was never dominated by the build.

16. Ownership and escalation

Task Owner
Com1PBX config (trunk, DID route, failover destination) Com1PBX admin
Asterisk (dialplan, PJSIP, upgrades) us through Phase 5, then hospital IT with a runbook
Host — patching, backups, disk hospital IT
Booking DB backups + restore drill hospital IT (procedure supplied by us)
Sarvam account, tier, rate-limit increases us
On-call for the agent us for the first 4 weeks after cut-over (included in the build fee), then hospital IT with us on a paid rate (§3)
On-call for PBX / trunk Com1PBX admin, from cut-over
Operator training + escalation scripts us + hospital ops lead
Department → extension map, doctor rosters hospital ops
Recording retention / deletion job, month-3 opt-off hospital IT (procedure supplied by us)
DPDP grievance contact hospital management

Handover training, hospital IT (before Phase 5): two half-days on the dialplan, the PJSIP trunk config and the runbook, plus one supervised change during Phase 4. Hospital IT should not inherit Asterisk cold.

Vendor bus factor. This build has one primary engineer. Name a second who can support it if the primary is unavailable; if there is no second today, say so in writing with a backup or hiring timeline at Phase 0 exit — the hospital's exposure starts at signature, not at cut-over. Asterisk is deliberately easier to hire for in India than the previous design — that is part of why it was chosen — but that argument only holds if a second person actually exists.

When it breaks at 2 am. The failure table in §10 means the patient still reaches a human without anyone being paged. Escalation is for restoring the agent, not for the call in progress:

Severity Example Who is paged Response target
S1 — calls reaching nobody Failover not firing; trunk and 122/123 both dead Com1PBX admin and us, together 15 min
S2 — agent down, failover working Health check failing; Sarvam down us 30 min (business hours), 2 h (night)
S3 — degraded Booking service down, bookings queuing us, next morning 1 business day

These targets hold through the 4-week warranty. Afterwards they apply only under the paid support tier (§3); otherwise response is best-effort and the contract must say so.

Names and numbers are filled in at Phase 2 and pinned above the operators' desk.

17. Monitoring and alerts

Abandon rate, defined (the Phase 4 gate and an alert): numerator = calls where the caller hung up before a booking, cancellation, message, or transfer completed; denominator = all calls the agent answered. Calls that fell through to the humans are not abandons — they are counted separately, and a rise in them is the leading indicator that something is wrong.

Metric Alert when Action
Dialplan health-check failures any in 5 min Page. Callers are being sent to the humans
Calls falling through to 122/123 > 2 in 15 min Page — the agent is unhealthy or the trunk is full
Asterisk trunk state (OPTIONS) peer unreachable Page the PBX admin
Sarvam 429 / rate-limit responses any in 5 min Page — the chat rate limit is the binding constraint (§7); request a tier increase
Sarvam 5xx or timeout rate > 5% over 10 min Page; check Sarvam status
Concurrent calls > 80% of trunk channels Warn; raise channels
Agent process restarts > 3 in 15 min Page
AudioSocket write-pacing drift any sustained Warn; audio quality is degrading
Event-loop lag p99 > 50 ms Page — something is blocking the loop, which degrades every call at once and would otherwise read as ten unrelated drift blips
Parked channels with no owning task any Page — a call task died silently; the 30-second sweep collected the caller, but find out why
Booking service 5xx > 1% over 10 min Page
Abandon rate > 10% over 1 h Page + review transcripts
Turn latency p95 > 2.5 s over 15 min Warn; check Sarvam latency

18. Open questions (blocking Phase 0 exit)

Open questions

17 things to settle before build

0 of 17 answered

    19. Risks


    Appendix A — Repository layout

    hospital-voice-agent/
    ├── docker-compose.yml          # asterisk, agent, booking, postgres, prometheus, grafana, loki
    ├── asterisk/
    │   ├── pjsip.conf              # trunk to Com1PBX + the 122/123 fallback endpoints
    │   ├── extensions.conf         # health check -> Stasis -> humans
    │   ├── ari.conf                # localhost only
    │   └── modules.conf            # res_audiosocket, res_ari_*, chan_pjsip
    ├── agent/
    │   ├── main.py                 # asyncio: ARI client + AudioSocket server + /health
    │   ├── ari.py                  # StasisStart, bridges, externalMedia, originate
    │   ├── audiosocket.py          # 3-byte framing, 20 ms write pacing
    │   ├── sarvam_stt.py           # realtime WS, VAD events, auto language
    │   ├── sarvam_tts.py           # streaming WS, barge-in flush + socket recycle
    │   ├── brain.py                # LLM turn loop + tool dispatch
    │   ├── emergency.py            # keyword match, runs BEFORE the LLM
    │   ├── prompts/                # system prompt per language
    │   └── config/departments.yaml # branch -> dept -> extension -> hours
    ├── booking/
    │   ├── api.py                  # slots, holds, bookings, cancellations, SMS
    │   ├── models.py               # unique (doctor_id, slot_start); audit rows
    │   ├── admin/                  # one-page form: doctor exceptions + pending queue
    │   └── adapters/his_stub.py    # replace with the real HIS
    ├── evals/
    │   ├── utterances.yaml         # 60+ labelled turns incl. the emergency category
    │   └── run_evals.py
    └── ops/
        ├── prometheus.yml, alerts.yml, loki-config.yml
        └── dashboards/calls.json
    

    Appendix B — Key code and config (reference)

    These are illustrative skeletons, not production code. They exist to pin down the decisions that are easy to get wrong — the answer ordering, the UUID correlation, frame pacing, resource teardown, the failover dialplan — because each of those has already been got wrong once during review. Error handling, logging, retries and configuration are sketched, not complete. Functions referenced but not shown (send_to_operators, drop_media_channel, wait_answered, say_to, ari.*) are left to the implementation. Treat this as the set of traps to design against, and Phase 1 as where the real code gets written and measured.

    Dialplan (extensions.conf) — the health check is the fallback, and it runs before anything answers:

    [helpline]
    exten => _X.,1,NoOp(helpline ${CALLERID(num)} -> ${EXTEN})
     same => n,Set(CURLOPT(conntimeout)=1)
     same => n,Set(CURLOPT(httptimeout)=1)   ; NOT 'timeout' - that option does not exist
     same => n,Set(AGENT=${CURL(http://127.0.0.1:9099/health)})   ; 'ok' only when the ARI socket is up
     same => n,GotoIf($["${AGENT}" != "ok"]?humans,1)
     same => n,Stasis(helpline)          ; call is still RINGING here
     same => n,Goto(humans,1)            ; anything that returns -> humans
     same => n,Hangup()
    
    ; NOTE: there is deliberately no `exten => 0` here. Once a channel is inside
    ; Stasis, DTMF is delivered to the ARI app as ChannelDtmfReceived events and is
    ; NOT re-matched against the dialplan, so a dialplan extension would be dead
    ; code. In-call "press 0" is handled by the agent (§8). The case where the
    ; agent is already dead is covered by the health check above, before we answer.
    
    exten => humans,1,NoOp(falling back to the operators)
     same => n,Goto(operators,1,1)
    
    ; ONE definition of "reach a human", reused by the pre-answer fallback above and
    ; by the agent's in-call escapes (via Local/1@operators). Both operators ring in
    ; parallel: sending an escape to 122 alone strands the caller when 122 is busy.
    [operators]
    exten => 1,1,Dial(PJSIP/122@com1pbx&PJSIP/123@com1pbx,40)
     same => n,Hangup()
    

    Trunk to Com1PBX (pjsip.conf, registration form) — VERIFY alaw vs ulaw against the PRI:

    ; Without a transport nothing binds a socket: there is nothing to REGISTER over
    ; and no listener for inbound INVITEs to arrive on. Match the protocol Com1PBX
    ; actually speaks.
    [transport-udp]
    type=transport
    protocol=udp
    bind=0.0.0.0:5060
    
    [com1pbx-reg]
    type=registration
    outbound_auth=com1pbx-auth
    server_uri=sip:<com1pbx-ip>
    client_uri=sip:<user>@<com1pbx-ip>
    retry_interval=30
    
    [com1pbx-auth]
    type=auth
    auth_type=userpass
    username=<user>
    password=<secret>
    
    [com1pbx]
    type=endpoint
    transport=transport-udp
    context=helpline
    disallow=all
    allow=alaw            ; G.729 deliberately not offered
    direct_media=no       ; keep media on Asterisk so we can read it
    dtmf_mode=rfc4733
    ; No NAT is assumed between Asterisk and Com1PBX (same LAN) - **VERIFY** in
    ; Phase 2. If anything translates addresses, add nat=force_rport,comedia.
    outbound_auth=com1pbx-auth
    aors=com1pbx-aor
    
    [com1pbx-aor]
    type=aor
    contact=sip:<com1pbx-ip>
    qualify_frequency=30  ; OPTIONS keepalive - dead peer noticed in seconds
    
    ; WITHOUT THIS, INBOUND CALLS DO NOT WORK AT ALL. type=registration only drives
    ; our outbound REGISTER; it does nothing to match INVITEs arriving FROM Com1PBX.
    ; Absent an identify object those hit "No matching endpoint found" and are
    ; rejected before the dialplan runs.
    [com1pbx-identify]
    type=identify
    endpoint=com1pbx
    match=<com1pbx-ip>
    

    Attaching audio to a call (ARI). The UUID in data is what ties the TCP connection to this channel:

    uuid_ = str(uuid.uuid4())
    # Register the waiter BEFORE asking Asterisk to connect, or the socket can
    # arrive before there is anything to signal.
    audio_ready[uuid_] = asyncio.Event()
    
    async def discard(bridge_id, media_id):
        """Tear down whatever exists. Must never raise: it runs inside a finally
        that may be unwinding a CancelledError, and an exception there would
        REPLACE the cancellation and leave the task looking failed, not cancelled."""
        with contextlib.suppress(Exception):
            if media_id:
                await ari.delete(f"/channels/{media_id}", raise_on_404=False)
        with contextlib.suppress(Exception):
            if bridge_id:
                await ari.delete(f"/bridges/{bridge_id}", raise_on_404=False)
    
    bridge = media = None
    ok = False
    try:
        # Everything that creates a resource lives INSIDE the guard, or a failure
        # here leaks with no handler in scope at all.
        bridge = await ari.post("/bridges", json={"type": "mixing"})
        media = await ari.post("/channels/externalMedia", json={
            "app": "helpline",
            "external_host": f"{AGENT_HOST}:9092",
            "format": "slin",            # 8 kHz signed linear - matches Sarvam
            "encapsulation": "audiosocket",
            "transport": "tcp",
            "connection_type": "client", # Asterisk dials OUT to us
            "direction": "both",
            "data": uuid_,               # REQUIRED for audiosocket; our correlation key
        })
        # Wait for Asterisk's AudioSocket connection to arrive and identify itself
        # with this UUID BEFORE answering. Answering first would produce exactly the
        # answered-call-with-dead-air failure §2 claims this design cannot have -
        # "we control the moment" has to be enforced here, not just asserted.
        await asyncio.wait_for(audio_ready[uuid_].wait(), timeout=2.0)
        # Answer BEFORE bridging: adding an unanswered channel to a bridge is a
        # well-known cause of a connected-but-silent call.
        await ari.post(f"/channels/{caller_id}/answer")
        await ari.post(f"/bridges/{bridge['id']}/addChannel",
                       params={"channel": f"{caller_id},{media['id']}"})
        call_bridges[caller_id] = bridge["id"]      # so hangup can destroy it
        call_media[caller_id] = media["id"]         # and release the media channel
        ok = True
    except Exception:
        await send_to_operators(caller_id)      # never answer what we cannot serve
        return
    finally:
        audio_ready.pop(uuid_, None)            # the socket map owns it from here
        if not ok:
            # Teardown lives HERE, not in the except: asyncio.CancelledError
            # inherits from BaseException, so `except Exception` does not catch the
            # caller hanging up mid-setup. Shielded so it survives that cancellation.
            await asyncio.shield(discard(bridge and bridge["id"], media and media["id"]))
    

    AudioSocket framing — 1-byte type, 2-byte big-endian length, payload:

    import asyncio, contextlib, uuid
    
    TERMINATE, UUID, DTMF, AUDIO = 0x00, 0x01, 0x03, 0x10
    
    async def read_frame(reader):
        head = await reader.readexactly(3)
        kind, length = head[0], int.from_bytes(head[1:3], "big")
        return kind, (await reader.readexactly(length) if length else b"")
    
    async def on_connection(reader, writer):
        """Asterisk dials us and identifies itself with the UUID frame first.
        That frame carries the UUID as 16 RAW BYTES - not the string we generated -
        so it must be converted back before it can match the waiting call.
        This rests on community reports rather than Asterisk's own externalMedia
        reference page - **VERIFY in Phase 1 (§15)** before trusting it."""
        kind, payload = await read_frame(reader)
        assert kind == UUID and len(payload) == 16
        call_id = str(uuid.UUID(bytes=payload))
        waiter = audio_ready.get(call_id)
        if waiter is None:                  # stale or unknown - the call is gone
            log.warning("audiosocket for unknown call %s", call_id)
            writer.close()
            return
        sockets[call_id] = (reader, writer)
        waiter.set()                        # releases the answer gate
    
    # Everything a call owns must be registered somewhere, or it cannot be released.
    tasks: dict[str, asyncio.Task] = {}       # call -> its handler task
    call_bridges: dict[str, str] = {}         # call -> the bridge to destroy
    call_media: dict[str, str] = {}           # call -> its externalMedia channel
    sarvam_sockets: dict[str, tuple] = {}     # call -> (stt_ws, tts_ws)
    
    async def on_stasis_start(event):
        caller_id = event["channel"]["id"]
        # Registering the task is what makes cancellation on hangup possible at all;
        # without this line on_stasis_end's tasks.pop() always returns None.
        tasks[caller_id] = asyncio.create_task(handle_call(caller_id))
    
    async def on_stasis_end(event):
        """Asterisk does NOT destroy an empty mixing bridge for you, and a dead
        task will not cancel itself. Both are leaks over a 24x7 process."""
        call_id = event["channel"]["id"]
        if task := tasks.pop(call_id, None):
            task.cancel()
        await release_call(call_id, call_bridges.pop(call_id, None))
    
    async def release_call(call_id: str, bridge_id: str | None = None):
        """Release EVERYTHING the call owns. Each of these leaked at some point
        during review: the TCP connection, the two Sarvam websockets, the
        externalMedia channel, and the bridge Asterisk will not reap for you."""
        for ws in sarvam_sockets.pop(call_id, ()):
            with contextlib.suppress(Exception):
                await ws.close()
        rw = sockets.pop(call_id, None)
        if rw:
            _, writer = rw
            writer.close()
            with contextlib.suppress(Exception):
                await writer.wait_closed()
        if media_id := call_media.pop(call_id, None):
            with contextlib.suppress(Exception):
                await ari.delete(f"/channels/{media_id}", raise_on_404=False)
        if bridge_id:
            with contextlib.suppress(Exception):
                await ari.delete(f"/bridges/{bridge_id}", raise_on_404=False)
    

    Sarvam realtime STT — 8 kHz PCM straight from AudioSocket, no resampling:

    url = ("wss://api.sarvam.ai/speech-to-text-realtime/ws"
           "?model=saaras:v3-realtime&language_code=auto&stream_type=fast"
           "&encoding=linear16&sample_rate=8000&endpointing=vad"
           "&silence_duration_ms=350")          # 500 default feels slow on a call
    ws = await websockets.connect(url, additional_headers={"API-SUBSCRIPTION-KEY": KEY})
    await ws.send(json.dumps({"event": "audio_input",
                              "audio": base64.b64encode(pcm).decode()}))
    # server emits: vad.speech_start | vad.speech_end |
    #               transcript.partial | transcript.final {text, language, ...}
    

    Sarvam streaming TTS — configured to hand back exactly what AudioSocket wants:

    await ws.send(json.dumps({"type": "config", "data": {
        "language_code": lang,          # follows the detected caller language
        "speaker": SPEAKER[lang],
        "model": "bulbul:v3",
        "output_audio_codec": "linear16",
        "speech_sample_rate": "8000",   # no resampling on the way out either
        "send_completion_event": True,  # gives us a real end-of-utterance marker
    }}))
    await ws.send(json.dumps({"type": "text", "data": {"text": chunk}}))
    await ws.send(json.dumps({"type": "flush"}))
    # Pace the audio out, and only mark the LAST frame when Sarvam says the
    # utterance is finished. Passing last=True on every per-chunk flush would
    # re-introduce the mid-speech padding this was written to avoid.
    #   {"type":"audio", ...}                  -> await pacer.send(pcm)
    #   {"type":"event","event_type":"final"}  -> await pacer.send(b"", last=True)
    # Barge-in: there is NO server-side cancel. On vad.speech_start, stop writing
    # to Asterisk, drop the buffered audio, and recycle this socket.
    

    LLM — OpenAI-compatible, so the standard SDK works:

    client = AsyncOpenAI(api_key=SARVAM_KEY, base_url="https://api.sarvam.ai/v1")
    resp = await client.chat.completions.create(
        model="sarvam-105b-conversations",   # sarvam-m and sarvam-30b are removed
        messages=history, tools=TOOLS, tool_choice="auto", stream=True,
    )
    

    Transfer — originate, bridge, leave:

    async def transfer(caller_id: str, ext: str, brief: str | None = None):
        dept = await ari.post("/channels", json={
            "endpoint": f"PJSIP/{ext}@com1pbx", "app": "helpline",
            "callerId": "Helpline", "timeout": 25,
        })
        if not await wait_answered(dept["id"], timeout=25):
            return "no_answer"                     # -> back to the caller (§9)
        if brief:
            await say_to(dept["id"], brief)        # warm: department hears it alone
        # Leave the original bridge before joining another - this is not automatic -
        # and destroy it afterwards, since Asterisk will not reap an empty bridge.
        old_bridge = call_bridges.pop(caller_id, None)
        if old_bridge:
            # removeChannel is a POST, not a DELETE - a DELETE here silently fails
            # and the caller is never taken out of the old bridge.
            await ari.post(f"/bridges/{old_bridge}/removeChannel",
                           params={"channel": caller_id})
        hold = await ari.post("/bridges", json={"type": "mixing"})
        await ari.post(f"/bridges/{hold['id']}/addChannel",
                       params={"channel": f"{caller_id},{dept['id']}"})
        # The agent leaves, but the CALL continues with the department. Release
        # everything the agent still holds, or both Sarvam sockets stay open and
        # billing for the rest of a conversation nothing is listening to - and keep
        # occupying one of the concurrent-connection slots budgeted in §7.
        for ws in sarvam_sockets.pop(caller_id, ()):
            with contextlib.suppress(Exception):
                await ws.close()
        tasks.pop(caller_id, None)
        await drop_media_channel(call_media.pop(caller_id, None))
        if old_bridge:
            await ari.delete(f"/bridges/{old_bridge}", raise_on_404=False)
        call_bridges[caller_id] = hold["id"]
        return "transferred"