← 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 · Fish Audio (recorded prompts) · vitest

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

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 twenty to thirty 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?

CallerI need to reschedule my appointment, it's with Dr. Chen sometime next week.

SystemWhat's your first and last name?

CallerJason Stiles.

SystemAnd your date of birth?

CallerMarch fifth.

SystemAnd what year?

CallerNineteen eighty.

Systemnext week. Which day works for you?

CallerTuesday.

SystemYour appointment with Dr. Chen would move to Tuesday, September 22, for Jason Stiles, born March 5th, 1980. Shall I make that change?

CallerNo, Thursday with Dr. Alvarez.

SystemYour appointment with Dr. Alvarez would move to Thursday, September 24, for Jason Stiles, born March 5th, 1980. Shall I make that change?

CallerYes, and can I also ask about my bill?

SystemYour appointment is moved. Now, let's ask about billing. What's your member ID?

The first sentence carried three facts. The system kept all three, asked only for what it did not have, used the caller's own "next week" to narrow the day question, took a birthday without a year and asked for just the year, accepted a correction that changed two details at once, and queued a second task for after the first. Five intents, five doctors, six 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

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, replay923
Labeled corpusOne utterance in one call state, with the expected decision217 entries
ScenariosScripted multi-turn calls, including silence and keypad steps75 scripts
Answer cassetteThe real model's answers, recorded once and committed~500 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 ten cents.

The real model matches 209 of the 217 corpus labels and 74 of the 75 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. 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 + 20 to 32 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 4,000 input tokens
Recorded promptsFish Audio, 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. Fourteen pull requests in five 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, the code is public, and the video above is the current state. Everything specific to the clinic is content: the five intents and their descriptions, six slots, the prompt texts, 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.