Front Desk Journeys
A customer reaches the front desk by phone, by the website lead form, or by text. Each touchpoint ends in a disposition, and the disposition decides the follow-up. This page draws that loop once, then cuts it into six journeys. Each journey has one owner, one tool set, and one headline metric, so a failing tool can be pinned to the journey it broke.
3 touchpoints 48 states in the enum · 18 reachable from an inbound call 1 disposition row per client 19 MCP tools on voice · 12 on SMS
Vocabulary
The service provider: one location. entity_id in every table below.
The end customer. client_id, a row in clients.clients. A conversation is keyed by the pair (client_id, entity_id).
Where the customer first appears: an inbound call, the website lead form, or an inbound SMS.
One chat.conversations row per EC per SP, with chat.members (types EC, ENTITY, Assistant) and chat.messages. SMS turns and post-call summaries land here.
One chatbot.transcript_mapping row per Retell call, keyed by call_id, with client_id, entity_id, status, and metadata.direction. This is the table the brief calls "transport mappings".
The conversation's state, written to chatbot.conversation_states.state. One row per client. Every change is copied to chatbot.conversation_states_logs by trigger.
A scheduled outbound touch, one chatbot.followups row, created from the chatbot.state_rules row that matches the disposition. Channel is SMS or PHONE.
A path from an entry event to a disposition write. Six are defined below. Time is not part of the definition; only the order of events is.
One run of a journey for one client. A client can run the same journey many times. Each run is scored on its own.
The holistic map
Read it top to bottom. A touchpoint hands the customer to an agent or to the website booking flow. The agent's turn ends in a classifier, and the classifier writes the disposition. From there a conversation ends three ways: booked, handed to a human, or sitting in a state with no rule. Every other state has a rule, and the rule schedules a follow-up by SMS or by outbound call. Both return to the same row, so the map is a loop, not a funnel. The pills travelling each edge are the states themselves.
Remark. The disposition row is shared by every journey. An inbound call, a website drop-off, and a follow-up SMS all write to the same conversation_states.state, and the follow-up engine cannot tell which one wrote it. The attribution section handles that.
Touchpoints
| Touchpoint | Entry point | Handler | Disposition writer |
|---|---|---|---|
| Inbound call | POST /frontdesk/webhooks/retell/incoming-call, then /call-events | FrontdeskRetellWebhookService → Retell inbound agent, 19 MCP tools | StateCalculatorService.calculateInboundCallState on call_analyzed; writer post_call_analysis |
| Website lead | POST /tasks/api/v1/frontdesk/widget/:entityId/lead, or POST /booking-enquiries | EnquiriesEntityService.createEnquiry → website.booking_enquiries, source MINDBODY_WIDGET | schedule-dropoff job writes DROP_LEAD_SP_GENERATED; funnel events and handle-booking-created move it on |
| Inbound SMS | POST /frontdesk/webhooks/twilio/sms | FrontdeskAgentService.sendInboundClientMessage, 12 MCP tools | StateCalculatorService.calculateState, feature sms-state-classifier |
Two more entry points exist and are not on the map. Web chat (BookingAgentService.streamVisitorTurn) runs the same agent in the browser. An outbound call is an entry point in the customer's eyes; here it is the tail of journey J4.
Dispositions
The Postgres enum holds 48 states. A classifier may only return the states in its reachable set: 14 universal states plus a channel-specific group.
| Group | States | Inbound call | SMS | Outbound call |
|---|---|---|---|---|
| Universal (14) | APPOINTMENT_BOOKED, APPOINTMENT_RESCHEDULED, APPOINTMENT_CANCELLED, CHECKOUT_LINK_SENT, BOOKING_REQUESTED, CANCELLATION_REQUESTED, RESCHEDULE_REQUESTED, MODIFICATION_REQUESTED, NOT_INTERESTED, DO_NOT_CONTACT, JOB_INQUIRY, CLIENT_COMPLAINT, GENERAL_INQUIRY, INQUIRY_HANDLED | ✓ | ✓ | ✓ |
| Ongoing (4) | ACTIVE_CONVERSATION, BOOKING_INTENT, UNRESPONSIVE, DORMANT | ✓ | ✓ | |
| Drop-off (3) | DROP_LEAD_SP_GENERATED, DROP_CART_ITEM_ADDED, DROP_SLOT_BLOCKED | ✓ | ||
| Non-customer (2) | SPAM, SALES_OR_VENDOR | ✓ | ✓ | |
| Call interruption (2) | ABANDONED_CALL, CALL_DISCONNECTED | ✓ | ||
| Reachable | 18 | 18 | 23 |
Three definitions matter for the booking metric. APPOINTMENT_BOOKED means the last successful book tool call returned confirmed: true or pendingApproval: true. CHECKOUT_LINK_SENT means a payment link went out and the deposit is pending. BOOKING_REQUESTED means the caller wanted to book and book did not succeed. The third is the one to watch: it is booking intent that the agent did not convert.
How a state changes
ConversationStateRepository.upsertState is the only writer. When the new state differs from the old one it does three things in order: deactivates every active, unprocessed follow-up for the client with reason Superseded by state transition from → to; removes the matching Bull jobs (win_followup_${id}); and writes an audit row state_transition / state_changed with changes: {from, to} and trace_id = the trigger id. Writing the same state again is a no-op. Nothing cancels a follow-up except a state change, a handover, or a DND flag.
Default follow-up rules
Rules resolve entity → cohort → defaults. The defaults are in ONBOARDING_DEFAULT_STATE_RULES; every rule has maxFollowUps: 1.
| State | Sequence |
|---|---|
AWAITING_FIRST_RESPONSE | PHONE @ 10 min → SMS @ 15 min → SMS @ 2 h |
DROP_LEAD_SP_GENERATED, DROP_CART_ITEM_ADDED, DROP_SLOT_BLOCKED | PHONE @ 10 min → SMS @ 15 min → SMS @ 2 h |
BOOKING_INTENT | PHONE @ 5 min → SMS @ 15 min |
ACTIVE_CONVERSATION | SMS @ 5 min → SMS @ 15 min |
UNRESPONSIVE | SMS @ 12 h |
CALL_DISCONNECTED | SMS @ 0 |
LOYALTY_CAMPAIGN_SCHEDULED, REVIEW_AUTOMATION | SMS @ 0 |
No post-call state has a default rule. BOOKING_REQUESTED, NEEDS_CALL_BACK, and GENERAL_INQUIRY after an inbound call get no follow-up unless the entity or its cohort has written one. After the last rule in a sequence fires, a no-response-transition job moves the state on.
Remark. There is one post-call SMS that does not go through rules. PostCallFallbackSmsService.dispatchIfNeeded fires when a transfer was attempted or when bucketState(state) is NEEDS_ACTION, and only if the entity has a transferNumber. It is tagged triggerSource: 'post_call_fallback', and it is the only SMS an inbound call sends on its own.
Journeys
Each journey is cut at a disposition write. A journey that ends in a state with a rule hands off to J3 or J4; one that ends in HUMAN_HANDOVER hands off to J6.
J1 · Inbound call
incoming-call webhook. A chatbot.transcript_mapping row appears with status: ONGOING and metadata.direction: 'inbound'.
call_analyzed event. calculateInboundCallState (gpt-5.4-mini, temperature 0, feature call-state-classifier) writes the state with metadata.source: 'POST_CALL_ANALYSIS'.
The voice agent.
All 19 MCP tools on frontdesk-agent.
18: universal, non-customer, call interruption.
Booking conversion on booking intent. Numerator: calls whose post-call state is APPOINTMENT_BOOKED. Denominator: calls whose post-call state is APPOINTMENT_BOOKED, CHECKOUT_LINK_SENT, or BOOKING_REQUESTED.
After the exit, two things happen that belong to other journeys. scheduleFollowUpsForCall runs getNextFollowUps and, if a rule exists, opens J3 or J4. ProcessCallTranscriptService.processCallTranscript writes a summary into the conversation so the SMS agent knows about the call.
-- J1 headline: inbound calls with booking intent, and how many booked
with post_call as (
select a.trace_id as call_id,
a.actor_id as entity_id,
a.changes ->> 'to' as state
from l2b.frontdesk_audit a
where a.action = 'state_changed'
and a.metadata ->> 'writer' = 'post_call_analysis'
)
select c.entity_id,
count(*) filter (where p.state in ('APPOINTMENT_BOOKED', 'CHECKOUT_LINK_SENT', 'BOOKING_REQUESTED')) as intent,
count(*) filter (where p.state = 'APPOINTMENT_BOOKED') as booked
from post_call p
join comms.call_comms c on c.retell_call_id = p.call_id
where c.direction = 'INBOUND'
group by 1;
J2 · Website self-serve
Lead form. A website.booking_enquiries row with source: 'MINDBODY_WIDGET'; the schedule-dropoff job writes DROP_LEAD_SP_GENERATED.
POST :entityId/funnel events: service_selected → DROP_CART_ITEM_ADDED, slot_selected → DROP_SLOT_BLOCKED.
handle-booking-created → APPOINTMENT_BOOKED, writer booking-created, if the current state is one of WEBSITE_DROP_OFF, DROP_LEAD_SP_GENERATED, DROP_CART_ITEM_ADDED, DROP_SLOT_BLOCKED, CHECKOUT_LINK_SENT.
Nobody. No agent runs. The customer drives the widget.
None. A drop-off here is a UX or platform failure, not a tool failure.
Funnel completion: enquiries that reach APPOINTMENT_BOOKED without a follow-up touch, over all enquiries. Stage drop-off is read from the three DROP_* writes.
A customer who stops at any DROP_* state leaves J2 with a rule attached: PHONE @ 10 min, SMS @ 15 min, SMS @ 2 h. That is the hand-off to J4, then J3.
J3 · Win-back by SMS
A chatbot.followups row with mode_of_communication: SMS becomes due. The win-followup queue's process-followup job picks it up.
DND, quiet hours 9 PM – 7 AM, feature gate, 4 messages per hour, 10-minute gap per channel, no active handover.
The FrontDesk agent drafts with generateFollowupResponse; WinSmsService.sendCustomerSms sends with triggerSource: 'followup-processor' and skipStateRecompute: true. The send does not write a state.
The customer replies. The reply is an SMS agent turn (gpt-5.6-terra, fallback gpt-5.6-luna) and the sms-state-classifier writes the state. No reply means no write; the sequence continues until no-response-transition.
The follow-up engine for the send; the SMS agent for the reply.
The 12 SMS-allowed tools, only on the reply turn.
Reply rate per follow-up row, and booked rate: rows whose next state write is APPOINTMENT_BOOKED.
J4 · Win-back by call
A chatbot.followups row with mode_of_communication: PHONE becomes due, and passes the same gates as J3.
CallInitiatorService.makeCall → RetellService.createPhoneCall with override_agent_id = the outbound agent (agent_51a9834083d213c80d69d36209 in production). A transcript_mapping row appears with metadata.direction: 'outgoing'.
call_analyzed. calculateOutboundCallState writes the state. Returning state: null preserves a DROP_* state.
The outbound voice agent.
All 19 MCP tools.
23: universal, ongoing, drop-off, non-customer.
Answer rate (call_comms call_status), and booked rate: outbound calls whose post-call state is APPOINTMENT_BOOKED.
-- J3 + J4: every follow-up row and the state it produced next
select f.id as followup_id,
f.conversation_state as from_state,
f.mode_of_communication as channel,
f.trigger,
s.state as next_state
from chatbot.followups f
left join lateral (
select l.state
from chatbot.conversation_states_logs l
where l.client_id = f.client_id
and l.created_at > f.processed_at
order by l.created_at
limit 1
) s on true
where f.processed = true;
Remark. Last-touch credit. When a client runs J4 and then J3 before booking, the booking belongs to the journey whose disposition write is APPOINTMENT_BOOKED. The other run is scored as a non-conversion. This is a rule, not a fact about the customer, and it should be stated wherever the numbers are shown.
J5 · Inbound SMS
Twilio webhook. ContactInitializationService.getFrontdeskConversationForClient resolves (client_id, entity_id) to the conversation; the text lands in chat.messages.
FrontdeskAgentService.sendInboundClientMessage. The reply goes out through WinSmsService.
calculateState, feature sms-state-classifier, writes the state after each turn.
The SMS agent.
12: book, booking-intent, find-slots, get-appointments, modify-appointment, search-services, search-offers-for-service, get-staff, save-caller-name, recall-from-brain, send-review-link, schedule-reminder-followup.
18: universal, ongoing.
Same shape as J1, over SMS threads: threads that reach APPOINTMENT_BOOKED over threads that reach APPOINTMENT_BOOKED, CHECKOUT_LINK_SENT, BOOKING_INTENT, or BOOKING_REQUESTED.
An SMS thread is not cut off, so it never produces CALL_DISCONNECTED or ABANDONED_CALL. It can produce ACTIVE_CONVERSATION and BOOKING_INTENT, which carry rules, so J5 hands off to J4 or J3 more often than J1 does.
J6 · Handover
Any agent calls request-human-handover, or HandoverWriterService.activateHumanHandover runs from a system path.
A chatbot.human_handovers row. HandoverOccurredHandler cancels active follow-ups and registers HUMAN_HANDOVER. Dispatch checks isHumanHandoverActive before every send. Slack posts to the HUMAN_HANDOVER channel.
Expiry after DEFAULT_DEACTIVATION_MINUTES = 2880 (48 h), or a manual close.
The studio.
Handover rate per originating journey, and time to first human reply.
Loops and repetition
States repeat. A client who books, cancels, and books again has been through APPOINTMENT_BOOKED twice, and the journeys are counted twice. The unit of measurement is the segment instance, not the client.
A worked sequence:
- Website lead →
DROP_LEAD_SP_GENERATED(J2 opens and stops). - PHONE rule @ 10 min → outbound call, no answer → state preserved (J4 run 1, non-conversion).
- SMS rule @ 15 min → SMS sent, no reply (J3 run 1, no write).
- Customer calls in the next day →
BOOKING_REQUESTED(J1 run 1, intent, not converted). - Customer texts "can I do 3pm" → SMS agent books →
APPOINTMENT_BOOKED(J5 run 1, converted).
Five instances across four journeys, one booking, credited to J5. Step 4 is the J1 metric's denominator and not its numerator, which is the correct reading: the call agent had the intent and did not close it.
Segment instances are delimited by writes, not by time. The boundary between step 2 and step 3 is the follow-up row, not the 5 minutes between them. If two writes happen in the same second, the conversation_states_logs.log_id sequence orders them.
Metrics
Each owner gets its own table. Do not average across owners; a good SMS agent and a bad call agent average to a meaningless number.
| Owner | Journeys | Headline | Supporting |
|---|---|---|---|
| Voice agent, inbound | J1 | Booking conversion on intent | CALL_DISCONNECTED rate, handover rate, tool failure rate per tool |
| Voice agent, outbound | J4 | Booked rate per outbound call | Answer rate, state: null (preserved) rate, tool failure rate |
| SMS agent | J5, J3 reply turn | Booking conversion on intent | Turns to booking, handover rate, tool failure rate |
| Follow-up engine | J3, J4 scheduling | Reply or answer within the sequence | Gate skip reasons (followup_skipped), superseded count, rows cancelled by handover |
| Website flow | J2 | Funnel completion | Drop-off per DROP_* stage |
Sources. State writes come from l2b.frontdesk_audit rows with action = 'state_changed' (or chatbot.conversation_states_logs for history before the audit table). Calls come from comms.call_comms (direction, status) and chatbot.transcript_mapping (tool calls, transfer status, post-call analysis). Follow-ups come from chatbot.followups. Tool outcomes come from l2b.frontdesk_audit rows with action_category = 'platform_tool'.
Attributing tool failures
The same 19 tools are served by one MCP server, frontdesk-agent on apps/servers/scp-server, to the inbound voice agent, the outbound voice agent, and the SMS agent. A tool failure lands in l2b.frontdesk_audit through FrontdeskAuditRepository.initiate() / complete() / fail() with this shape:
trace_id call_* for voice, a uuid for SMS
actor_id entity_id
client_id may be null on a voice call before the caller is identified
conversation_id null on voice
action_category platform_tool | state_transition | followup |
call_lifecycle | disposition | handover | sms
actor_type agent | system | scheduler | sp | customer
action e.g. create_booking, state_changed, followup_skipped
resource_type appointment | booking | availability | service
status initiated | completed | failed
changes {from, to}
metadata {writer, …}
There is no channel or journey column. To find the journey of a failed platform_tool row:
trace_idstarts withcall_→ joincomms.call_comms.retell_call_id;direction = 'INBOUND'is J1,OUTBOUNDis J4.- Otherwise the row is an SMS turn. If a
chatbot.followupsrow for the client was processed in the minutes before it, the turn is a J3 reply; otherwise it is J5. action_category = 'followup'rows belong to the engine regardless of channel.
Step 2 is a heuristic. The fix is to stamp metadata.journey at the start of every trace: the webhook knows it is inbound, the processor knows it is a follow-up, and the SMS handler can check for an open follow-up before the first tool runs. One field, written once per trace, and the join goes away.
| Tool | Voice | SMS |
|---|---|---|
book, booking-intent, find-slots, get-appointments, modify-appointment | ✓ | ✓ |
search-services, search-offers-for-service, get-staff | ✓ | ✓ |
save-caller-name, recall-from-brain, send-review-link, schedule-reminder-followup | ✓ | ✓ |
check-availability, collect-card-on-file, validate-card-on-file | ✓ | |
get-business-services, get-server-metadata, request-human-handover, send-sms | ✓ |
Remark. check-availability is voice-only, so a failure in it can only belong to J1 or J4. request-human-handover is voice-only as a tool; the SMS agent reaches J6 through a system path.
Data model
website.booking_enquiries be (client_id, entity_id, source, status, booking_id, attributes)
└─ clients.clients c c.id = be.client_id
└─ chatbot.conversation_states cs cs.client_id = c.id -- one row per client
└─ chat.conversations cv cv.id = cs.conversation_id
└─ chat.members / chat.messages
└─ scheduling.bookings b b.id = be.booking_id
└─ scheduling.carts ct ct.enquiry_id = be.id
chatbot.transcript_mapping tm (call_id, client_id, entity_id, status, metadata, post_call_analysis)
comms.call_comms cc cc.retell_call_id = tm.call_id -- direction INBOUND | OUTBOUND
chatbot.followups f (client_id, entity_id, conversation_state, mode_of_communication, trigger, processed_at, deactivation_reason)
chatbot.human_handovers hh (client_id, is_active, deactivate_at) -- no entity_id
l2b.frontdesk_audit a (trace_id, actor_id = entity_id, client_id, action_category, action, status)
Three facts about identity. conversation_states.client_id is unique across the table, so a client who talks to two locations shares one state row. scheduling.bookings has no channel, source, or call id; the link to a journey runs through booking_enquiries.booking_id or through the state write that recorded it. transcript_mapping.metadata.direction holds 'inbound', 'outgoing' at dial time, and 'outbound' from the webhook, so filter on call_comms.direction instead.
Known gaps
- No journey stamp on audit rows. Attribution for SMS relies on a time-window heuristic.
- No default rule for post-call states.
BOOKING_REQUESTEDfrom an inbound call gets no follow-up unless the entity has one; this is the largest untouched intent in J1. - Call context flows to SMS; SMS context does not flow to calls.
ConversationsRepository.updateSummaryhas no callers. directionis spelled three ways intranscript_mapping.metadata.- The legacy
libs/chatbotengine still holds one rule (HUMAN_HANDOVER, PHONE, 2 h, ×100). It is not on the win path.
Mapped. Not yet measured.