Maponos Book a call

Build write-up

Teaching a voice agent to order medication, safely.

I built an outbound voice agent for a UK domiciliary care provider. It phones GP surgeries to place repeat prescription orders. This is what the evals caught, why I benchmarked five models and then kept the one I started with, and why the fix in the end was architecture instead of prompting.

The job

Every week someone at a domiciliary care company sits down with a list of names and starts dialling GP surgeries. Repeat prescriptions, for the people they look after. Each call means getting through a phone menu, proving who you are, reading out a list of medications, checking it against what the surgery holds, and writing down a reference number at the end.

It is exactly the kind of job you would want to hand to a voice agent. It is also about vulnerable people and their medication, which changes what a good decision looks like at almost every step.

I built the agent, the test harness around it, and the compliance scaffolding it needs before it can ring a real surgery. Here is what happened.

Three constraints that shaped everything

Everything said on the call is special-category health data

Patient identity, conditions, medications. That rules out a lot of convenient architecture, and it means you have to settle retention, where the data lives and who can read it before you write any of it.

The client wanted it indistinguishable from a human, and latency was the number he cared about

My streaming stack was already measuring 0.66 to 1.8 seconds of perceived reply latency, so that bar was reachable.

That bar fought with disclosure

An agent built to pass as human is hard to square with being honest about what it is. I put it to him on a demo call instead of quietly picking a side in the prompt, and we landed on announced automation. The agent says up front that it is an automated system, then talks normally for the rest of the call.

It turned out to help performance too. The opening line is pre-rendered and passed in as a variable, so no model is involved in producing it. It reaches the listener in about 400 milliseconds and covers the cold start of the conversation.

Architecture

The trigger minimises before anything leaves the machine, creates a job record, then hands the call to a managed voice platform.

care-plan JSON
   │  allowlist minimisation (local)
   ▼
job record (DynamoDB, client's own AWS account, eu-west-2)
   │  dynamic variables only: patient identity, GP contact, medications, job id
   ▼
voice platform: 5-node agent workflow  ──►  webhook tool
   │                                           │
   │  post-call webhook (signed)               │ result
   ▼                                           ▼
audit record + audio in the client's bucket ◄── bridge Lambda (write-only, job-scoped)

Two properties matter more than the boxes do.

The agent can never read the database

The bridge Lambda has exactly two routes, both write-only, and every update is conditional on the job already existing. If the agent gets confused, or somebody on the phone talks it into something, it still cannot enumerate other patients. All it can do is write the outcome of the call it is on.

Durable records stay in the UK, in the client's own AWS account

The voice platform processes the call in the US under the UK extension of the Data Privacy Framework. So the honest sentence is: transient processing in the US, durable records in the UK. That is the sentence I put in the DPIA, instead of the more comfortable one about everything staying onshore, because a care provider can be audited on it and they would be the ones holding it.

The bit I got wrong first

The agent started life as one big prompt with the whole call flow inside it. It scored well. Then the test suite grew to 39 cases and two failures showed up that no amount of prompt wrangling would shift.

The first one. Asked to read back a confirmation reference, the agent would sometimes call the keypad tool and send *#4821 as touch tones instead of just saying the digits. On a real call that is silence, then beeping, at a pharmacist. I tried a guard in the prompt. I rewrote the tool description to spell out, in plain words, that a human being cannot hear tones as words. The failure rate went from 3 in 10 to 5 in 10 and back to 3 in 10. I was tuning noise.

The second one. Every so often the agent would say goodbye and hang up without calling the tool that records the result. The call sounds perfect and the order simply is not there.

Both of them had the same fix, which was to stop asking and start constraining. The agent became a five-node workflow.

NodeRoleTools it actually has
openGet through menus, hold music, answerphoneskeypad, voicemail detection, end call
orderThe actual conversation with a humanend call only
recordWrite the resulttool node, guaranteed execution
closeConfirm and say goodbyeend call
close_failedGraceful exit when the order did not landend call

The keypad tool now only exists in the node where there might be a machine on the other end. In the node where a human is talking, the agent does not have the tool at all, so misusing it is not unlikely, it is impossible. Recording the result is a node the workflow moves into, not a call the model decides to make, and the job id is bound by schema override so the model never supplies it.

Reference readback went from 3 in 10 to 4 out of 4, with zero keypad calls. The full suite went to 39 out of 39, and 116 out of 117 across three repeats.

If a rule has to hold, put it in the graph. A prompt is a strong suggestion.

Evaluation

39 tests, run against the deployed agent, each pinned to the node it exercises. Roughly 19 edge cases, 6 safety rails, 6 workflow transitions, 4 personas and 4 tool assertions. 29 are judged by a model and 10 are hard assertions about tool calls.

Some of what they actually check:

Three things came out of running these that I was not expecting.

One green run does not mean much

The suite is non-deterministic. Three runs in a row scored 38 out of 39 and failed a different test each time. My rule now is that it is not green until it is green twice.

Some failures were not prompt bugs at all

Three of four failures in one stretch were the model producing garbage. A wall of exclamation marks, then a stream of r r |. Read out to a pharmacist, that is just noise. No prompt change addresses it, and telling the two apart changed what I did next.

The test harness lied to me, quietly

The platform CLI ignores per-test node pinning and runs everything against the default node. Same agent: 29 out of 39 through the CLI, 39 out of 39 pinned properly. I would have spent a day chasing regressions that were never there if I had trusted the convenient command.

Choosing a model with evidence

Once garbage output turned up, the question was whether to change model. I benchmarked five. 39 tests by 10 repeats, 390 runs each.

ModelPass rateGarbage turnsCharacteristic failure
Qwen 3.5 397B (live)96.9%1Keypad misuse on readback, 6 to 7 in 10
Gemini 2.5 Flash94.9 / 95.6%0Put an offered extra medication into the order, 2 in 10
Claude Haiku 4.592.6%0Spoke DOB as digits, skipped the result tool when an item was added
Gemini 3.5 Flash92.1%0Tool call with no spoken line
GPT-4.1 mini78.2%0Not a contender

The totals are the least interesting column. What matters is what each one does when it goes wrong. Gemini 2.5 Flash beat the incumbent on garbage output, but when a receptionist offered to add paracetamol, it put paracetamol into the order 2 times in 10. That is a wrong medication, which is the worst thing this system can do. A keypad quirk is embarrassing. A wrong medication is an incident.

So I kept the model with the noisy quirk and took the quirk out structurally.

The bench also taught me not to trust single-test rates. One judged scenario went 0 in 10 and then 10 in 10 across runs where the replies were near enough identical, so I now read any individual scenario as plus or minus 1 to 2 out of 10.

Data minimisation as code, not policy

The care plan the client exports holds far more than an ordering call needs. Personal profile, health status, visit schedule, family contacts, patient phone number.

The trigger runs an allowlist copy. Not a denylist strip, a copy of the fields I named: patient identity, the GP contact, the medication list. Everything else never leaves the machine the script runs on. Family and pharmacy contacts get dropped even though the data contract lets them through, because the call does not need them. If a care plan arrives in a shape the validator does not fully recognise, it throws. A care plan we half understand should fail loudly.

The consequence I like most is that when somebody on the call asks what conditions the patient has, the agent does not know. There is no instruction for it to resist, because the data was never sent.

Same thinking on disclosure. Rather than trusting that the prompt made the agent announce itself, every call writes an audit record containing the literal opening line and a computed flag for whether it disclosed automation. You can query what it actually said.

Where it actually stands

It has not rung a real GP surgery, and that is deliberate.

Verified

Complete end-to-end outbound calls on both the original stack and the current platform, to my own phones, with the result recorded and the agent hanging up by itself. Roughly 1.1 to 1.2 seconds to first audio per turn on those calls. Four synthetic receptionist personas, including a brusque one and a hard-of-hearing one, completed correctly in an agent-versus-agent harness with no phone calls involved at all.

Not verified

End-to-end latency on the current platform, because the test runs do not expose it and it needs real calls. And the job id the tool node sends in production, because the simulations mock every webhook, so the bridge has never seen a real request from the current build. That is the first thing I would check before a real order, and I would rather write it down here than find out on a live call.

Blocking, and not mine to solve alone

Signed data-protection paperwork. Workspace-level data isolation on the voice platform. Retention settings that are currently set for debugging rather than for production. And two questions only the client and their GPs can answer: does the surgery's repeat-ordering process accept a phone order placed by an agent acting for the care company, and how does that work for a controlled drug?

The engineering was the fast part. It usually is.

What I would carry to the next one

  1. If a rule has to hold, put it in the graph. Tool availability per node beats any instruction about when not to use a tool.
  2. Bench on failure modes, not pass rates. The model with the best score had the most embarrassing quirk, and the runner-up had the most dangerous one.
  3. Green once is not green. Non-determinism means a single passing run is weak evidence.
  4. Distrust the convenient test command. Check that the harness runs what you think it runs.
  5. Minimise structurally. The data you never send is the data you never have to defend.
  6. Make compliance observable. Record what the agent said, not what the prompt told it to say.

On the client

Details of the client are anonymised, and this write-up is published with their agreement.

Want one built like this?

If you have a phone job that is repetitive, high volume and low margin for error, this is the way I would approach it. Structure first, evals before confidence, and an honest list of what is still unproven.

Or email hello@maponos.co.uk. Pricing is published in full on the pricing page.