← stiles.one

Case study

Jev IVR: A Phone Line You Can Talk To

A mixed-initiative voice IVR for a fictional clinic, built on TypeSafe's Jev model. Callers talk the way they would to a receptionist. The code stays deterministic. Every decision is a typed question with a probability, which means the whole thing can be tested like software.

Stack: TypeScript · Node · Twilio ConversationRelay · TypeSafe Jev · ElevenLabs voice · vitest

Now: Jev IVR has grown into DialogWright, an open-source framework for voice and chat agents built from what this project taught.

Status: Live on a demo number · Open source on GitHub · Built in seven days of commits, 22 pull requests

Updated September 24, 2026: the line now reads back the booking it finds and offers an open time, negotiates earlier and later, answers "what can you do?", and speaks every prompt in one voice. The video shows the September 22 version; the transcript below is today's.

Two calls, six minutes. The left pane is the conversation and the form filling; the right pane is every question sent to the model each turn, with the probabilities that came back.

The idea

For twenty years, phone systems made you choose. A phone tree is predictable but rigid. Anything conversational meant a speech-science team tuning grammars, and lately it means a large language model you cannot fully control. This project is the third option: the caller gets a conversation, and you keep a deterministic program you can read, test and diff.

The trick is what the model is asked to do. Jev is a System One model: it does not generate text or take actions. It answers small typed questions about a piece of state, and returns probabilities. The application sends the caller's transcript, the current call state and a batch of about thirty-five questions in one request, and gets back one typed answer per question in about 170 milliseconds. The code decides what to do with them.

A typical call, exactly as the line handles it today:

SystemThanks for calling Stiles Family Medical Practice. How can I help you today?

CallerHi, I need to reschedule my appointment with Dr. Alvarez.

SystemI'd be happy to help you reschedule your appointment. What's your first and last name?

CallerJason Stiles.

SystemAnd your date of birth?

CallerMarch fifth, nineteen eighty.

SystemWhat day works for you?

CallerTuesday.

SystemYour appointment with Dr. Alvarez is on Friday, October 2 at 1:00 PM. It would move to Tuesday, September 29 at 9:15 AM, for Jason Stiles, born March 5th, 1980. Shall I make that change?

CallerDo you have anything later that day?

SystemTuesday, September 29 at 1:00 PM. Does that work?

CallerYes.

SystemYour appointment is moved to Tuesday, September 29 at 1:00 PM. Goodbye.

The first sentence carried the task and the doctor, so the system acknowledged the task and asked only for what it did not have. It found the caller's existing booking and read it back with its time, never asked for a time itself, and offered an opening on the day the caller chose. When the caller asked for something later, it moved one opening along that day and, having read the whole summary once, said only the new day and time. Five tasks, eight doctors, five pieces of information. The domain is intentionally small; the interaction style is the point.

What makes it interesting

Questions are the program

Nothing in the code lists the ways a caller might phrase a request. The intent is a choice whose options are described, not enumerated:

OptionCriterion sent to the model
schedule_newWants to book a new appointment that does not exist yet
rescheduleWants to move an existing appointment to a different day
cancelWants to cancel an existing appointment
billingAsks about a bill, charge, payment, or insurance coverage

"I need to change my appointment," "can we do a different day," and "reschedule" all land on the same option because the model reads the criterion and the utterance and judges the match. Adding a phrasing costs nothing. Adding an intent costs one row.

The same pattern runs everything else on a turn. Gates ask whether the caller is addressing the system, whether the utterance is intelligible, whether they want a human, how frustrated they sound. Slots are choices over candidates the code computed: for the birth year, the code finds every number-like span in the transcript and asks which one is the year; for the name, every one-to-four word span and asks which is the caller's own name. The model selects; it never invents a value, so a wrong answer is always something the caller actually said. Confirmation asks whether the caller agreed, refused, or named the detail they want changed.

The code owns everything deterministic. The model owns only the judgments that need language.

Mixed initiative, in practice

Offering a time, not asking for one

Doctor's offices never let you pick an exact time, but they will work with you on a window. The line behaves the same way. Confirm, cancel and reschedule read back the booking it found, with its time. Schedule and reschedule never ask for a time; they offer an opening on the day the caller chose. A part of the day the caller volunteers, anywhere in the call, picks the first opening in that window ("next Thursday afternoon"), or the nearest one with "The closest I have to the afternoon is 12:30 PM." Nobody is ever asked for a window. At the offer, "earlier", "later" or "that time doesn't work" moves one opening along the day, and the ends say so: "That's the earliest opening that day."

The whole summary plays once. After that it is a time negotiation, so a re-read that only moves the day or time says just "Tuesday, September 29 at 1:00 PM. Does that work?" A change of doctor, name or birthday brings the full summary back.

Behind it is a two-call interface: find the caller's booking, and list a doctor's openings on a day. The demo implementation invents both from a hash of the caller's details, so the same caller hears the same appointment on every call, and a real deployment plugs in its scheduling system.

When the caller gets frustrated

Every turn also asks how frustrated the caller sounds, on three levels. The first time the answer is high, the system acknowledges it once, "I understand, let's get this sorted," and continues. The second time, it offers a transfer: "Would you like me to connect you to a person, or keep going?" The third time, or after declining, it transfers. The wording never claims a problem the system does not know about, which was the deliberate choice over any "sorry" phrasing.

This feature taught the sharpest lesson of the project. The first version of the frustration question defined the middle level as "repeats a request with irritation, or says come on or seriously." The real model followed that definition to the letter: "I already told you, cancel it" scored mild at 1.0 and high at 0, so a caller repeating themselves for the third time never escalated. The level descriptions are the program. Rewriting one sentence fixed it, and the fix showed up as a diff.

Testing a conversation like software

Because the core is pure and the model is only ever asked questions, almost all development ran against text, not audio.

LayerWhat it isSize
Unit testsSlots, gates, prompts, server, replay1,028
Labeled corpusOne utterance in one call state, with the expected decision241 entries
ScenariosScripted multi-turn calls, including silence and keypad steps89 scripts
Answer cassetteThe real model's answers, recorded once and committed~585 requests

A fixture stub answers the questions from the corpus labels, so the whole suite runs in about a second and produces a committed baseline of every decision. Any code change that alters a decision shows up as a diff. The cassette holds the real model's answers keyed by a hash of the call state and the question batch, so the suite also runs against real answers with no network and no cost. Re-recording the whole cassette after a wording change costs about thirteen cents. The file only ever grows, so each rewording leaves behind answers no run will ask for again; a small command replays the whole suite from the cassette and keeps only the answers it still asks for. The first trim took the file from 1,089 lines to 378 with every result unchanged.

The real model matches 229 of the 241 corpus labels and 88 of the 89 scenarios. The remaining differences are documented one by one: a hedge between two similarly named doctors, a lone surname said with no cue, an all-numeric spoken birthday, and a handful of corrections the model routes through a different gate to the same result. They are in the corpus, so the day a wording or model change fixes one, the diff says so.

A dashboard that shows the model's answers

The page in the video is served by the same Node process. Live, it follows the call over server-sent events; in replay, it steps through any past call from its trace, with an adjustable pause on the moment the question batch leaves so a presenter can narrate it. The point of the right pane is that every bug becomes one of two kinds: the probabilities were wrong for what was said, which is a question-wording problem, or they were right and the code did the wrong thing with them, which is a rule or threshold problem. The pane tells you which.

Not new, but rarely done

Mixed-initiative dialogue is an old idea. The research literature described it decades ago, and VoiceXML even shipped a form-filling algorithm for it. It stayed rare in production because of cost: every phrasing had to be anticipated, per intent, per slot, per prompt state, and a caller who volunteered two facts in one breath usually broke the flow. Teams responded with strict menus, and callers learned to say one word at a time.

What changed is that understanding became a fast, cheap call with a typed answer. The engineering effort moved from anticipating language to designing the dialogue policy, and the policy is ordinary code.

What went wrong on the way

Architecture

Caller ──▶ Twilio number ──▶ ConversationRelay (speech to text, text to speech)
                                    │ WebSocket: transcripts, keypad, interrupts
                                    ▼
                          Node server (one process, behind ngrok for the demo)
                            adapter: sessions, no-input timer, reconnects, dashboard
                            core:    plan → ask → resolve   (pure, no I/O)
                                    │ one request per turn: state + 30 to 35 questions
                                    ▼
                                  Jev  ──▶ typed answers with probabilities

Tech stack

AreaChoice
Language and runtimeTypeScript, strict ESM, Node
TelephonyTwilio ConversationRelay over a WebSocket; Deepgram for speech recognition, ElevenLabs for text-to-speech, both configured through Twilio
Language understandingTypeSafe Jev, one batched request per turn, about 5,000 input tokens, over a kept-alive connection
VoiceElevenLabs through ConversationRelay; optional Fish Audio clips generated from a manifest with a staleness check
Testingvitest; a fixture stub over labeled corpus entries; an append-only answer cassette; frame-log replay of real calls
ObservabilityPer-turn JSONL trace records, a per-call frame log, the live and replay dashboard
Toolingpnpm, tsx, ngrok

How it was built

Every feature went through the same loop: a short design conversation, a written spec, a plan with the code in it, then implementation by a fleet of Claude Code subagents with a fresh agent per task, one writer in the tree at a time and read-only reviewers in parallel. Each task got a spec review and a code quality review before the next started, and every plan ends with a record of where the implementation deviated from it and why. Twenty-two pull requests in seven days, each with its own review trail.

The reviews did real work. One found that the dashboard's redaction masked the caller's number on the setup frame but let it through on the webhook fields and the forwarded-from number; a scan of every real trace on disk after the fix found nothing. Another found that the confirmation group on the dashboard appeared one turn late. A third found the third frustration rung was silently dropped when an earlier gate had already decided the turn. None of those would have shown up in the demo call, and all of them would have shown up in production.

Status and what's next

The line is live on a demo number and the code is public. Everything specific to the clinic is content: the five intents and their descriptions, five slots, the prompt texts, the appointment directory, the corpus. The mechanisms are general: the gate ladder, the form loop with per-slot retry ladders and partial narrowing, the summary and correction logic, the task queue, the frustration rungs, the recorded-answer harness. The next step is to pull those into a framework, so the second line like this one takes days rather than weeks, and the third takes a domain description and a set of labeled utterances.

Conversational on the outside, deterministic on the inside, and testable like any other code.