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 wksto cut-over
-
~₹0per call
-
0open questions
Summary
Summary The plan in one picture — before and after
Today
Bookings are written by hand; anything else is transferred by the operator.
With the agent
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:
- answers every call at once (no two-call ceiling),
- books, reschedules, and cancels appointments in the caller's language,
- hands anything else to the right internal extension,
- and leaves the PBX, the extensions, and the department workflows exactly as they are.
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
- PBX admin: two hours in Phase 0 for the trunk / registration / failover / codec questions (§18).
- Hospital ops: department → extension map with hours; where doctor schedules and leave live today; expected call volume; single site or branches; a named reviewer for the pilot week (~30 min/day listening to calls).
- Hospital IT: SMS gateway details, a LAN IP for the host, in-principle agreement to a weekday 6–7 am cut-over window (exact date confirmed after Phase 3); firewall approval for outbound WSS/HTTPS from the host to
api.sarvam.ai— many hospital LANs block outbound by default, and finding that in Phase 2 stalls the build; a named person to reach the HIS vendor for Q13. - Finance: review the cost drivers in §3 and confirm the approval process once Q8 (volume) and the vendor rows land; confirm milestones tied to phase gates.
- Management: a named DPDP grievance / data-protection contact (§13).
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:
- 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
linear16atsample_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. - 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.
- 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?
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
Com1PBX
The one edit is routing the two helpline DIDs to the Asterisk trunk; every other extension is untouched.
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 DPA — VERIFY, §13).
5. Call flows
Call flows
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?
Offered load: 6.0 Erlangs
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
- Launch languages: Tamil, English, Hindi. Sarvam's realtime models cover 22+ Indic languages, so adding Telugu/Malayalam/Kannada later is a config change once evals pass.
- The voice follows the caller, natively. Connect the STT socket with
language_code=auto; everytranscript.finalcarries a detectedlanguageplus a confidence. The agent pins the language on the first confident detection and sets the TTS connection'slanguage_codeand speaker to match. No detection hack, no per-language turn-detector — this was the ugliest part of the LiveKit design and the API does it for us. - Code-mixed Tanglish is the normal case here, not an edge case. Sarvam exposes a
mode=codemixfor Indic+English mixing, but the docs do not name Tamil-English quality specifically — VERIFY with the Phase 1 eval set before promising it. - Turn-taking is server-side. The STT socket emits
vad.speech_start/vad.speech_endwith tunablethreshold(default 0.3),silence_duration_ms(default 500) andmin_speech_duration_ms(default 250). Usestream_type=fastfor the lowest latency. There is no local VAD model to ship, and nothing language-specific to configure — a real simplification over a turn-detector that did not support Tamil at all. - Expect to tune
silence_duration_msdown from 500 ms during the pilot: on a phone call, half a second of silence before the agent responds reads as sluggish, but cutting it too fine truncates callers who pause mid-sentence. This is a listening-and-tuning job in Phase 4, not a value we can pick from a document. - Barge-in is the one genuinely fiddly part of the build. Sarvam's streaming TTS has no documented server-side cancel. When
vad.speech_startfires while the agent is talking, the agent must (a) immediately stop writing TTS frames toward Asterisk, (b) discard whatever audio is already buffered, and (c) recycle the TTS socket — sending a freshconfigmessage also flushes the prior buffer. Budget a day for this and test it explicitly; a bot that cannot be interrupted is the single most common complaint about phone agents. - DTMF. Once a call is inside Stasis, digits are delivered to the agent (as ARI
ChannelDtmfReceivedevents, and inline over AudioSocket as0x03frames on Asterisk 20+). They are not re-matched against the dialplan. So be precise about what "press 0 for an operator" buys: it is handled by the agent, and therefore only works while the agent is running. It is a shortcut past a bad conversation, not a safety net against an agent outage — that job belongs to the pre-answer health check (§10), which never lets a broken agent answer in the first place. Digit entry for a phone number or age uses the same events, with a 3-second inter-digit timeout and#to finish. - Numbers, dates and doctor names are where speech recognition fails. The agent reads the booking back and requires a yes/no before it commits anything.
9. Transfer design
One mechanism covers both cold and warm transfer: originate the department leg, bridge it, drop out.
- The agent announces the transfer in the caller's language.
- It moves the caller into a holding bridge (music on hold) and originates a new channel to
PJSIP/130@com1pbx. - Cold: as soon as the department answers, the caller is bridged to it and the agent removes its own media channel and leaves.
- 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:
- Department busy or no answer within 25 s → back to the caller: take a message, or the 122/123 operator group.
- "Operator", "person", or DTMF 0 at any point → the 122/123 group immediately. The caller is never trapped.
- Always the group, never a single extension. Sending the escape path to 122 alone means a caller who asks for a person while 122 is on another call has nowhere to go. Both the pre-answer fallback and every in-call escape dial the same Asterisk extension, which fans out to both operators.
- The agent announces before transferring, in the caller's language.
- The department extension map lives in
departments.yaml, keyed by branch, with hours.
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.
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
- Entities:
department,doctor,schedule(recurring weekly template + exceptions),slot(materialised per day),hold,booking,patient(phone + name, MRN — Medical Record Number — if the HIS provides one). - Holds:
hold_slotreserves for 3 minutes, renewed once if the caller is still in that flow; released on call end or expiry. - Double-booking is prevented at the database, not in application code: unique constraint on
(doctor_id, slot_start). A losing hold getsslot_takenand the agent offers the next nearest slot. - Doctor unavailability: exceptions table (leave, OPD cancelled) — populated from whatever the hospital uses today (roster system, WhatsApp group, whiteboard — Q9). Until integrated, the front desk edits it in a one-page admin form.
- Walk-ins vs phone bookings: whether they share one pool is Q11; the model supports a per-doctor
phone_sharepercentage either way. - Multi-branch: the DID (or an in-call question) selects the branch;
departments.yamlis keyed by branch. - Cancellation/reschedule as in §5. Every state change writes an audit row (who/when/via which call-id).
12. Conversation design
- Greeting (Tamil, then one English line): identifies the hospital, says it's an automated assistant, that booking details are stored to make the appointment, then asks "may we also record this call?" (recording consent, yes/no, logged — §13; a "no" only disables recording, the booking still proceeds), and adds "say 'operator' any time to speak to a person." Under 12 seconds total. Interruptible.
- What a Tamil caller hears, roughly: "Vanakkam, [hospital] helpline. Naan oru automated assistant. Appointment book panna unga details store pannuvom. Intha call-a record pannalaamaa? Eppo venumnaalum 'operator' nu sonna oru aal-kitta connect pannuven." Final wording is written with the hospital's front-desk lead, not by us alone.
- The prompt refuses medical advice and transfers instead. It does not diagnose, recommend medicines, or interpret reports — ever.
- Read-back before every commit: name, phone, department, doctor, date, time. Yes/no.
- Slot-filling tolerates partial info; asks one question at a time; never asks for something already known (caller-ID, returning patient).
- Silence: 8 s → "are you still there?"; second 8 s → goodbye + hang up.
- Every branch has a scripted fallback line per language; the LLM chooses among tools, it does not improvise policy.
13. Security, privacy, compliance
- Nothing in this stack is exposed to the internet. Com1PBX ↔ Asterisk is SIP on the LAN with digest auth and IP restriction; Asterisk ↔ agent is AudioSocket over TCP on localhost or a private LAN address. The AudioSocket protocol has no TLS in mainline Asterisk — that is acceptable only because both ends sit on the hospital LAN. If the agent ever moves to a different host, wrap it in stunnel; do not expose that port.
- The only egress is HTTPS/WSS to Sarvam. RTP, SIP signalling and the booking database never leave the building.
- DPDP Act 2023, in plain terms. The hospital is the Data Fiduciary: it must (a) collect personal data only with clear consent for a stated purpose, (b) use it only for that purpose and keep it only as long as needed, (c) let a person see, correct or erase it on request, (d) report a breach to the Data Protection Board and affected people without undue delay, and (e) name a contact for grievances. Penalties run up to ₹250 crore for serious failures. Health context makes this data sensitive.
- What this plan does about it: consent captured explicitly in the greeting (yes/no, logged with the call id); a "no" disables recording only — the booking proceeds on the data needed to complete it; transcripts kept 30 days, audio (when recording is on) also 30 days and only on-prem; erasure is a documented SQL + object-store procedure. Management names the grievance / data-protection contact.
- Sarvam's terms are an open item. Their Trust Centre and privacy pages could not be read programmatically during this research, so claims circulating about India-only residency, ISO 27001 and SOC 2 Type II are UNVERIFIED here. Before any patient audio is sent: read the DPA directly, confirm processing location and retention, and file it in the hospital's vendor register — VERIFY (Q15). Sarvam is reported to offer on-prem/air-gapped deployment for regulated customers; if the DPA is unsatisfactory, that is the fallback worth pricing.
- Secrets (Sarvam key, AMI/ARI credentials, DB creds) in a
.envwith 0600 permissions; nothing in the repository. Asterisk's AMI/ARI listener binds to localhost only. - The booking API is LAN-only and token-gated; the agent and the front-desk admin form are its only clients.
- Logs redact phone numbers to the last four digits; full data lives only in the booking database.
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:
- Preferred: a registration-based SIP trunk (
type=registration+outbound_auth). Built for N concurrent calls, no per-device contact limits on the calling side. - Fallback: register as extensions. If Com1PBX will only accept device registrations, Asterisk registers as several extensions and hunts across them. The catch that killed this idea in v4 still applies: most PBXes cap concurrent calls per registered extension, and "call waiting" does not raise that cap — so register one extension per channel in a range that does not touch the operators' 122/123 (e.g. 200–209 for ten channels).
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
No questions for this owner.
19. Risks
- Com1PBX behaviour is still the main unknown, but a smaller one than in v4. Asterisk speaks to it as an ordinary SIP peer by registration or static trunk, so the question moved from "will this work at all" to "which of two standard configurations do we use".
- Two Asterisk failure semantics are documented ambiguously —
AudioSocket()'s-1return andSTASISSTATUSon an unregistered app. The design deliberately does not depend on either (§10), but both must be tested in Phase 1 so we know what actually happens. externalMediawith AudioSocket encapsulation is the newest part of the stack (Asterisk 20.4.0+). The plain dialplanAudioSocket()path has run since 2020 and is the fallback if the ARI path misbehaves — at the cost of needing AMI to pass the transfer target back.- Barge-in has no server-side cancel in Sarvam's streaming TTS. The workaround (stop writing, discard, recycle the socket) is understood but must be built and heard, not assumed.
- The Sarvam chat rate limit binds before anything else does. At 10 concurrent calls we exceed the Starter tier. Settle the tier in Phase 0.
- Name transcription in Tamil and Hindi will be imperfect. Read-back confirmation plus front-desk verification on arrival; never block a booking on perfect spelling.
- Sarvam's DPA is unread. Do not send patient audio until §13's VERIFY is closed.
- Scope creep into "answer any medical question". The prompt refuses and transfers. Keep it that way.
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"