← All calls·

FSM State Guide

Canonical states + phases + deterministic actions from voice-booking-flow.ts, merged with what’s actually been observed across the last 500 calls. Use this to know which states a call could visit, and which the system has seen in practice.

States · 28 canonical · 14 observed

Router + identity (orthogonal to flow branches)

booking_discovery

canonicalobserved 340×

Router state. Identifies caller intent and dispatches to a super-state branch (appointment_setting / appointment_adjustment / qa / handoff).

Enters when:
Call starts after greeting, OR a sub-flow returns to the router (e.g. qa side-loop completes).
Exits when:
Routes into appointment_setting (new booking), appointment_adjustment (touch existing), qa (side question), or handoff (callback/transfer/escalation).
Allowed tools:
BASE_TOOLS minus CreatePatient. Read-only lookups while still gathering intent.
Observed phases (10)

booking_existing_patient

canonicalobserved 24×

Caller is an existing patient (identity orthogonal to flow). Available within any flow branch.

Enters when:
GetPatientDetails returned a match for the caller.
Exits when:
Identity persists for the call lifetime. Flow dimension transitions independently.
Allowed tools:
BASE_TOOLS — patient identity is known, no CreatePatient needed.
Observed phases (2)

collecting_new_patient

canonicalobserved 6×

Caller isn’t in the patient database. Collecting first_name + last_name + DOB before any booking write. Identity dimension.

Enters when:
GetPatientDetails returned no match (or shared-phone identity flow split out a new caller).
Exits when:
CreatePatient succeeds → identity becomes booking_existing_patient. Caller refuses identity → closing.
Allowed tools:
BASE_TOOLS plus CreatePatient. Some sub-phases lock tools (still gathering name/DOB).
Observed phases (1)

rescheduling

canonicalnever observed

Caller is moving an existing booking to a different time. task.kind === reschedule on the FSM task field. Distinct top-level lane on the dashboard so reschedule flows do not get absorbed into the broad booking_discovery state.

Enters when:
task.kind === reschedule (caller said reschedule terms, asked for earlier/later, or had a single current booking and confirmed they want to change it). Promoted from booking_discovery automatically via promoteStateForTaskKind.
Exits when:
Replacement slot confirmed → booking_existing_patient → CreateAppointment. Caller pivots to cancel → cancelling. Caller abandons → closing.
Allowed tools:
Varies per sub-phase. Typically CheckAvailability with existing booking IDs (appointment_type, business, practitioner) reused, plus the lookups. CancelAppt is held back until a replacement is confirmed.

cancelling

canonicalobserved 10×

Caller is cancelling an existing booking. task.kind === cancel. Covers confirm_cancel (asking caller to confirm) and commit (firing CancelAppt). Distinct lane so cancellation directives — which are stricter than booking discovery — surface as their own state.

Enters when:
task.kind === cancel (caller used explicit cancel terms, or confirmed cancellation of a loaded current booking). Promoted from booking_discovery automatically.
Exits when:
CancelAppt succeeds → closing or post_booking_closing. Caller pivots to reschedule → rescheduling. Caller backs out → booking_discovery.
Allowed tools:
confirm_cancel phase has no tools (gathering yes/no). commit phase opens CancelAppt / CancelAppointments / EscalateToHuman.
Observed phases (2)

answering_query

canonicalobserved 3×

Read-only flow where the caller asked about their existing bookings ("what do I have coming up?") or pricing. task.kind === query. Bookings are prefetched at init so this state ANSWERS from session data rather than re-loading.

Enters when:
task.kind === query (caller asked about upcoming appointments, pricing, or returned-call intent needs clarifying). Promoted from booking_discovery automatically.
Exits when:
Caller picks an action → booking_discovery / rescheduling / cancelling. Caller closes → closing.
Allowed tools:
GetCurrentlyBookedAppts (for refresh edge cases only — data is pre-loaded), GetPatientDetails, EscalateToHuman, TransferCall. No write tools, no CheckAvailability.
Observed phases (1)

handoff_prep

canonicalobserved 2×

Sales transfer, callback collection, or human escalation. task.kind === handoff AND NOT an identity-verification rule (those go to verifying_identity).

Enters when:
task.kind === handoff (returned-call intent clarification, sales transfer requested, callback contact details being gathered). Promoted from booking_discovery automatically.
Exits when:
EscalateToHuman / TransferCall fires → closing. Caller withdraws → booking_discovery.
Allowed tools:
Varies per sub-phase. Sales transfer / callback phases open TransferCall / EscalateToHuman.
Observed phases (2)

verifying_identity

canonicalnever observed

Shared-phone identity verification — checking who is actually on the line when the matched patient may not be the caller. Distinct from handoff_prep so the dashboard separates "we are checking who the caller actually is" from "we are transferring them out".

Enters when:
identity_verification.status set (collecting_name / collecting_dob / handoff_required) OR callerIndicatesSharedOrWrongPhone fires on the caller message. State is set explicitly by the FSM rules (not via promoteStateForTaskKind) so it bypasses the handoff_prep promotion.
Exits when:
Identity confirmed → booking_discovery or whatever the caller asked for. Identity unsafe → EscalateToHuman → closing.
Allowed tools:
collecting_name / collecting_dob phases have no tools (gathering caller answers). handoff_required phase opens EscalateToHuman.

init

canonicalnever observed

WebSocket opened and prefetch (patient lookup, current bookings, recent thread, preferences) is in flight. No agent utterance yet.

Enters when:
Twilio ConversationRelay opens the WebSocket.
Exits when:
Prefetch completes → greeting.
Allowed tools:
No tools — the route layer is fetching DB state in parallel.

greeting

canonicalnever observed

Deterministic agent greeting plays once prefetch resolves and before the caller speaks for the first time.

Enters when:
Prefetch resolves; route layer emits the greeting TTS.
Exits when:
Caller responds → first buildVoiceBookingFlow pass routes to booking_discovery / rescheduling / cancelling / answering_query / verifying_identity.
Allowed tools:
No tools — single deterministic utterance.

discovering_service

canonicalobserved 21×

Multi-service tenant where the service catalogue has not yet been read in this conversation. Agent must call GetServicesAndPricing before CheckAvailability becomes available, so the appointment_type_id passed to CheckAvailability is grounded in real services and not hallucinated from the prompt context.

Enters when:
voiceBookingSession.services_discovered is NOT true AND tenant has ≥2 distinct appointmentTypeIds across practitioner_configs AND the broad booking_discovery fallback would otherwise fire.
Exits when:
GetServicesAndPricing or GetAppointmentTypes returns successfully → services_discovered=true → next turn re-enters booking_discovery with CheckAvailability available. Caller hangs up → closing.
Allowed tools:
GetServicesAndPricing, GetAppointmentTypes, GetPatientDetails, ConvertTimezone, EscalateToHuman, TransferCall. NO CheckAvailability, NO write tools. Surfaced from CA22eaf8a08eb69e25734c0d51b7bb6c83 — LLM hallucinated appointment_type_id=50014 and the wrong service was offered.
Observed phases (3)
Super-state · appointment_setting·5 leafs, 69× total observed

appointment_setting

super-statenot directly observed (container only)

NEW booking flow. Caller wants to book an appointment they don’t currently have.

Enters when:
booking_discovery router decides intent is "book new". Re-entered from appointment_adjustment if the caller pivots from a reschedule into picking a brand-new slot.
Exits when:
Booking written → post_booking_closing. Caller abandons → booking_discovery or closing.
Tool set:
Tool set narrows per sub-state.

resolving_service

canonicalnever observed

Determining which service / appointment type the caller wants. Asks visit reason, narrows ambiguous phrasing.

Enters when:
appointment_setting entered with no service yet selected.
Exits when:
Service identified → resolving_location (if not already set) or offering_slots (if location + service both set).
Allowed tools:
GetServicesAndPricing, GetAppointmentTypes for clarification. CheckAvailability not yet — service must be set first.

resolving_location

canonicalnever observed

Determining which clinic location the caller wants when the clinic has multiple sites.

Enters when:
Service identified but no location set, OR caller mentioned an alternate location term.
Exits when:
Location identified → offering_slots (via CheckAvailability).
Allowed tools:
BASE_TOOLS. May call CheckAvailability once location is in hand.

offering_slots

canonicalobserved 23×

CheckAvailability returned candidate slots and the agent has read them back. The caller has not yet picked one or confirmed. Tools restricted to lookups so the agent can answer questions about the offered service without re-calling CheckAvailability or writing.

Enters when:
Prior assistant turn offered two concrete slots AND session has fresh candidate_slots AND the caller has NOT yet picked one or confirmed.
Exits when:
Caller picks a slot → awaiting_final_confirmation. Caller asks for different times/service → resolving_service / resolving_location. Caller asks a question about the offered service → stay in offering_slots; LLM answers via lookups. Caller hangs up → closing.
Allowed tools:
GetServicesAndPricing, GetAppointmentTypes, EscalateToHuman, TransferCall. NO CheckAvailability (slots already loaded). NO write tools.
Observed phases (1)

awaiting_final_confirmation

canonicalobserved 41×

Final slot has been read back to the caller. Waiting for an explicit yes/no before committing the write.

Enters when:
Caller picked a slot from offering_slots.
Exits when:
Caller confirms → CreateAppointment fires, → post_booking_closing. Caller declines → offering_slots (re-pick) or resolving_service (re-discover).
Allowed tools:
No tools allowed. Agent must wait for a verbal yes/no.
Observed phases (1)

post_booking_closing

canonicalobserved 5×

Booking has been written. Post-booking actions allowed (confirmation SMS, payment link, telehealth link).

Enters when:
CreateAppointment / RescheduleAppointment completed successfully.
Exits when:
Caller ends call → closing (synthetic). Caller starts a new booking → booking_discovery.
Allowed tools:
SendTextMessage, GetTelehealthLink, CreatePaymentLink, CheckPaymentStatus. No booking writes.
Observed phases (1)
Super-state · appointment_adjustment·4 leafs, 0× total observed

appointment_adjustment

super-statenot directly observed (container only)

EXISTING booking touch. Caller wants to cancel, reschedule, or query their current bookings.

Enters when:
booking_discovery router decides intent is "modify existing".
Exits when:
Cancel commits → post_booking_closing (or closing). Reschedule → appointment_setting (to pick a new slot). Caller abandons → closing.
Tool set:
Tool set narrows per sub-state.

loading_bookings

canonicalnever observed

LEGACY: Fetching the caller's current upcoming bookings via GetCurrentlyBookedAppts. Superseded by answering_query (top-level) — bookings are now prefetched at init.

Enters when:
Legacy emissions only. New code emits answering_query at the top level.
Exits when:
One booking returned → confirming_cancel (or rescheduling). Multiple → picking_target. None → closing or qa.
Allowed tools:
GetCurrentlyBookedAppts is the required prerequisite — guards on CancelAppt/CancelAppointments enforce this.

picking_target

canonicalnever observed

Caller has multiple bookings — agent asks which one the action applies to.

Enters when:
loading_bookings returned >1 booking.
Exits when:
Target picked → confirming_cancel or rescheduling.
Allowed tools:
No tools. Agent reads the bookings back and waits for a choice.

confirming_cancel

canonicalnever observed

Reading the target booking back to the caller and asking for an explicit yes/no before the cancellation write.

Enters when:
Cancel intent + target picked (or single booking).
Exits when:
Caller confirms → CancelAppt/CancelAppointments fires → post_booking_closing or closing. Caller declines → booking_discovery.
Allowed tools:
CancelAppt, CancelAppointments, EscalateToHuman. Guards require explicit confirmation.

rescheduling

canonicalnever observed

Caller wants to move an existing booking. Bridges into appointment_setting to pick a new slot.

Enters when:
Reschedule intent + target picked.
Exits when:
New slot chosen → appointment_setting / offering_slots. Booking written → post_booking_closing.
Allowed tools:
Inherits BASE_TOOLS for slot lookup; RescheduleAppointment write gated by confirmation in appointment_setting/awaiting_final_confirmation.
Super-state · qa·3 leafs, 0× total observed

qa

super-statenot directly observed (container only)

Side-loop for questions that don’t advance a booking. Returns to the caller’s prior state after answering.

Enters when:
booking_discovery router (or any other state) detects a question that doesn’t fit the current flow (e.g. "what’s a laser one foot appointment?", "how much does it cost?", "what are your hours?").
Exits when:
Question answered → return to prior state (resume the flow that was in progress).
Tool set:
GetServicesAndPricing, GetAppointmentTypes. Read-only.

pricing

canonicalnever observed

Caller asked about cost / fee / health-fund coverage. Answers via GetServicesAndPricing.

Enters when:
qa router detects pricing-related question.
Exits when:
Answer delivered → return to prior state.
Allowed tools:
GetServicesAndPricing.

service_info

canonicalnever observed

Caller asked what a specific service / appointment type is. Answers via GetAppointmentTypes + GetServicesAndPricing.

Enters when:
qa router detects service-info question (e.g. "what's a laser one foot appointment?").
Exits when:
Answer delivered → return to prior state. THIS IS THE GAP CHECKAVAILABILITY-STALL CALLS FALL INTO.
Allowed tools:
GetAppointmentTypes, GetServicesAndPricing.

hours

canonicalnever observed

Caller asked about clinic hours / availability windows / opening days. Answers from clinic profile.

Enters when:
qa router detects an hours question.
Exits when:
Answer delivered → return to prior state.
Allowed tools:
Static clinic profile data, no tool call needed in most cases.
Super-state · handoff·1 leaf, 28× total observed

handoff

super-statenot directly observed (container only)

Branch for handing the caller off — callback request, live transfer, or escalation to human.

Enters when:
booking_discovery router detects handoff intent (emergency, sales, "talk to someone", repeated failures).
Exits when:
Handoff completed → closing.
Tool set:
EscalateToHuman, TransferCall.

closing

canonicalobserved 28×

Call wrapping up without a booking write — caller hung up, refused, redirected, transferred, or escalated.

Enters when:
End-reason rule fires (emergency, sales, handoff, caller indicates done), OR caller hangs up from any flow state.
Exits when:
Synthetic ended state follows when the websocket closes.
Allowed tools:
No tools. One short polite closing sentence.
Observed phases (1)

booking_in_progress

undocumented · observed 38×

Seen in production calls but not present in the canonical FSM catalog. Either add it to src/lib/fsm-catalog.ts or check whethervoice-booking-flow.ts defines a new state.

awaiting_service_lookup

undocumented · observed 31×

Seen in production calls but not present in the canonical FSM catalog. Either add it to src/lib/fsm-catalog.ts or check whethervoice-booking-flow.ts defines a new state.

closing_after_booking

undocumented · observed 1×

Seen in production calls but not present in the canonical FSM catalog. Either add it to src/lib/fsm-catalog.ts or check whethervoice-booking-flow.ts defines a new state.

Phases · 13

Sub-task progress markers within a state. A phase narrows down what step of identify-intent / elicit-service / present-slots / commit / wrap-up the agent is on.

identify_intent
Figure out what the caller wants — book, cancel, reschedule, query, or handoff.
load_existing_bookings
Fetch the caller’s current/upcoming bookings so we can act on the right one.
select_target
Caller has more than one booking — pick which appointment they mean.
confirm_intent
Confirm we understood what the caller wants before doing anything destructive.
confirm_cancel
Explicit confirm before a cancellation write fires.
elicit_service
Determine which service / appointment type the caller wants.
elicit_time_window
Determine the caller’s preferred date/time window.
check_availability
Looking up available slots via CheckAvailability.
present_slots
Reading the available times back to the caller.
await_slot_choice
Caller is picking which proposed slot they want.
await_final_confirmation
Slot picked — waiting for the caller’s yes/no before commit.
commit
Booking write in flight (CreateAppointment / RescheduleAppointment / etc).
wrap_up
Booking written — handling optional follow-ups (SMS, payment link, telehealth).

Deterministic actions · 36

What the FSM tells the agent to do, by name. Surfaced inline on each state card under FSM internals → action:.

final_slot_confirmation
Read the final picked slot back and ask for explicit confirmation.
reconfirm_selected_slot
Caller wavered — re-read the slot they chose for one more yes/no.
propose_initial_slot
Offer the first available slot to the caller.
book_confirmed_selected_slot
Caller said yes — fire the booking write.
reschedule_confirmed_selected_slot
Caller confirmed a reschedule target — fire the reschedule write.
clarify_cancellation_target
Caller wants to cancel but it’s ambiguous which booking — ask which.
confirm_current_booking_cancellation
Read back the booking being cancelled and ask for yes/no.
confirm_all_current_bookings_cancellation
Caller said "cancel everything" — confirm all-at-once cancel.
cancel_confirmed_current_booking
Caller confirmed cancel — fire the cancel write.
cancel_confirmed_all_current_bookings
Caller confirmed cancel-all — fire batch cancels.
confirm_just_booked_cancellation
Caller wants to cancel a booking we just made this call — confirm.
answer_current_booking_status
Read the caller’s current upcoming bookings back to them.
red_flag_emergency_redirect
Caller described a medical emergency — redirect to 000 / triage.
hesitation_recovery
Caller went silent or unclear — prompt them gently to continue.
capture_tentative_booking_request
Caller’s request is vague — capture what we have and ask follow-ups.
ask_callback_message
Caller wants a callback — ask what to relay to the clinic.
ask_callback_contact_details
Confirm phone / name for the callback request.
confirm_callback_contact_details
Read callback contact details back to the caller for confirmation.
human_callback_requested
Caller wants a human to call them back — request logged.
ask_shared_phone_full_name
Multiple patients share this phone — ask the caller’s full name to identify which one.
ask_shared_phone_dob
Disambiguate shared-phone callers by asking DOB.
handoff_shared_phone_identity
Shared phone identification complete — handing off to the booking flow as the identified patient.
ask_how_can_help
Open question to figure out caller’s intent.
ask_problem_duration
Ask how long the medical issue has been going on (helps service routing).
ask_visit_reason
Ask the caller what their visit is about / what concern they need help with.
ask_continue_visit_reason
Caller gave a partial visit reason — ask for more detail.
clarify_returned_call_intent
Caller is responding to a callback — figure out why we called them.
clarify_returned_sales_call_intent
Sales-callback variant of clarify_returned_call_intent.
sales_callback_requested
Caller wants sales to call back — logged + acknowledged.
clarify_two_slot_choice
Two slots offered — caller’s answer is ambiguous, ask which one.
ask_alternate_location_preference
Caller mentioned a non-default location — ask if that’s where they want to be seen.
clarify_existing_booking_intent
Caller has an existing booking — clarify whether they want to cancel, reschedule, or just confirm.
ask_first_name
New-patient flow — collect first name.
ask_last_name
New-patient flow — collect last name.
confirm_sales_transfer
About to transfer to sales — confirm with caller.
transfer_confirmed
Sales transfer confirmed — TransferCall imminent.

Tools · 17 canonical

Every tool the agent can call, grouped by category. The “Gated by” line shows which states unlock each tool and the guards that must be satisfied.

Read
GetPatientDetails
Look up the caller in the patient database by phone.
Gated by: BASE_TOOLS — always allowed in discovery + booking + cancellation states.
CheckAvailability
Look up open slots for a service / practitioner / location / date range.
Gated by: BASE_TOOLS — allowed in discovery + booking states. Removed once in awaiting_final_confirmation.
GetServicesAndPricing
Look up the clinic’s service list and price ranges.
Gated by: BASE_TOOLS — always allowed.
GetAppointmentTypes
List the clinic’s configured appointment types.
Gated by: BASE_TOOLS — always allowed.
ConvertTimezone
Translate a time between the caller’s timezone and the clinic timezone.
Gated by: BASE_TOOLS — rarely used in practice.
AddToWaitlist
Add the caller to a service waitlist when no slots are open.
Gated by: BASE_TOOLS — rarely used in practice.
GetCurrentlyBookedAppts
Load the caller’s current/upcoming bookings (prerequisite to cancel/reschedule).
Gated by: Cancellation flow — required before CancelAppt / CancelAppointments will pass guards.
Write (booking)
CreatePatient
Create a new patient record (first_name, last_name, DOB, phone).
Gated by: collecting_new_patient + awaiting_final_confirmation (no patient). Guards: caller must have provided name+DOB.
CreateAppointment
Write the booking to PracSuite/Cliniko. Final step of the booking flow.
Gated by: awaiting_final_confirmation only. Guards: caller must have explicitly confirmed the exact slot, AND the slot must have come from a CheckAvailability result. Premature calls return "Booking blocked".
Cancel
CancelAppt
Cancel a single existing appointment.
Gated by: Cancellation flow. Guards: appointment_id must have been returned by GetCurrentlyBookedAppts AND caller must have explicitly confirmed.
CancelAppointments
Batch-cancel multiple existing appointments (caller said "cancel everything").
Gated by: Cancellation flow. Guards: same as CancelAppt but for the full list.
Escalation / transfer
EscalateToHuman
Hand off to a human via callback request (logged for clinic to action later).
Gated by: Available in most states as a safety valve.
TransferCall
Live-transfer the call to a sales line or human number.
Gated by: Sales transfer flow only.
Post-booking
SendTextMessage
Send an SMS confirmation / link to the caller.
Gated by: post_booking_closing + awaiting_final_confirmation (with patient).
GetTelehealthLink
Fetch a telehealth meeting link for a booking.
Gated by: post_booking_closing.
CreatePaymentLink
Generate a Stripe payment link for the booking.
Gated by: post_booking_closing.
CheckPaymentStatus
Check whether a payment link has been paid.
Gated by: post_booking_closing.

Triggers observed · 16

FSM router rules that have fired across the last 500 calls. Each row shows the rule name and the evidence that triggered it.

RouteEvidenceCountLast seen
unknown/lowno_rule_matched1121/6/26, 3:03 pm
booking_request/mediumbooking_terms3029/5/26, 12:41 pm
ambiguous/mediumunresolved_two_slot_reply1629/5/26, 2:55 pm
alternate_location/highlocation_prompt1328/5/26, 12:08 pm
alternate_location/highlocation_terms111/6/26, 2:25 pm
confirmation/highfinal_confirmation_prompt929/5/26, 12:44 pm
cancel_request/highcancel_terms729/5/26, 2:56 pm
slot_choice/hightwo_slot_choice_prompt,slot_choice_terms728/5/26, 1:41 pm
availability_request/mediumavailability_terms629/5/26, 2:54 pm
price_question/highpricing_terms428/5/26, 1:42 pm
alternate_time/hightime_prompt228/5/26, 12:26 pm
callback_request/highcallback_terms129/5/26, 9:07 am
reschedule_request/highreschedule_terms128/5/26, 9:00 am
alternate_time/hightime_terms128/5/26, 12:54 am
practitioner_seniority_question/highseniority_terms127/5/26, 3:49 pm
closing/highcaller_closing127/5/26, 3:50 pm

Tools observed · 12

Guard errors are the FSM rejecting an agent call (e.g. agent tried to write before the caller confirmed). They indicate model eagerness, not system failure. PMS errors are real upstream failures from Cliniko / PracSuite. Other is timeouts, network, unknown.

ToolCallsOKGuardPMSOtherErr %Last seen
CheckAvailability545534112%29/5/26, 2:55 pm
CreateAppointment240177582326%29/5/26, 2:55 pm
GetCurrentlyBookedAppts14514232%28/5/26, 11:25 am
EscalateToHuman868511%29/5/26, 9:07 am
CancelAppt81631822%29/5/26, 2:56 pm
GetPatientDetails696927/5/26, 5:34 pm
CreatePatient454324%26/5/26, 10:45 am
GetAppointmentTypes131329/5/26, 2:52 pm
SendTextMessage121218/5/26, 4:12 pm
GetServicesAndPricing10101/6/26, 2:25 pm
CancelAppointments73457%22/5/26, 1:50 pm
TransferCall6626/5/26, 1:07 pm