Docs

Front desk · Journeys

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

libs/win · libs/frontdesk-agent · apps/servers/scp-server · read at develop 7065dc3218

Vocabulary

SP

The service provider: one location. entity_id in every table below.

EC

The end customer. client_id, a row in clients.clients. A conversation is keyed by the pair (client_id, entity_id).

Touchpoint

Where the customer first appears: an inbound call, the website lead form, or an inbound SMS.

Conversation

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.

Call

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".

Disposition

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.

Follow-up

A scheduled outbound touch, one chatbot.followups row, created from the chatbot.state_rules row that matches the disposition. Channel is SMS or PHONE.

Journey

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.

Segment instance

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

Inbound call Retell webhook Website lead widget lead form Inbound SMS Twilio webhook Call agent Retell inbound · 19 tools Booking flow service → slot → pay SMS agent FrontDesk · 12 tools Disposition chatbot.conversation_states · one row per client Booked APPOINTMENT_BOOKED Human handover human_handovers No follow-up state has no rule Follow-up engine state_rules → chatbot.followups Outbound call Retell outbound Follow-up SMS WinSmsService
Every path the front desk can take. The pills are the disposition each step writes; tabs isolate one journey.

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

TouchpointEntry pointHandlerDisposition writer
Inbound callPOST /frontdesk/webhooks/retell/incoming-call, then /call-eventsFrontdeskRetellWebhookService → Retell inbound agent, 19 MCP toolsStateCalculatorService.calculateInboundCallState on call_analyzed; writer post_call_analysis
Website leadPOST /tasks/api/v1/frontdesk/widget/:entityId/lead, or POST /booking-enquiriesEnquiriesEntityService.createEnquiry → website.booking_enquiries, source MINDBODY_WIDGETschedule-dropoff job writes DROP_LEAD_SP_GENERATED; funnel events and handle-booking-created move it on
Inbound SMSPOST /frontdesk/webhooks/twilio/smsFrontdeskAgentService.sendInboundClientMessage, 12 MCP toolsStateCalculatorService.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.

GroupStatesInbound callSMSOutbound 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✓
Reachable181823

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.

StateSequence
AWAITING_FIRST_RESPONSEPHONE @ 10 min → SMS @ 15 min → SMS @ 2 h
DROP_LEAD_SP_GENERATED, DROP_CART_ITEM_ADDED, DROP_SLOT_BLOCKEDPHONE @ 10 min → SMS @ 15 min → SMS @ 2 h
BOOKING_INTENTPHONE @ 5 min → SMS @ 15 min
ACTIVE_CONVERSATIONSMS @ 5 min → SMS @ 15 min
UNRESPONSIVESMS @ 12 h
CALL_DISCONNECTEDSMS @ 0
LOYALTY_CAMPAIGN_SCHEDULED, REVIEW_AUTOMATIONSMS @ 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

1 Call connects transcript_mapping row 2 Call agent 19 MCP tools 3 Call ends call_analyzed 4 Classifier calculateInboundCallState 5 Disposition 18 reachable states
J1 · Inbound call. Call start → disposition.
Entry

incoming-call webhook. A chatbot.transcript_mapping row appears with status: ONGOING and metadata.direction: 'inbound'.

Exit

call_analyzed event. calculateInboundCallState (gpt-5.4-mini, temperature 0, feature call-state-classifier) writes the state with metadata.source: 'POST_CALL_ANALYSIS'.

Owner

The voice agent.

Tools

All 19 MCP tools on frontdesk-agent.

States

18: universal, non-customer, call interruption.

Metric

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

1 Lead form booking_enquiries 2 Service service_selected 3 Slot slot_selected 4 Booking handle-booking-created 5 Booked APPOINTMENT_BOOKED
J2 · Website self-serve. Lead → booking, no agent.
Entry

Lead form. A website.booking_enquiries row with source: 'MINDBODY_WIDGET'; the schedule-dropoff job writes DROP_LEAD_SP_GENERATED.

Steps

POST :entityId/funnel events: service_selected → DROP_CART_ITEM_ADDED, slot_selected → DROP_SLOT_BLOCKED.

Exit

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.

Owner

Nobody. No agent runs. The customer drives the widget.

Tools

None. A drop-off here is a UX or platform failure, not a tool failure.

Metric

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

1 State with rule DROP_* · CALL_DISCONNECTED 2 Follow-up row chatbot.followups 3 SMS sent win-followup job 4 Reply SMS agent turn 5 Disposition sms-state-classifier
J3 · Win-back by SMS. Rule → SMS → reply → disposition.
Entry

A chatbot.followups row with mode_of_communication: SMS becomes due. The win-followup queue's process-followup job picks it up.

Gates

DND, quiet hours 9 PM – 7 AM, feature gate, 4 messages per hour, 10-minute gap per channel, no active handover.

Send

The FrontDesk agent drafts with generateFollowupResponse; WinSmsService.sendCustomerSms sends with triggerSource: 'followup-processor' and skipStateRecompute: true. The send does not write a state.

Exit

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.

Owner

The follow-up engine for the send; the SMS agent for the reply.

Tools

The 12 SMS-allowed tools, only on the reply turn.

Metric

Reply rate per follow-up row, and booked rate: rows whose next state write is APPOINTMENT_BOOKED.

J4 · Win-back by call

1 State with rule DROP_* · BOOKING_INTENT 2 Follow-up row channel = PHONE 3 Call placed RetellService.createPhoneCall 4 Call ends call_analyzed, outbound 5 Disposition calculateOutboundCallState
J4 · Win-back by call. Rule → outbound call → disposition.
Entry

A chatbot.followups row with mode_of_communication: PHONE becomes due, and passes the same gates as J3.

Call

CallInitiatorService.makeCall → RetellService.createPhoneCall with override_agent_id = the outbound agent (agent_51a9834083d213c80d69d36209 in production). A transcript_mapping row appears with metadata.direction: 'outgoing'.

Exit

call_analyzed. calculateOutboundCallState writes the state. Returning state: null preserves a DROP_* state.

Owner

The outbound voice agent.

Tools

All 19 MCP tools.

States

23: universal, ongoing, drop-off, non-customer.

Metric

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

1 SMS arrives chat.messages 2 SMS agent 12 MCP tools 3 Reply sent WinSmsService 4 Classifier sms-state-classifier 5 Disposition 18 reachable states
J5 · Inbound SMS. First text → disposition.
Entry

Twilio webhook. ContactInitializationService.getFrontdeskConversationForClient resolves (client_id, entity_id) to the conversation; the text lands in chat.messages.

Turn

FrontdeskAgentService.sendInboundClientMessage. The reply goes out through WinSmsService.

Exit

calculateState, feature sms-state-classifier, writes the state after each turn.

Owner

The SMS agent.

Tools

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.

States

18: universal, ongoing.

Metric

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

1 Agent requests request-human-handover 2 Row written human_handovers 3 Follow-ups cancelled HandoverOccurredHandler 4 Slack alert HUMAN_HANDOVER channel 5 Expires 48 h default
J6 · Handover. Any agent → human.
Entry

Any agent calls request-human-handover, or HandoverWriterService.activateHumanHandover runs from a system path.

Effect

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.

Exit

Expiry after DEFAULT_DEACTIVATION_MINUTES = 2880 (48 h), or a manual close.

Owner

The studio.

Metric

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:

  1. Website lead → DROP_LEAD_SP_GENERATED (J2 opens and stops).
  2. PHONE rule @ 10 min → outbound call, no answer → state preserved (J4 run 1, non-conversion).
  3. SMS rule @ 15 min → SMS sent, no reply (J3 run 1, no write).
  4. Customer calls in the next day → BOOKING_REQUESTED (J1 run 1, intent, not converted).
  5. 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.

OwnerJourneysHeadlineSupporting
Voice agent, inboundJ1Booking conversion on intentCALL_DISCONNECTED rate, handover rate, tool failure rate per tool
Voice agent, outboundJ4Booked rate per outbound callAnswer rate, state: null (preserved) rate, tool failure rate
SMS agentJ5, J3 reply turnBooking conversion on intentTurns to booking, handover rate, tool failure rate
Follow-up engineJ3, J4 schedulingReply or answer within the sequenceGate skip reasons (followup_skipped), superseded count, rows cancelled by handover
Website flowJ2Funnel completionDrop-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:

  1. trace_id starts with call_ → join comms.call_comms.retell_call_id; direction = 'INBOUND' is J1, OUTBOUND is J4.
  2. Otherwise the row is an SMS turn. If a chatbot.followups row for the client was processed in the minutes before it, the turn is a J3 reply; otherwise it is J5.
  3. 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.

ToolVoiceSMS
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_REQUESTED from 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.updateSummary has no callers.
  • direction is spelled three ways in transcript_mapping.metadata.
  • The legacy libs/chatbot engine still holds one rule (HUMAN_HANDOVER, PHONE, 2 h, ×100). It is not on the win path.

Mapped. Not yet measured.