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
- There are two booking engines.
book_appointment_v1(LLM asks for a time, backend offers concrete slots) is the production default — it has no "dead air."scheduling_v1(deterministic press-1/2/3 offers) is more testable but its triage makes the caller wait ~9–50s in silence, so live flows moved off it. - Booking works. The one fragility — the LLM emitting a time format the
slot-mapper rejected, which used to escalate the call — is fixed (the P1
time_rangefast-path). That fix lives in the shared mapper, so every calendar flow got it for free. - The backend already does more than any flow uses. One tool,
schedule_event, multiplexes book / check-availability / reschedule / cancel by intent. Today only book and check are wired into flows. - The tutorial to read is
tutorial_book_appointment— 8 states, 5 baked-in safety invariants. §4 walks it end to end.
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 | none — ask_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
- "When are you free Thursday afternoon?" → list free slots. (exists)
- "What's on the calendar Thursday / what's blocked?" → list events. (new)
- "Are you free at 2 PM Thursday?" → is-this-slot-open check. (new)
- "What's the next available appointment?" → soonest opening. (exists)
- "Any openings this week?" → availability over a range. (exists)
- "First opening ≥ 90 min, mornings only, not Mondays." → constrained search.
- "Read me my upcoming appointments." → lookup by caller identity → list. (new)
B · WRITE — booking & mutation
- "Book an hour at 2 PM Thursday." → create a 60-min event. (exists — the tutorial)
- "Move my Thursday appointment to Friday at 10." → reschedule. (backend-ready via
intent=modify; no flow yet) - "Cancel my Thursday appointment." → cancel. (backend-ready via
intent=cancel; no flow yet) - "Hold that slot while I check with my partner." → tentative hold, auto-expire.
- "Book me every Tuesday at 3 for a month." → recurring booking.
- "Add my partner to the appointment." → attendees.
- "Make my 2 PM 90 minutes instead of 60." → extend/shorten.
- "Block off Friday morning — I'm out." → a busy block, not a customer meeting.
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.
- I-1 · No fabricated bookings. The "you're booked" line lives only in
session_booked, and thebook_session → session_bookedtransition fires only onbooking_confirmed_with_event_id— which requires the backend to have returned a non-emptyevent_id. The adapter rewrites aDONE-without-id result toERROR. No id ⇒ no transition ⇒ no "booked." (defends B-181) - I-2 · Confirm before side-effect.
read_backisspeech_act: confirmand itsallowed_toolsexcludesschedule_event; the runtime rejects side-effect tools from confirm states. The write can only happen inbook_session(act), after the yes/no gate. - I-3 · No orphaned holds. Every tentative booking writes
_tentative_event_id; on session end, uncommitted tentatives are cancelled. - I-4 · Exactly one winner per slot. If two callers race the same slot, the
loser gets a conflict and
slot_busyroutes them back tofetch_offerfor a fresh offer — never a double-book. (defends B-144) - I-5 · No silent UTC.
business_tzis required at flow-load time when any state declaresschedule_event; a missing tz fails the load loudly. (defends B-105)
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
- Building-block tutorials (each atomic piece):
docs/tutorials/building-blocks/— start at01-announce.md, and10-book-appointment.mdis the module this page walks. - The problem statement (why the live path was fragile, and the #20 endgame):
scheduler-demo-problem-statement.md. - The full use-case catalog + "several ways" matrix (Fable 5's v0 input):
scheduler-use-cases-and-capability-scope.md. - The deterministic simulator report (how the offer→book loop is tested, and
the two bugs it caught):
calendar-scheduler-sim-report.md.