In-Chat Booking¶
Overview¶
The Booking Workflow intercepts CTA button clicks (e.g., "Book a Demo") to collect the visitor's email address through conversation before redirecting them to the booking page (Cal.com, Calendly, etc.) with the email pre-filled via a ?email= query parameter.
This feature requires the inchat_booking flag to be enabled:
| Flag | Layer | Purpose |
|---|---|---|
inchat_booking |
Frontend (site config / feature flag) | Makes CTA clicks trigger the in-chat email collection flow instead of direct navigation |
The booking flow runs on the agentic system, with intent_router routing to the booking_handler node.
When inchat_booking is disabled, CTA buttons navigate directly to their target URL (with ?email= appended if email is already known).
When the visitor's email is already known (from a previous session or enrichment), the flow is nearly instant: a brief confirmation message followed by automatic redirect.
Prerequisites¶
| Requirement | How to Enable | Notes |
|---|---|---|
| Booking workflow flag | inchat_booking: true in site config features |
Controls frontend CTA behavior |
| At least one CTA configured | CTA entries in site config with url and optional bookingMessage |
Booking needs a redirect_url from CTA context |
Blocking conditions (booking flow will not start):
- Form assistant mode — visitor is already on the CTA destination page in copilot mode
FORM_COLLECTINGactive flow — widget is collecting form data via suggested answers
System Architecture¶
Key points:
- Booking handler skips RAG and enrichment — no knowledge base retrieval needed for email collection, saving ~300-500ms
- 3-way routing —
intent_routerroutes to ANSWER/REDIRECT/BOOKING - Redirect and booking paths both cancel pending retrieval tasks for faster response
User Flows¶
Use Case Summary¶
| # | Scenario | Outcome |
|---|---|---|
| 1 | Happy path — email unknown | CTA click → bot asks email → visitor provides → confirmation → redirect |
| 2 | Happy path — email known | CTA click → instant confirmation → redirect |
| 3 | Invalid email | Visitor provides malformed email → gentle correction → retry |
| 4 | PAUSE: product question mid-booking (IX-2579) | Visitor asks "do you have clients in my industry?" → classifier routes to LEARN → answer writer answers the question AND softly re-asks the pending field on the last line. booking_state preserved. |
| 5 | EXIT: explicit refusal (IX-2579) | "not now", "no thanks", "I don't want to give my email" → classifier emits STOP_BOOKING → warm acknowledgement, no re-ask, booking_state reset, interest score reset |
| 6 | EXIT: multilingual refusal (IX-2579) | "pas maintenant", "ahora no", "nicht jetzt" → same EXIT path, handled natively by the LLM classifier (no regex) |
| 7 | EXIT: support / off-topic / other / hack mid-booking (IX-2579) | Visitor surfaces a support issue or off-topic message during booking → booking exits, intent is handled by redirect_handler |
| 8 | Returning visitor | Email loaded from Supabase on session start → treated as "email known" |
| 9 | Natural language booking | "I want to book a demo" → BOOKING intent but no redirect_url → answer flow guides to CTA |
| 10 | Inline demo CTA | Bot response includes 👇 emoji → inline CTA button shown → same booking flow |
| 11 | Booking disabled | inchat_booking flag off → CTA does direct navigation |
| 12 | Form assistant mode | User on CTA destination page → booking flow blocked entirely |
| 13 | Max email attempts | After 3 invalid email attempts → graceful exit with direct redirect |
| 14 | Max booking turns (frontend) | After 5 booking turns without email → frontend safety valve redirects without email |
| 15 | Multi-field form | Site has in_chat_booking form with extra fields (e.g. team_size) → each field collected sequentially; STAY/PAUSE/EXIT applies to each turn |
Sequence Diagrams¶
Email Unknown (Happy Path)¶
Email Known (Returning Visitor)¶
State Machine¶
Backend: BookingState¶
Email is tracked as a regular field in pending_booking_fields / collected_booking_fields alongside any extra fields from the site's in_chat_booking form config (e.g. team_size). When email is captured it is also mirrored onto visitor_profile.email for frontend redirect detection and webhook/analytics side-effects.
| Field | Type | Description |
|---|---|---|
is_booking_flow |
bool |
Whether the current message is part of a booking flow |
cta_id |
str \| None |
CTA ID that triggered the flow |
redirect_url |
str \| None |
Target URL to redirect after field collection |
pending_booking_fields |
list[str] |
Field IDs still needed (e.g. ["email", "team_size"]) |
collected_booking_fields |
dict[str, str] |
Collected field values (e.g. {"email": "j@x.com", "team_size": "11-50"}) |
field_attempts |
dict[str, int] |
Per-field failed extraction attempt counts |
email_attempts |
int |
Number of invalid email attempts (retry limit, max 3) |
booking_completed |
bool |
Set after in-chat calendar confirms the booking (PostMessage handshake) |
Three "ambient" fields also live on the top-level RoseChatState to carry the router's decision to the answer writer (IX-2579):
| State field | Type | Description |
|---|---|---|
just_exited_booking |
bool |
true for the turn we exited booking — triggers the "no worries" ack |
paused_in_booking |
bool |
true when PAUSE fired — triggers answer + soft re-ask in the answer writer |
paused_booking_pending_field |
str \| None |
Field that was being asked when pause happened (e.g. "email") — used to phrase the re-ask |
Frontend: ConversationFlowReducer¶
The frontend uses a discriminated union to manage all conversation modes:
type ActiveFlow =
| { type: 'ANSWERING' }
| { type: 'FORM_COLLECTING' }
| { type: 'REDIRECTING'; redirectUrl: string | null }
| {
type: 'BOOKING';
isAwaitingEmail: boolean;
pendingRedirectUrl: string | null;
pendingCtaId: string | null;
pendingBookingMessage: string | null;
bookingTurnCount: number;
};
| Action | Trigger | Effect |
|---|---|---|
startBookingFlow |
CTA click when isInchatBookingEnabled |
Sets activeFlow = BOOKING with CTA details, bookingTurnCount = 0 |
incrementBookingTurn |
Backend response complete during booking | Increments bookingTurnCount (prevents premature redirect) |
completeBookingFlow |
After redirect | Returns to ANSWERING |
cancelBookingFlow |
User exits | Returns to ANSWERING |
updateVisitorProfile |
Backend completion event | Updates visitorProfile (triggers redirect useEffect) |
Blocking conditions: startBookingFlow is a no-op when isFormAssistant = true or activeFlow.type === 'FORM_COLLECTING'.
Safety Valves¶
| Layer | Mechanism | Threshold | Behavior |
|---|---|---|---|
| Backend | MAX_EMAIL_ATTEMPTS |
3 invalid emails | Exits booking flow, redirects without email |
| Frontend | MAX_BOOKING_TURNS |
5 turns in booking | Frontend redirects without email (safety valve) |
| Frontend | bookingTurnCount >= 1 |
1st backend response | Prevents premature redirect before bot responds |
Intent Router Priority Logic¶
The intent_router uses a priority-based routing algorithm. When booking is active (IX-2579), Priority 0 produces one of three outcomes — STAY, PAUSE, or EXIT — driven by the booking-aware intent classifier rather than regex-based input sniffing.
| Priority | Condition | Outcome | Route | Description |
|---|---|---|---|---|
| P0 STAY | booking active AND intent == BOOKING |
STAY | BOOKING |
Visitor engaged with booking — collect next pending field |
| P0 STAY | booking active AND _is_field_answer_for_pending() (email regex matches, or field-answer heuristic) |
STAY | BOOKING |
Field answer detected — continue collecting |
| P0 EXIT | booking active AND intent == STOP_BOOKING |
EXIT | REDIRECT |
Explicit refusal — warm ack, reset booking_state |
| P0 PAUSE | booking active AND intent in {LEARN, CONTEXT} |
PAUSE | ANSWER |
Product question mid-booking — answer + soft re-ask, preserve state |
| P0 EXIT | booking active AND intent in {OFFTOPIC, SUPPORT, OTHER, HACK} |
EXIT | intent-specific | Incompatible intent — exit booking, handle intent, reset state |
| P1 | intent == BOOKING AND redirect_url exists |
— | BOOKING |
Natural-language booking with CTA context |
| P1-skip | intent == BOOKING AND no redirect_url |
— | ANSWER |
Booking intent without CTA — guide to click CTA |
| P2 | intent in {HACK, SUPPORT, OFFTOPIC, OTHER} |
— | REDIRECT |
Blocking intents handled immediately |
| P3 | Interest score ≥ demo threshold (and not exiting/pausing) | — | ANSWER |
Demo proposal |
| P4 | intent in {CONTEXT, LEARN} |
— | ANSWER |
Normal product intents |
On EXIT, the router sets just_exited_booking=true, resets booking_state, and calls interest_signals.reset_cumulative() to avoid an immediate demo re-proposal.
On PAUSE, the router sets paused_in_booking=true and copies the first pending field into paused_booking_pending_field. booking_state is not touched — the next turn can resume exactly where the visitor left off.
Interruption Handling (IX-2579)¶
Before IX-2579, the only exit signal was "no @ in input while awaiting_email". That trapped visitors who asked product questions mid-booking in an infinite email re-ask loop. The new flow treats the booking form as interruptible:
Three coordinated pieces make this work:
-
Booking-aware classifier (
intent_classifier.py:_build_booking_classifier_guidance) — when booking is active, the classifier prompt is enriched with rules that map refusals toSTOP_BOOKING, product questions toLEARN, and short option-label values (e.g."11-50","just myself") toBOOKINGso they're not misclassified as off-topic. -
Three-branch router (
intent_router.py:determine_next_action) — at Priority 0, chooses STAY / PAUSE / EXIT and emits thejust_exited_booking/paused_in_booking/paused_booking_pending_fieldstate fields. -
Answer-writer transition instruction (
utils/booking_transition.py:build_booking_transition_instruction) — renders the{rt_booking_transition_instruction}Langfuse slot:- On EXIT: brief acknowledgement (e.g. "No worries"), no email re-request.
- On PAUSE: mandatory 2-part response — answer the question, then soft re-ask the pending field on the final line (e.g. "Happy to send the booking link — what's the best email?").
Feature Flag Interactions¶
The booking workflow depends on multiple feature flags that are evaluated independently:
Flag Cascade (highest to lowest priority)¶
1. Preprod override (localStorage) ← highest priority
2. Site-specific config (custom_config)
3. Global default
4. Fallback: false ← lowest priority
Related Flags¶
| Flag | Check Function | Effect on Booking |
|---|---|---|
inchat_booking |
isInchatBookingEnabled(domain) |
Required — controls whether CTAs trigger email collection |
form_assistant |
isFormAssistantEnabled(domain) |
When active on CTA pages, blocks booking flow entirely |
Inline demo CTA behavior is no longer configurable: when the assistant uses 👇, the widget renders the primary CTA inline below that message and hides the CTA strip above the search bar for that turn.
Preprod Testing Toggles¶
| Toggle | localStorage Key | Purpose |
|---|---|---|
| Force In-Chat Booking | rose_preprod_inchat_booking_override |
Overrides frontend booking flag |
Prompt System¶
The booking handler uses a 3-level prompt hierarchy, consistent with other response agents:
website-agent/response-agents/meta-template (shared by all agents)
↓
website-agent/response-agents/booking/template (email collection)
website-agent/response-agents/booking/confirmation-template (email known → confirm redirect)
Key Guardrails¶
All booking prompts enforce these rules:
- NEVER include URLs, links, or page addresses in the response
- **NEVER tell the visitor to "visit", "go to", or "click" any page
- URLs from conversation history are stripped before passing to the LLM (
_format_chat_history(strip_urls=True))
Email Validation Status¶
The rt_email_validation_status variable tells the LLM how to respond:
| Status | When | LLM Instruction |
|---|---|---|
WAITING FOR EMAIL |
First time asking | Request email naturally |
INVALID EMAIL PROVIDED |
User entered invalid format (contains @ but no valid pattern) |
Gentle correction, suggest name@company.com |
Default Model¶
Booking uses gpt-4.1-nano for fast, lightweight responses — email collection doesn't need a powerful model.
Key Files¶
Backend¶
| File | Purpose |
|---|---|
ixchat/nodes/booking_handler.py |
Main booking handler — field collection + confirmation |
ixchat/nodes/intent_router.py |
Priority-based STAY / PAUSE / EXIT routing (IX-2579) |
ixchat/nodes/intent_classifier.py |
Booking-aware LLM classifier — distinguishes refusal / product-question / field-answer |
ixchat/utils/booking_transition.py |
Builds the {rt_booking_transition_instruction} slot for PAUSE / EXIT turns (IX-2579) |
ixchat/utils/fast_path.py |
Short-circuits the classifier when the input is clearly a field answer |
ixchat/graph_structure.py |
Graph definition with BOOKING path |
ixchat/pydantic_models/booking_state.py |
BookingState model (multi-field aware) |
ixchat/pydantic_models/intent_router.py |
VisitorIntent enum incl. BOOKING / STOP_BOOKING |
ixchat/utils/email_extraction.py |
Regex-based email extraction from text |
ixchat/utils/lead_capture_webhook.py |
Fire-and-forget webhook on email capture |
ixchat/chatbot.py |
Merges BookingContext into booking_state in _prepare_query_state() |
prompts/.../booking/template.md |
Langfuse prompt for field collection |
prompts/.../booking/confirmation-template.md |
Langfuse prompt for confirmation |
Frontend¶
| File | Purpose |
|---|---|
utils/widget/conversationFlowReducer.ts |
Unified flow reducer (BOOKING state) |
hooks/widget/useConversationFlowReducer.ts |
Hook with memoized actions and derived state |
components/ExpandedChatView.tsx |
Booking flow orchestration, redirect useEffect |
components/ExpandedChat/BookingTopBar.tsx |
Dedicated top bar during booking — surfaces the pending field prompt |
components/SearchBarCTAButton.tsx |
CTA click handler, starts booking flow |
components/InlineDemoCTAButton.tsx |
Inline demo CTA triggered by 👇 emoji |
utils/content/ctaReplacer.ts |
CTA data resolution with dynamic URL overrides |
API Layer¶
| File | Purpose |
|---|---|
ixsearch_api/routes/chat.py |
Extracts bookingContext from request body, passes to chatbot |
Lead Capture Webhook & Analytics¶
When email is captured during the booking flow (first-time extraction only), the booking handler fires:
- Lead capture webhook (
send_lead_capture_webhook) withcapture_context="in_chat_booking"— sends visitor profile + conversation context to the configured webhook endpoint (fire-and-forget with 3 retries) - PostHog event (
track_email_capture) withrw_capture_context="in_chat_booking"— tracksrw_email_capturedevent for funnel analytics
Both are wrapped in try/except so failures never break the booking flow.
This only fires when extracted_email is set (email freshly provided by the visitor). When the email was already known (returning visitor), no webhook is sent since it was already triggered during the original capture.
capture_context Values¶
| Value | Trigger | Source Node |
|---|---|---|
in_chat_booking |
Email captured during CTA booking flow | booking_handler |
gated_content |
Email captured after content gating offer (💌 emoji) | visitor_profiler |
organic |
Email captured during normal conversation | visitor_profiler |
Cross-Session Persistence¶
Email and visitor profile data persist across sessions via Supabase:
- Saving: When email is captured during booking, it's stored in the Supabase
visitorstable - Loading: On new session start,
get_visitor_profile_data()loads saved profile data - Merging: Loaded data merges into
visitor_profileif the current session has no data - Effect: Returning visitors skip email collection — they get instant confirmation + redirect
Debugging¶
Backend Logs¶
Booking handler logs use the 📧 [BOOKING HANDLER] prefix:
📧 [BOOKING HANDLER] Starting booking flow handling
📧 [BOOKING HANDLER] cta_id=demo_cta, has_email=false, was_awaiting=false
📧 [BOOKING HANDLER] Extracted email from user input
📧 [BOOKING HANDLER] Email already known, generating confirmation via LLM
📡 Sending lead capture webhook for booking email capture (session: ..., site: ..., context: in_chat_booking)
✅ Lead capture webhook sent successfully to ...
✅ Tracked email capture in PostHog: person=..., email=..., session=...
✅ [BOOKING HANDLER] Generated confirmation response (85 chars)
Intent router logs:
📧 Routing to booking_handler for email collection flow
🚪 User exiting booking flow - no email provided, routing based on intent
Cross-session persistence logs:
📋 Loaded visitor profile data from Supabase: fields=['email', 'company_name']
[Visitor Profile] Loaded data for person_id=abc12345...: fields=['email', 'company_name']
Common Issues¶
| Issue | Cause | Solution |
|---|---|---|
| Booking flow not starting | inchat_booking flag disabled |
Check isInchatBookingEnabled(domain) and site config |
| Booking flow not starting | Missing inchat_booking flag |
Check isInchatBookingEnabled(domain) and site config |
| Email not extracted | Invalid format | Check regex in email_extraction.py |
| Stuck in awaiting state | visitor_profile not updating from backend |
Check completion event includes visitor_profile |
| Redirect not happening | Streaming not complete | Redirect waits for isComplete before triggering |
| Bot outputs URL | Prompt guardrail failure | Check db_client_guardrails and strip_urls=True in history formatting |
| CTA visible during booking | forceHideCTAs not set |
Should come from flowDerived.bookingDetails?.isAwaitingEmail |
| Top bar doesn't show the pending field prompt | BookingTopBar not receiving pendingFieldId |
Check ExpandedChatView passes the first pending_booking_fields entry; strings live in getBookingFieldPromptText |
| Booking blocked | Form assistant mode | Expected — user is already on CTA destination page |
| User refuses but booking persists | Classifier emitted wrong intent | Verify STOP_BOOKING fires — check intent_classifier booking guidance and the determine_next_action Priority 0 branch |
| Visitor's product question ignored (stuck in email re-ask) | PAUSE branch not firing | Verify classifier returned LEARN/CONTEXT; check paused_in_booking is set on router output; check {rt_booking_transition_instruction} is rendered in the answer writer |
| Frontend stuck in booking mode | Safety valve not triggered | After MAX_BOOKING_TURNS (5) turns, frontend redirects without email |
Langfuse Traces¶
| Trace Name | Description |
|---|---|
agent-booking-handler |
Main booking handler span |
llm-booking-handler |
LLM call for email collection |
llm-booking-confirmation |
LLM call for confirmation message |
Metadata fields: booking_flow (email_collection, immediate_redirect, or email_collection_fallback), has_email, model_used.
Testing¶
Backend¶
cd backend
poetry run pytest packages/ixchat/tests/test_booking_handler.py -v
poetry run pytest packages/ixchat/tests/test_email_extraction.py -v
poetry run pytest packages/ixchat/tests/test_graph_structure.py -v -k booking
Frontend¶
Manual Preprod Testing¶
- Open preprod UI (
localhost:3003) - Enable "Force In-Chat Booking" toggle
- Open the widget and interact until a CTA button appears
- Click the CTA — verify bot asks for email (not direct navigation)
- Provide an email — verify redirect to booking URL with
?email=parameter
Interaction with Other Features¶
| Feature | Interaction |
|---|---|
| Form Field Extraction | Dynamic CTA URL overrides can change the redirect_url used by booking flow |
| Form Assistant Mode | Blocks booking flow entirely (user is on CTA destination page in copilot mode) |
| Inline Demo CTA | Bot response with 👇 emoji triggers InlineDemoCTAButton, which uses the same booking flow |
| Redirect Handler | Separate path for SUPPORT/OFFTOPIC/OTHER intents; booking exit falls through to these |
| Demo Proposal | Interest signals can trigger demo proposal independently of booking flow |
| Returning Visitor | Cross-session email persistence means returning visitors skip email collection |
Related¶
- Form Field Extraction — Dynamic CTA URL pattern
- Redirect Handler — Non-product intent handling
- IXChat Package — LangGraph architecture
- Skill System — How prompts are organized
- Preprod Testing — Testing feature flags