Qualification Flow & CTA Blocking¶
Overview¶
The qualification flow gates CTA proposals (demo offers) behind profiling questions. When a site configures profiling fields with blocks_cta: true, the system first tries to collect those fields before proposing a CTA, regardless of interest score.
This gate is not absolute anymore: if the context-gathering question window is saturated, the system releases the CTA gate and lets demo_offer through to avoid a dead end where the visitor gets neither more questions nor a CTA.
This mechanism interacts with several graph nodes: skill_selector, skill_applier, profile_extractor, interest_signals_detector, and dialog_state_extractor.
How It Works¶
Configuration¶
Qualification fields are configured per site in the qualification config slug:
{
"profiling": {
"field_definitions": {
"company_stage": {
"question_template": "What stage is your company at?",
"type": "predefined",
"options": [
{"label": "Pre-idea / exploring"},
{"label": "Have a team or an idea"},
{"label": "Have revenue / traction"}
]
},
"business_or_technical_profile": {
"question_template": "Are you a business or technical profile?",
"type": "predefined",
"options": [
{"label": "Business"},
{"label": "Technical"}
]
}
},
"forms": {
"default": {
"type": "pre_conversion",
"cta_id": "apply-to-start",
"field_groups": [
{
"label": "Qualification",
"fields": ["company_stage", "business_or_technical_profile"]
}
],
"cta_blocking_fields": ["company_stage", "business_or_technical_profile"]
}
}
}
}
Key config pieces:
| Property | Type | Description |
|---|---|---|
field_definitions.<field_id>.question_template |
str |
The question text shown to the LLM |
field_definitions.<field_id>.options |
list[{label, value?}] |
Single-choice options shown inline with the question |
forms.<form_id>.field_groups[*].fields |
list[str] |
Ordered field IDs to ask |
forms.<form_id>.cta_blocking_fields |
list[str] |
Field IDs the system tries to collect before the CTA is shown; if the question window is saturated, the CTA gate is released |
Emoji Markers¶
The system uses Unicode emoji as machine-readable markers in LLM output. These are invisible to visitors (stripped by the frontend) but detected by dialog_state_extractor:
| Emoji | Unicode | Meaning | Detected by |
|---|---|---|---|
| 👉 | U+1F449 |
Qualification question asked | profile_extractor, E2E evaluator |
| 👇 | U+1F447 |
CTA / demo proposed | dialog_state_extractor |
| 💌 | U+1F48C |
Email asked (content gating) | dialog_state_extractor |
Step-by-Step Execution¶
Turn 1: Visitor expresses intent¶
"I want to apply"
interest_signals_detector— Detects explicit booking signals,cumulative_scoreincreases (may cross threshold)skill_selector(LLM) — Suggestsdemo_offerbecauseshould_propose_demo=Trueskill_applier— Rule_rule_defer_demo_for_cta_blocking()fires:- Checks
has_unresolved_cta_blocking_fields(profiling_state)→True(no fields collected yet) - Replaces
demo_offerwithcontext_gathering - Injects
qualification_gathering_rulesinto prompt
- Checks
- Answer writer — Asks first qualification question: > "👉 What stage is your company at? (Options: Pre-idea / exploring, Have a team or an idea, Have revenue / traction)"
profile_extractor— No answer to extract yet
Turn 2: Visitor answers Q1¶
"I'm exploring an idea"
profile_extractor— Extracts answer, maps to field IDcompany_stageskill_applier— Still blocked (secondblocks_ctafield uncollected)- Answer writer — Asks second qualification question: > "👉 Are you a business or technical profile?"
Turn 3: Visitor answers Q2¶
"Technical"
profile_extractor— Extracts answer, maps to field IDbusiness_or_technical_profileskill_applier— Allblocks_ctafields now collected →cta_blocked=Falsedemo_offer— Now allowed. Bot proposes CTA: > "👇 You can apply using the button below."
Saturated Window Fallback¶
If demo_offer is selected while cta_blocked=True, but context_gathering is already forbidden because the recent-turn window is full, the system does not keep deferring forever.
Instead, skill_applier releases the CTA gate for that turn and allows demo_offer through:
interest_signals_detector—should_propose_demo=Trueskill_applier— Sees unresolvedblocks_ctafields, but also seescontext_gathering_availability == "forbidden"- CTA gate release —
demo_offeris kept instead of being replaced withcontext_gathering - Answer writer — Proposes the CTA with 👇 even though some
blocks_ctafields are still missing
CTA Blocking Logic¶
Config Helpers (agent_config.py)¶
def has_cta_blocking_fields(config: ConfigResolver | None) -> bool:
"""True if any form configures cta_blocking_fields."""
def has_unresolved_cta_blocking_fields(
config: ConfigResolver | None,
profiling_state: ProfilingState | None,
) -> bool:
"""Check if any cta_blocking_fields entry is missing from profiling_state.collected_values."""
Skill Applier Rule (Priority 2)¶
def _rule_defer_demo_for_cta_blocking(self):
if "demo_offer" in skills and context.cta_blocked:
# If the question window is saturated, release the CTA gate
# Otherwise replace demo_offer with context_gathering
Score Reset Exception¶
When a demo is proposed (👇 detected), the system normally resets cumulative_score to 0. But for sites with CTA-blocking fields, the reset is skipped — this ensures the demo can be re-proposed on the next turn after qualification completes, without needing new interest signals.
# dialog_state_extractor.py
if demo_was_proposed and has_cta_blocking_fields:
# Skip score reset — keep score to re-propose demo
Field ID Generation¶
Unified profiling configs normally use explicit field IDs like company_stage and
business_or_technical_profile. If a standalone ProfilingField is created
without an ID, ProfilingField.auto_generate_id() still applies the same slugification:
The slugification logic lives in ProfilingField.auto_generate_id(): lowercase, replace non-alphanumeric with _, strip leading/trailing _, truncate to 50 chars.
Key Files¶
| File | Purpose |
|---|---|
ixchat/utils/agent_config.py |
ConfigResolver helper functions — CTA blocking checks, qualification rules |
ixchat/nodes/skill_applier_rules.py |
_rule_defer_demo_for_cta_blocking() — post-processing rule |
ixchat/nodes/skill_selector.py |
Passes cta_blocked flag to LLM context |
ixchat/nodes/dialog_state_extractor.py |
Emoji detection, score reset logic |
ixchat/pydantic_models/profiling.py |
ProfilingState, ProfilingField models |
Related¶
- Interest Signals — How cumulative scoring triggers demo proposals
- Content Gating — Email collection flow using 💌 marker
- E2E Evaluation: History Reconstruction — How multi-turn qualification flows are tested