Skip to content

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 works on both the NEW and LEGACY agentic systems. The LEGACY system routes through legacy_intent_router to the shared booking_handler node.

When inchat_booking is disabled, CTA buttons navigate directly to their target URL (with ?email= appended if email is already known). This works regardless of which backend system is active.

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_COLLECTING active flow — widget is collecting form data via suggested answers

System Architecture

flowchart TD A["Visitor message / CTA click"] --> B["system_router"] B -->|"enable_new_agentic_system = true"| C["NEW Agentic System"] B -->|"legacy"| D["LEGACY System"] C --> E["[parallel]\nintent_classifier\ninterest_signals_detector\nskill_selector"] E --> F["intent_router"] F -->|"BOOKING"| G["booking_handler"] F -->|"ANSWER"| H["enricher → retrieval → answer_writer"] F -->|"REDIRECT"| I["redirect_handler"] G --> J["finalize"] I --> J H --> K["post-processing → finalize"] D --> L["[parallel]\nlegacy_intent_classifier\nlegacy_interest_signals"] L --> P["legacy_intent_router"] P -->|"BOOKING"| G P -->|"ANSWER"| M["enricher → retrieval → legacy_answer_writer"] M --> N["post-processing → finalize"] style G fill:#e1f5fe,stroke:#0288d1,stroke-width:2px style P fill:#e1f5fe,stroke:#0288d1,stroke-width:1px

Key points:

  • Booking handler skips RAG and enrichment — no knowledge base retrieval needed for email collection, saving ~300-500ms
  • Both systems support booking — NEW uses intent_router (3-way: ANSWER/REDIRECT/BOOKING), LEGACY uses legacy_intent_router (2-way: ANSWER/BOOKING)
  • Redirect and booking paths both cancel pending retrieval tasks for faster response

User Flows

flowchart TD START["Visitor interacts with widget"] --> CTA_CLICK{"CTA button\nclicked?"} CTA_CLICK -->|"Yes"| CHECK_FE{"inchat_booking\nenabled?"} CTA_CLICK -->|"No"| NL{"Visitor says\n'book a demo'"} CHECK_FE -->|"Yes"| CHECK_FORM{"Form assistant\nmode?"} CHECK_FE -->|"No"| DIRECT["Direct navigation\nto CTA URL"] CHECK_FORM -->|"Yes"| COPILOT["Copilot behavior\n(no booking)"] CHECK_FORM -->|"No"| CHECK_EMAIL{"Email\nknown?"} CHECK_EMAIL -->|"Yes"| CONFIRM["Bot: brief\nconfirmation"] CHECK_EMAIL -->|"No"| ASK_EMAIL["Bot: asks\nfor email"] ASK_EMAIL --> USER_RESP{"Visitor\nresponse"} USER_RESP -->|"Valid email"| CONFIRM USER_RESP -->|"Invalid email\n(@ present)"| RETRY["Bot: gentle\ncorrection"] USER_RESP -->|"Unrelated\nquestion"| EXIT["Exit booking →\nnormal flow"] RETRY --> USER_RESP CONFIRM --> REDIRECT["Redirect to booking URL\nwith ?email=..."] NL --> INTENT["Intent classified\nas BOOKING"] INTENT --> HAS_CTA{"Has redirect_url\nfrom CTA?"} HAS_CTA -->|"Yes"| CHECK_EMAIL HAS_CTA -->|"No"| ANSWER["Answer flow\n(guides to CTA)"] DIRECT --> NAV_URL["Navigate to URL\n(+ ?email if known)"] style REDIRECT fill:#e8f5e9,stroke:#2e7d32 style NAV_URL fill:#e8f5e9,stroke:#2e7d32

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 User exits booking Visitor asks unrelated question while awaiting email → graceful exit to normal flow
5 Returning visitor Email loaded from Supabase on session start → treated as "email known"
6 Natural language booking "I want to book a demo" → BOOKING intent but no redirect_url → answer flow guides to CTA
7 Inline demo CTA Bot response includes 👇 emoji → inline CTA button shown → same booking flow
8 Booking disabled inchat_booking flag off → CTA does direct navigation (any backend)
9 Booking + legacy backend legacy_intent_router routes to booking_handler — works the same as NEW system
10 Form assistant mode User on CTA destination page → booking flow blocked entirely
11 Max email attempts After 3 invalid email attempts → graceful exit with direct redirect
12 Max booking turns (frontend) After 5 booking turns without email → frontend safety valve redirects without email

Sequence Diagrams

Email Unknown (Happy Path)

sequenceDiagram participant User participant FE as Frontend participant API as FastAPI participant Graph as LangGraph participant IR as intent_router participant BH as booking_handler participant LLM as LLM Note over User,FE: Turn 1: CTA Click User->>FE: Clicks "Book a Demo" CTA FE->>FE: isInchatBookingEnabled() → true FE->>FE: dispatch(START_BOOKING) FE->>FE: Hide CTAs FE->>API: POST /query/stream {bookingContext: {is_booking_flow, cta_id, redirect_url}} API->>Graph: Invoke with booking_state Graph->>IR: intent_router (is_booking_flow=true) IR-->>Graph: Route → BOOKING (P0: active booking) Graph->>BH: booking_handler BH->>BH: Cancel pending retrieval BH->>BH: _has_valid_email() → false BH->>LLM: [booking/template] Ask for email LLM-->>BH: "To get you booked in, could you share your email?" BH-->>API: {response, booking_state: {awaiting_email: true}} API-->>FE: Stream response + completion event Note over User,FE: Turn 2: Email Provided User->>FE: Types "john@acme.com" FE->>API: POST /query/stream {query: "john@acme.com"} API->>Graph: Invoke (booking_state: awaiting_email=true) Graph->>IR: intent_router (awaiting_email=true, "@" in input) IR-->>Graph: Route → BOOKING (P0: continuing flow) Graph->>BH: booking_handler BH->>BH: extract_email_from_text() → "john@acme.com" BH->>BH: visitor_profile.email = "john@acme.com" BH->>BH: send_lead_capture_webhook(capture_context="in_chat_booking") BH->>BH: track_email_capture(capture_context="in_chat_booking") BH->>LLM: [booking/confirmation-template] LLM-->>BH: "Perfect! Redirecting you now." BH-->>API: {response, booking_state: {email_collected: true}, visitor_profile} API-->>FE: Stream response + completion event Note over FE: useEffect detects email in visitorProfile FE->>FE: Wait 1s (read confirmation) FE->>FE: appendEmailToUrl(redirectUrl, email) FE->>User: Redirect to booking URL

Email Known (Returning Visitor)

sequenceDiagram participant User participant FE as Frontend participant API as FastAPI participant Graph as LangGraph participant BH as booking_handler participant LLM as LLM participant Supabase Note over FE,Supabase: Session start FE->>API: Session init API->>Supabase: get_visitor_profile_data(person_id) Supabase-->>API: {email: "john@acme.com", company_name: "Acme"} API-->>FE: visitor_profile merged into state Note over User,FE: CTA Click User->>FE: Clicks "Book a Demo" FE->>FE: dispatch(START_BOOKING) FE->>API: POST /query/stream {bookingContext: {...}} API->>Graph: Invoke with booking_state + visitor_profile Graph->>BH: booking_handler BH->>BH: _has_valid_email() → true (email from profile) BH->>LLM: [booking/confirmation-template] LLM-->>BH: "On it! Redirecting you now." BH-->>API: {response, booking_state: {email_collected: true}} API-->>FE: Stream response + completion event FE->>FE: Email already known → wait 1s FE->>User: Redirect to booking URL with ?email=john@acme.com

State Machine

Backend: BookingState

Field Type Description
is_booking_flow bool Whether current message is part of a booking flow
awaiting_email bool Whether waiting for visitor to provide email
email_collected bool Whether email has been successfully captured
cta_id str \| None CTA ID that triggered the flow
redirect_url str \| None Target URL to redirect after collection
email_attempts int Number of email collection attempts (incremented on invalid email, max 3)
stateDiagram-v2 [*] --> Inactive: No booking flow Inactive --> FirstTurn: CTA click (BookingContext received) FirstTurn --> AwaitingEmail: booking_handler asks for email FirstTurn --> EmailCollected: Email already known (skip collection) AwaitingEmail --> AwaitingEmail: Invalid email (retry, email_attempts++) AwaitingEmail --> EmailCollected: Valid email extracted AwaitingEmail --> Inactive: User exits (no @ in input) AwaitingEmail --> Inactive: Max attempts reached (email_attempts >= 3) EmailCollected --> [*]: Frontend redirects note right of FirstTurn is_booking_flow = true awaiting_email = false email_collected = false end note note right of AwaitingEmail is_booking_flow = true awaiting_email = true email_collected = false end note note right of EmailCollected is_booking_flow = true awaiting_email = false email_collected = true end note

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;
      };
stateDiagram-v2 [*] --> ANSWERING ANSWERING --> BOOKING: CTA click (START_BOOKING) ANSWERING --> FORM_COLLECTING: Enter form collection ANSWERING --> REDIRECTING: Enter redirecting BOOKING --> ANSWERING: Redirect complete (COMPLETE_BOOKING) BOOKING --> ANSWERING: User exits (CANCEL_BOOKING) FORM_COLLECTING --> ANSWERING: Form done REDIRECTING --> ANSWERING: Redirect done
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

Both systems use the same intent_router logic (wired as intent_router in NEW, legacy_intent_router in LEGACY) with a priority-based routing algorithm:

Priority Condition Route Description
P0 is_booking_flow && !email_collected && !awaiting_email BOOKING First turn of booking flow — ask for email
P0 awaiting_email && "@" in input BOOKING Continuing flow — visitor providing email
P0-exit awaiting_email && "@" not in input (fall-through) User exits booking — route based on intent
P1 intent == BOOKING && redirect_url exists BOOKING Natural language booking with CTA context
P1-skip intent == BOOKING && !redirect_url ANSWER Booking intent without CTA — guide to click CTA
P2 intent == SUPPORT/OFFTOPIC/OTHER REDIRECT Blocking intents handled immediately
P3 Interest score >= demo proposal threshold ANSWER Demo proposal when interest is high
P4 intent == CONTEXT/LEARN ANSWER Normal product intents

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
Flag Check Function Effect on Booking
enable_new_agentic_system _is_new_agentic_system() Not required for booking — booking works on both NEW and LEGACY systems
inchat_booking isInchatBookingEnabled(domain) Required — controls whether CTAs trigger email collection
inline_cta isInlineCTAEnabled(domain) Controls inline demo CTA buttons (which also trigger booking flow)
form_assistant isFormAssistantEnabled(domain) When active on CTA pages, blocks booking flow entirely

Preprod Testing Toggles

The preprod UI provides two independent toggles:

Toggle localStorage Key Purpose
Force New Agentic System rose_preprod_new_agentic_system_override Overrides backend system selection
Force In-Chat Booking rose_preprod_inchat_booking_override Overrides frontend booking flag

Only the In-Chat Booking toggle is required for booking testing. The New Agentic System toggle controls which system handles the flow (NEW uses intent_router, LEGACY uses legacy_intent_router).

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:

  1. NEVER include URLs, links, or page addresses in the response
  2. **NEVER tell the visitor to "visit", "go to", or "click" any page
  3. 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 — email collection + confirmation
ixchat/nodes/intent_router.py Priority-based routing to booking_handler
ixchat/graph_structure.py Graph definition with BOOKING path in both systems
ixchat/pydantic_models/booking_state.py BookingState model
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 email 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/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:

  1. Lead capture webhook (send_lead_capture_webhook) with capture_context="in_chat_booking" — sends visitor profile + conversation context to the configured webhook endpoint (fire-and-forget with 3 retries)
  2. PostHog event (track_email_capture) with rw_capture_context="in_chat_booking" — tracks rw_email_captured event 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:

  1. Saving: When email is captured during booking, it's stored in the Supabase visitors table
  2. Loading: On new session start, get_visitor_profile_data() loads saved profile data
  3. Merging: Loaded data merges into visitor_profile if the current session has no data
  4. 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
Booking blocked Form assistant mode Expected — user is already on CTA destination page
User exits but booking persists Exit logic not firing Check "@" not in current_input condition in determine_next_action
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

cd frontend
pnpm vitest run shared/src/utils/widget/conversationFlowReducer

Manual Preprod Testing

  1. Open preprod UI (localhost:3003)
  2. Enable "Force In-Chat Booking" toggle
  3. Optionally toggle "Force New Agentic System" to test with NEW vs LEGACY system
  4. Open the widget and interact until a CTA button appears
  5. Click the CTA — verify bot asks for email (not direct navigation)
  6. 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