Voice agents
Voice agent playbook: HiX, Exchange and a pain rule
A 25-person rehab clinic in Enschede handles 1,260 weekly appointment changes through a voice agent. Here is how we wired HiX, Exchange and a 7/10 pain rule.

Vrijdag, 16:47. The front desk at a revalidatiecentrum in Enschede has thirty-one voicemails from the lunch hour and a queue of seven patients still in their raincoats. Two of those voicemails are someone moving a knie-revalidatie appointment by twenty minutes. One is a man who says his back has gone out and asks if there is anything sooner. He gives the receptionist a 7 when she asks how bad it is on a scale of ten. She cannot pick up the next call and write his pain score into HiX at the same time, so she does the second thing badly. By the time anyone in clinical reads the note it is 18:30, the behandelaars have gone home, and the Zvw-rapportage window closes in three and a half hours.
This is the situation a voice agent earns its keep on. Not the demo where it books a haircut. The one where the cost of dropping a melding above 7/10 is a person sitting on the bathroom floor at 21:55, three minutes after he could have been seen.
What follows is the playbook we used for a 25-person centrum that takes about 1,260 afspraak-wijzigingen a week — most boring, two not — across a thirteen-year-old ChipSoft HiX EPD and a homegrown Exchange 2016 fysiotherapeut-rooster that the physiotherapy lead built sometime around the 2019 verbouwing.
The two systems that have to agree
The voice agent is not the hard part. It never is.
The hard part is that the centrum's source of truth lives in two places that hate each other. Patient afspraken, behandelplanen and the Zvw-aanspraak records live in HiX. The physiotherapy rooster — who is in which behandelzaal at which quarter-hour — lives in an Exchange 2016 calendar with custom categories and a colour-coded shadow spreadsheet only the senior fysiotherapeut understands.
Moving an afspraak means a write into HiX and a write into Exchange, in that order, in the same minute, or the rooster will quietly double-book a knee onto a shoulder. We have watched it happen. The behandelaar then improvises in front of two patients, both of whom assume she is incompetent, and the centrum eats the slot.
So the first rule of the playbook is: the voice agent is not allowed to confirm anything to the caller until both writes have landed. Not ik geef het door. Not we hebben het genoteerd. Confirmation only after the round-trip.
The shape of the system
Here is the actual topology we shipped.
caller
│
▼
SIP trunk (KPN) ── Twilio voice ──► Dutch ASR (Azure Speech, nl-NL)
│
▼
intent router
│
┌───────────────────────────────┼───────────────────────────────┐
▼ ▼ ▼
afspraak-wijziging pijn-trigger (≥7/10) algemene info
│ │
▼ ▼
HiX SIU^S14 over MLLP behandelaar queue (Teams)
│ + SMS pager to dienst-doend
▼
Exchange 2016 EWS write
│
▼
read-after-write confirm in Dutch
Nothing exotic. The only opinion in the diagram is that the intent router is the smallest possible LLM call we could get away with — a gpt-4o-mini-class model on a private endpoint, scoped to four intents, with the pijn-trigger as a hard-coded regex fallback because we did not trust the model to never miss a quiet zeven or a mumbled acht.
The 50-second pain rule
The clinical lead drew a line. If a caller volunteers a pain score above 7, or uses any of a fixed list of phrases (ik kan niet meer, vastgeslagen rug, tintelingen in mijn been, ik val bijna), the agent must put a behandelaar on the line within fifty seconds, or it must have failed audibly and routed to the front desk.
Fifty seconds because the clinical team measured what the longest tolerable wait felt like on the receiving end of a real triage call. Not thirty, because that is unrealistic if the dienst-doend is washing her hands. Not ninety, because by then the caller has hung up and driven to the spoedpost.
PIJN_TRIGGER_PHRASES = (
"tintelingen", "kan niet meer", "val bijna", "vastgeslagen",
"uitstralend", "uitstraalt", "schiet door", "verlamd",
)
def should_escalate(transcript: str, pain_score: int | None) -> bool:
if pain_score is not None and pain_score >= 7:
return True
lowered = transcript.lower()
return any(p in lowered for p in PIJN_TRIGGER_PHRASES)
That is not the clever part. The clever part is what the agent says while it is dialling the behandelaar. We tried een moment, ik verbind u door. Callers hung up at a 19% rate inside ten seconds. We changed it to ik blijf bij u aan de lijn, ik haal er nu een behandelaar bij and the drop-offs fell to under 3%. The agent then narrates the wait: ik heb Marleen gebeld, zij pakt zo op at the 15-second mark, nog tien seconden at the 40-second mark. People will sit through almost anything if they are told what is happening.
If the behandelaar queue does not pick up by second 50, the agent says ik zet u nu door naar de balie and the call drops to a human receptionist with the transcript already on her screen. We never fall through silently. The receptionist sees a red banner with the pain score, the trigger phrase, and the caller's BSN-masked record open in HiX.
Never let a voice agent escalate to a queue without a hard ceiling on the wait. The failure mode is not the patient waits a bit longer. It is the patient hangs up and you have lost the melding and the audit trail. Put the ceiling in code, not in a Slack agreement.
Talking to HiX without an API you can trust
ChipSoft HiX has integrations. They are documented in the way that very large enterprise software is documented, which is to say: thoroughly, but for someone who already works there. The relevant surfaces, in order of preference, are HL7v2 ADT/SIU messages over MLLP for appointment movement, and the newer FHIR R4 endpoints for read-side queries. The FHIR R4 Appointment resource is what we mapped against.
We landed on three rules.
- Read patient and behandeling status via FHIR R4 over the centrum's internal network. Cache for 60 seconds. Never trust the cache for a write decision.
- Write appointment movements as HL7v2 SIU^S12 (new), S14 (modify) or S15 (cancel) messages over MLLP to the centrum's HiX integration server.
- Reconcile every write with a follow-up FHIR GET on the appointment ID within five seconds. If the read does not confirm the write, retry once, then escalate to the receptionist with the transcript intact.
The reconcile is not optional. HiX accepted our SIU message, returned an ACK, and then silently dropped the change three times in the first week of pilot because the behandelaar's agenda had a soft lock from a different module. We learned never to trust an ACK alone. Read-after-write or it didn't happen.
async def move_appointment(appt_id: str, new_slot: Slot) -> Result:
await hl7.send_siu_s14(appt_id, new_slot)
for _ in range(2):
await asyncio.sleep(2)
live = await fhir.get_appointment(appt_id)
if live.start == new_slot.start:
return Result.ok(live)
return Result.escalate("hix_write_unconfirmed")
Exchange 2016, older than some of the patients
The fysiotherapeut-rooster runs on an on-prem Exchange 2016 server. No Microsoft Graph — that is cloud-only. We talked to it via EWS (Exchange Web Services), which is deprecated for Exchange Online but still the supported protocol for on-prem 2016 boxes. A single service account with impersonation rights, one long-lived worker holding the connection.
Two gotchas cost us a week.
- The rooster uses custom calendar categories (
Knie-NWO,Schouder-Post-OK,Bekken-FT) to indicate room and equipment. They are stored asCategorieson each appointment item, and the senior fysio uses them to filter her colour view. The voice agent has to preserve the full category list across a move or the rooster silently breaks her filter. We now ship a unit test for every category we have ever seen, because we do not trust ourselves to remember. - Exchange 2016 timezone handling for Europe/Amsterdam is correct in theory and broken in practice if the calendar item was originally created by a Mac Outlook client during a DST transition week. We normalise to UTC on read, write back as Europe/Amsterdam with an explicit
TimeZoneContext, and we have logged exactly four ghost-hour shifts in eight months.
The 22:00 cliff
The Zvw-aanspraak rapportage is the boring rule that makes everything else matter. Every patient interaction that affects entitlement has to be in HiX by the end-of-day reporting cycle, which for this centrum runs at 22:00 to the koepelorganisatie. A no-show recorded at 22:04 is, for billing purposes, not a no-show. It is unbilled labour.
Late-evening calls — and there are more than you would think between 20:00 and 22:00, because that is when people sit down on the couch and realise their back is worse than they thought — have to make it through the HiX write before the cliff. So the agent behaves differently after dark.
- After 21:00, the agent shortens its disambiguation prompts. No kunt u dat herhalen loops. Two attempts, then drop to the on-call receptionist's mobile.
- Between 21:30 and 22:00, every successful afspraak-wijziging triggers a synchronous push to the HiX aanspraak-module, not the lazy queue we use during the day. We pay the latency to be on the right side of 22:00.
- At 21:55, the agent stops accepting non-urgent wijzigingen and explains the cutoff in plain Dutch: ik kan deze wijziging vandaag niet meer doorzetten in het systeem; ik plan u in voor de eerste behandelaar morgenochtend. Pijn-triggers still escalate. Always. There is no quiet hour for pain.
Every healthcare voice agent has a clock somewhere that turns its work from billable into free. Find that clock, then put it in the agent's prompt, the routing logic and the dashboard.
What we deliberately left to humans
The playbook is also a list of things we refused to automate.
- First-time intake for a new revalidatie traject. We do not let the agent gather medical history. Behandelaar only, in person, with the goniometer.
- Annulering binnen 24 uur without a reason. The agent collects the reason in a free field and queues it for behandelaar review; it does not decide on Zvw-rechtmatigheid.
- Calls where the caller cries. We trained a small emotional-distress classifier on the Dutch ASR output (silence ratio, pitch variance, sigh detection) and route those calls straight to the receptionist with a soft ik geef u even iemand. We do not try to comfort. We are not equipped for it, and pretending to be is worse than admitting it.
What it actually moved
Four months in, on a normal week.
- 1,260 afspraak-wijzigingen handled end-to-end by the agent. 87% resolved without a human touching them. 13% routed to a human, intentionally.
- Average pijn-trigger escalation: 38 seconds caller-to-behandelaar. Slowest in production: 49 seconds. None over the 50-second ceiling.
- Zero Zvw-rapportages missed since week three, when we moved the 21:30 sync from queued to synchronous.
- Receptionist time recovered: roughly 22 hours per week across two FTE, redirected to in-person intake and the back-office work nobody had time for.
The number we care about most is the one we never see: the patient who got a behandelaar inside the hour on a Friday evening because the agent did not drop his melding when he said his back had gone.
What to do today
Open the schedule for your busiest hour next week. Listen to ten random recordings from the same hour last week. Count the calls where the receptionist had to choose between picking up the next line and finishing a write into the system of record. That number is your case for a voice agent — not the demo, not the keynote, just that number, on your own clock.
When we built this for the centrum in Enschede, the hard part was never the voice agent itself; it was getting HiX and Exchange to agree on what a moved appointment meant, and refusing to confirm anything to the caller until they did. If you are staring at a similar two-system stack, that is the place to start.
Key takeaway
A healthcare voice agent's real job is not talking to patients — it is making two stubborn back-end systems agree before the billing window closes.
FAQ
Can a voice agent really hold a Dutch medical conversation?
Only if the ASR is tuned for nl-NL and the agent never decides anything clinical. Ours triages and routes; behandelaars decide. Azure Speech and Whisper-medium both handle the clinical vocabulary well enough for triage.
How do you integrate with ChipSoft HiX in practice?
Two surfaces. HL7v2 SIU messages over MLLP for appointment writes, FHIR R4 for read confirmations. Never trust an ACK on write. Always read-after-write on the appointment ID before confirming anything to the caller.
What if the rooster is still on Exchange 2016, not Microsoft 365?
Use EWS with a service account that has impersonation rights. Preserve custom categories on every move, and normalise timezones to UTC on read. EWS is deprecated for Exchange Online but still supported on on-prem 2016.
Is a 50-second escalation target realistic in production?
Yes, if the agent narrates the wait and the behandelaar queue is paged in parallel with the call. We measured 38 seconds average in production. The hard ceiling matters more than the average; never let it slip past.
What if the HiX write fails after 22:00?
Treat the 22:00 Zvw-rapportage window as a hard cliff in code. After 21:55, the agent stops accepting non-urgent wijzigingen and explains the cutoff. Pijn-triggers still escalate. Bookings move to the next morning queue.