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

System Architecture

flowchart TD A["Visitor message / CTA click"] --> B["system_router"] B --> C["Agentic 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"] style G fill:#e1f5fe,stroke:#0288d1,stroke-width:2px

Key points:

  • Booking handler skips RAG and enrichment — no knowledge base retrieval needed for email collection, saving ~300-500ms
  • 3-way routingintent_router routes to ANSWER/REDIRECT/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{"All pending\nfields known?"} CHECK_EMAIL -->|"Yes"| CONFIRM["Bot: brief\nconfirmation"] CHECK_EMAIL -->|"No"| ASK_FIELD["Bot: asks for\nnext pending field"] ASK_FIELD --> CLASSIFY["intent_classifier\n(booking-aware)"] CLASSIFY -->|"BOOKING / field answer"| STAY["STAY → collect\nnext field"] CLASSIFY -->|"LEARN / CONTEXT"| PAUSE["PAUSE → answer\nthen soft re-ask"] CLASSIFY -->|"STOP_BOOKING"| EXIT_REFUSAL["EXIT → 'no worries'\nreset booking_state"] CLASSIFY -->|"OFFTOPIC / SUPPORT /\nOTHER / HACK"| EXIT_OTHER["EXIT → handle\nintent, reset state"] CLASSIFY -->|"Invalid email"| RETRY["Bot: gentle\ncorrection"] STAY --> CHECK_EMAIL PAUSE --> ASK_FIELD RETRY --> ASK_FIELD 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 style PAUSE fill:#fff3e0,stroke:#f57c00 style EXIT_REFUSAL fill:#fce4ec,stroke:#c2185b style EXIT_OTHER fill:#fce4ec,stroke:#c2185b

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)

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: {pending_booking_fields: ["email", ...]}} 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: pending_booking_fields=["email", ...]) Graph->>IR: intent_router (booking active, "@" detected via _is_field_answer_for_pending) IR-->>Graph: Route → BOOKING (P0 STAY: field answer) 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: {collected_booking_fields: {"email": ...}}, 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: {collected_booking_fields: {"email": ...}}} 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

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
stateDiagram-v2 [*] --> Inactive: No booking flow Inactive --> Active: CTA click (BookingContext received) Active --> Active: STAY — field answer / BOOKING intent (collect next) Active --> Active: Invalid email (retry, email_attempts++) Active --> Paused: PAUSE — LEARN/CONTEXT mid-flow Active --> Inactive: EXIT — STOP_BOOKING (refusal) Active --> Inactive: EXIT — SUPPORT/OFFTOPIC/OTHER/HACK Active --> Inactive: Max email attempts (>= 3) Active --> Completed: All pending fields collected Paused --> Active: Next turn (booking_state preserved, re-ask fires) Paused --> Inactive: EXIT on next turn Paused --> Completed: Visitor provides field on next turn Completed --> [*]: Frontend redirects note right of Active is_booking_flow = true pending_booking_fields ≠ [] paused_in_booking = false just_exited_booking = false end note note right of Paused is_booking_flow = true pending_booking_fields preserved paused_in_booking = true paused_booking_pending_field = <field> end note note left of Inactive On EXIT: - booking_state reset - interest_signals reset_cumulative() - just_exited_booking = true (for this turn only) 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

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:

stateDiagram-v2 direction LR [*] --> Collecting Collecting --> Collecting: Field captured → ask next Collecting --> Paused: LEARN / CONTEXT Collecting --> Exited_Refusal: STOP_BOOKING Collecting --> Exited_Incompatible: SUPPORT / OFFTOPIC / OTHER / HACK Collecting --> Done: All fields collected Paused --> Collecting: Visitor provides field Paused --> Paused: Another product question Paused --> Exited_Refusal: STOP_BOOKING Paused --> Exited_Incompatible: Other incompatible intent Exited_Refusal --> [*]: "No worries" ack,<br/>no re-ask Exited_Incompatible --> [*]: redirect_handler<br/>(support / offtopic) Done --> [*]: Frontend redirects<br/>(?email=...)

Three coordinated pieces make this work:

  1. Booking-aware classifier (intent_classifier.py:_build_booking_classifier_guidance) — when booking is active, the classifier prompt is enriched with rules that map refusals to STOP_BOOKING, product questions to LEARN, and short option-label values (e.g. "11-50", "just myself") to BOOKING so they're not misclassified as off-topic.

  2. Three-branch router (intent_router.py:determine_next_action) — at Priority 0, chooses STAY / PAUSE / EXIT and emits the just_exited_booking / paused_in_booking / paused_booking_pending_field state fields.

  3. 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
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:

  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 — 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:

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

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. Open the widget and interact until a CTA button appears
  4. Click the CTA — verify bot asks for email (not direct navigation)
  5. 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