← Choice Health calendar-flows-and-tutorials.md raw .md

Calendar Flows & Tutorials — A Walkthrough

What this is. One place that explains, for a newcomer, everything the RIFF calendar does over the phone: the two scheduling engines and when to use each, the full set of calendar use cases, a tour of every flow that touches a calendar, and then a step-by-step walkthrough of the booking tutorial — state by state, turn by turn. Read top to bottom and you can build or debug a calendar flow.

On authorship: the use-case catalog below was written directly rather than generated by the Gemini CLI — that CLI now hard-fails with IneligibleTierError ("migrate to the Antigravity suite"), so it is no longer a usable content generator on this machine.


0. TL;DR


1. The two engines (and which to use)

Both engines end by calling the same write tool (schedule_event) against the same backend (cal-provider). They differ in how the caller arrives at a concrete time.

scheduling_v1 book_appointment_v1
How a time is chosen deterministic locked_choice — the server prefetches openings and the caller says/presses 1, 2, 3 the LLM asks "what day and time?", parses free speech, then the backend offers concrete slots
Robustness very high — offers are server-derived, nothing to mis-format high now — the old time_range fragility is fixed (P1)
Testability excellent — the whole offer→book loop is deterministic (133-test sim, B-CAL-1/2 fixed) the offer fetch is deterministic; the ask is an LLM turn
UX cost ⚠️ ~9–50s "B-302 dead air" while the server prefetches before the triage state can speak noneask_when speaks instantly, the fetch happens after
Used live by defined in austin_plumbing but bypassed (intake-only); apartment_scheduler triage (incomplete) tutorial_book_appointment, serene_mobile_massage, skyline_apartments

The decision that surprised everyone: skyline_apartments deliberately migrated off scheduling_v1 to book_appointment_v1 (flows/skyline_apartments.yaml:9-23) specifically to kill the dead air — and live-validated the result. So "make everything use the deterministic engine" is the wrong instinct: it would re-introduce the silence. The real endgame is #20 "offers are quotes" — keep book_appointment_v1's instant UX but have the caller pick a backend-vetted offer_id so the LLM never hand-formats a time at all. That merges the best of both.


2. What you can do with a calendar — the use cases

A calendar over voice is more than "book me a slot." The full catalog (the input Fable 5 is designing a v0 against — see scheduler-use-cases-and-capability-scope.md) groups into five families. Each use case is written as the caller's actual words plus the backend primitive it needs.

A · READ — availability & discovery

B · WRITE — booking & mutation

C · POLICY — constrain every read/write

Lead time & max-advance ("nothing sooner than 2 hours, nothing past 60 days"), duration-by-service ("a cleaning is 90 min"), capacity/overbooking, cancellation windows & fees, buffers/travel time, blackout dates & working hours.

D · IDENTITY — "whose calendar, which appointment"

Identify the caller's existing appointment by phone/name/confirmation code (prerequisite for reschedule/cancel), pick which staff/room, which tenant's calendar this call is for.

E · LIFECYCLE — confirmation read-back before any write (never fabricate),

reminders, waitlist/backfill ("text me if something opens Thursday"), and change notifications.

The one gap the flows expose that the catalog doesn't name

Two production flows — austin_plumbing and property_trouble_ticket — do something the catalog above silently assumes away: they never touch the calendar during the call. They collect constraints (possible + blocked days, or a work-order description), end at a needs_callback terminal, and a human matches and schedules later. This deferred / async scheduling request is one of the most common real intake shapes, and it deserves to be a first-class use case (or an explicit "this is not a calendar flow — hand it zero scheduling tools" note), because reaching for a booking engine here is a documented mistake: property_trouble_ticket once cloned in scheduling_v1 by accident and got stuck in its day/time picker (flows/property_trouble_ticket.yaml:29-34).


3. Walking through the calendar flows

Every flow in the repo that references a calendar, what it actually does, and its status. "Books?" means writes a real calendar event during the call.

Flow Engine Books? What it does
tutorial_book_appointment book_appointment_v1 The teaching flow. A fictional tutoring center. §4 walks it.
serene_mobile_massage book_appointment_v1 ✅ live Real mobile-massage booking; "you're booked" renders only after a real event_id (I-1). Live-validated 2026-06-28.
skyline_apartments book_appointment_v1 ✅ live Apartment viewing; the reference B-302 migration (locked_choice unit pick → instant ask_when, no dead air).
apartment_scheduler scheduling_v1 triage ⚠️ incomplete Has the triage, but the calendar-lookup step is a TODO (:14-16). This is the one clean upgrade candidate — adopt skyline's recipe.
apartment_viewing bespoke LLM tools Pre-modular; uses its own check_calendar_availability + create_viewing_booking. Upgradable, but needs rework, not a drop-in.
austin_plumbing scheduling_v1 (defined, bypassed) ❌ intake Collects possible/blocked days → provider match after hangup. System prompt forbids booking on the call.
property_trouble_ticket verify_caller_v1 ❌ ticket Files a maintenance work order → needs_callback. Deliberately removed scheduling_v1.
coffee, sweet_things_bakery none ❌ retail Pre-order flows. They forbid_action_until: [schedule, book, calendar] and are negative regression fixtures — CI fails if a scheduling tool ever leaks in.
module_calendar_scheduler scheduling_v1_choice_module ❌ demo Proof-of-concept for the triage primitive only.

Reading the table: three flows already book on the good engine; one (apartment_scheduler) is a documented upgrade; one (apartment_viewing) needs rework; the rest are intentionally not booking flows (two are guardrail fixtures). "Upgrade everything" is neither possible nor desirable — the right frame is converge the booking flows on book_appointment_v1 + #20, and keep the intakes off the engine on purpose.


4. Step-by-step: the booking tutorial

Flow: tutorial_book_appointment (open it at voice.html?flow=tutorial_book_appointment). It composes the book_appointment_v1 module, binding the module's abstract state IDs to concrete names. Here is the whole machine, in order.

 ask_when ──▶ fetch_offer ──▶ offer_slots ──▶ collect_contact ──▶ read_back ──▶ book_session ──▶ session_booked ✅
   (ask)        (act)            (ask)            (ask)            (confirm)        (act)          (terminal)
                   │                                                  │               │
                   └── booking_failed ─────────────────┐             (denied)      slot_busy──┐
                                                        ▼          back to           back to    │
 offer_slots/collect/read_back  ── escalation ──▶ handoff_to_human  collect_contact  fetch_offer│
                                                     (terminal)                                 │
                                                        ▲───────── booking_failed ──────────────┘

A concrete call, turn by turn:

# State Speech act What happens Example line
1 ask_when ask Collect preferred_date (ISO) + preferred_time. "Soonest possible" → next business day + "morning" by convention. One question at a time. "When works for you?" → caller: "Tuesday afternoon"
2 fetch_offer act Runtime must call propose_booking (a read-only availability probe). The backend writes the offer phrase into _scheduling_offered_speakable. The LLM says nothing here. (internal)
3 offer_slots ask Speaks the offered slots verbatim; caller picks one; the LLM narrows preferred_date/preferred_time to that exact slot and sets _scheduling_pick_confirmed. "Different time?" → re-fetch. "I have 1 PM, 2:30, or 4 PM Tuesday — which works?" → "the 2:30"
4 collect_contact ask Collect name, phone, address (student_name, student_phone, home_address in this flow). Pre-filled slots are skipped (Collect pattern). "I just need a name and phone for the booking. What's your name?"
5 read_back confirm Read the full booking back and gate on yes/no before any write. "Yes" → book; "change something" → back to collect_contact. "I have you down for 2026-05-05 at 14:30, name Alex, phone …, address …. Should I book it?"
6 book_session act Runtime must call schedule_event (the one write tool). Advance is gated on a real event_id. Same-slot race → back to fetch_offer; other error → escalate. (internal)
7 session_booked close ✅ Terminal. The only state allowed to say "you're booked." "You're booked for 2026-05-05 at 14:30. We'll text you a confirmation. Goodbye."
handoff_to_human handoff Failure terminal — reached on a terminal booking error or any escalation request. "Let me get someone on the line who can help."

The five invariants that make it safe

These are structural — enforced by the FSM shape and the runtime, not by asking the LLM nicely. This is the part worth internalizing.

The one improvement you should know about (P1 time_range)

Before the fix, in step 3/6 the LLM would sometimes emit a time the mapper rejected — a range ("12:00 to 15:00"), an ISO datetime ("2026-07-02T14:00:00-05:00"), or "2-4pm" — and the tool raised, which the flow treated as a terminal failure → handoff_to_human. That is the demo escalation. The mapper now normalizes those to the window start (and re-asks on genuine ambiguity) instead of raising. Because it lives in the shared slot_mapper, both engines and every calendar flow inherit it. See scheduler-demo-problem-statement.md.


5. Run it yourself

# start the voice UI server, then open:
#   http://127.0.0.1:8765/voice.html?flow=tutorial_book_appointment
# Say: "Tuesday afternoon" → pick an offered slot → give a name + phone →
#      say "yes" at the read-back → hear "you're booked".

Deterministic coverage (no live call needed):

pytest tests/test_calendar_scheduler_sim.py    # the scheduling_v1 offer→book loop (133 permutations)
pytest tests/providers/test_slot_mapper.py     # the time_range contract, incl. the P1 fix + tool boundary

6. Where to go next