Testing Voice Agents Without Touching a Real EHR

April 21, 2026 (6mo ago)

For a while, testing a change to a voice agent meant calling it. Someone on the team would ring the number, pretend to be a patient, and see what happened. That approach has three problems in healthcare specifically:

  1. It writes to real systems. A test booking is still a booking in the clinic’s EHR, on a real provider’s schedule.
  2. It doesn’t scale. An agent might handle twenty distinct scenarios. Nobody phones twenty times per prompt change.
  3. It isn’t comparable. “It felt better” is not a result you can put next to the previous version.

So we built a framework that tests agents the way we’d test any other service: automatically, repeatably, and without side effects.

A scenario library drives a simulated caller that talks to the agent under test. The agent's tool calls go to a fake EHR instead of the real one. Transcripts and tool logs feed deterministic checks and an LLM judge, which produce a report comparing versions.
The agent under test is the real production configuration. Only the edge changes: the caller is simulated and the EHR is fake.

Principle: test the real agent, fake only the edges

The most important decision was what not to fake. The agent under test runs the exact configuration that would be published: same prompt, same tools, same function gateway, same harness version. If we tested a simplified copy, we’d be testing the copy.

What we replace are the two edges:

  • The caller, with a simulated patient.
  • The EHR, with a fake that behaves like one.

The simulated caller

Each scenario is a small spec: who the caller is, what they want, what they know, and how they behave.

scenarios/new-patient-booking.yaml
id: new_patient_booking_no_insurance_card
persona:
  name: "Dana Cole"          # synthetic, never a real patient
  dob: "1984-02-11"
  style: "polite, gives information only when asked, unsure of insurance details"
goal: "Book a new-patient appointment next week, mornings only"
facts:
  insurance: "doesn't have the card with them"
expected:
  tools_called: [search_patient, register_new_patient, find_slots, book_appointment]
  outcome: appointment_booked
  must_not: ["ask for full SSN", "book outside mornings"]

An LLM plays the caller from that spec, in text for speed or through synthetic voice when we specifically want to test speech handling. The persona’s style matters more than you’d expect: agents that pass with a cooperative caller often fail with one who answers a different question than the one asked.

The fake EHR

The fake EHR implements the same adapter interface as the real integrations: patient search, registration, provider resolution, slots, booking, filing documents. It’s seeded per scenario with fixtures (existing patients, a provider schedule, a slot that will be taken mid-call) and records every call made to it.

fake-ehr.ts
export class FakeEhr implements EhrAdapter {
  readonly calls: EhrCall[] = [];
 
  constructor(private fixtures: EhrFixtures) {}
 
  async searchPatient(q: PatientQuery) {
    this.calls.push({ op: "searchPatient", args: q });
    return this.fixtures.patients.filter((p) => matches(p, q));
  }
 
  async book(req: BookingRequest) {
    this.calls.push({ op: "book", args: req });
    const slot = this.fixtures.slots.find((s) => s.start === req.slotStart);
    if (!slot || slot.taken) throw new EhrError("SLOT_UNAVAILABLE");
    slot.taken = true;
    return { id: `appt_${this.calls.length}`, ...req };
  }
  // …the rest of the adapter
}

Because it’s injected at the adapter boundary, the agent and the function gateway can’t tell the difference. That’s also what guarantees eval runs never reach a real EHR: in the eval environment, the real adapters aren’t registered at all.

Scoring: deterministic first, judge second

Each run produces a transcript and a tool log. We score them in two passes.

Deterministic checks come first, because they’re cheap, exact and don’t drift:

  • Did the agent call the expected tools, with valid arguments?
  • What is the final state of the fake EHR? Is there exactly one booking, at a time that fits the constraint?
  • If the call was transferred, was it to the right destination with a hand-off?
  • Did the transcript contain anything on the must_not list?

An LLM judge then scores what rules can’t: was the agent accurate, did it invent information, was the tone right for a patient. The judge gets a rubric and the transcript, and returns scores with reasons. We treat judge scores as a signal, not a gate on their own, and spot-check them regularly against human review.

Comparing versions

Every scenario runs several times per agent version, because LLM behaviour varies run to run and a single pass proves little. The report shows pass rate per scenario and the difference from the currently published version. A regression on a scenario that previously passed reliably blocks publishing until someone looks at it.

This changed how we work on prompts. Instead of “I think this wording is better”, the conversation is “this change fixed the insurance-card scenario and broke the reschedule one; here are the transcripts”.

What I’d tell someone building this

  • Fake the EHR at the adapter interface, not with HTTP mocks. You want a fake that holds state, so you can assert on outcomes rather than calls.
  • Write scenarios from real failure modes. Every production issue becomes a scenario. The library gets more valuable every month.
  • Run each scenario more than once. Report rates, not single results.
  • Keep synthetic data obviously synthetic. No real patient details, ever, even “just for a test”.

Evals didn’t make the agents perfect. They made changes safe to ship, which is what lets you keep improving them.