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_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
- Both systems support booking — NEW uses
intent_router(3-way: ANSWER/REDIRECT/BOOKING), LEGACY useslegacy_intent_router(2-way: ANSWER/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 | 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)¶
Email Known (Returning Visitor)¶
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) |
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¶
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
Related Flags¶
| 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:
- 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 — 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:
- 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 |
| 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¶
Manual Preprod Testing¶
- Open preprod UI (
localhost:3003) - Enable "Force In-Chat Booking" toggle
- Optionally toggle "Force New Agentic System" to test with NEW vs LEGACY system
- 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