notifyActivity() or the server SDK observes a message, the backend runs the gates in order, and only if every gate passes does it compose a widget and push it down the socket.
How it works
Eligibility runs entirely server-side inConversationRouterService. An event reaches the backend two ways: the server SDK observes an LLM message (POST /v1/conversations/:conversationId/messages), or the browser socket emits an activity signal (chat opened, user idle, custom event). The router derives a trigger type from that event, finds experiences whose settings.triggers[] declare that trigger, then applies conditions, audience, schedule, and frequency. Survivors are sorted by priority, and the winner’s flow is walked to a Result node, composed into a WidgetTree by Anthropic (claude-sonnet-4-6), and pushed over Socket.IO.
The whole gate chain lives in the experience’s settings JSON, validated by ExperienceSettingsSchema on the backend. The shape mirrors the ExperienceSettings type in the dashboard’s api.ts:
settings. The fields below are the exact names the backend reads, so they are useful for reasoning about why an experience did or did not fire.
Triggers
A trigger is one row insettings.triggers[]. Its when.show_on names the event that makes the experience a candidate. The backend derives the trigger type from the inbound event and keeps only experiences that declare a matching show_on.
The scope column matters for how a run is tracked. Fire-on-open triggers (
chat_opens, after_idle, custom_event) run in user scope they are independent of any conversation, so a tour or announcement resumes for that user across reloads. Message-driven triggers (after_user_message, after_user_and_agent_message, conversation_classifier) run in conversation scope, where message history and classifier context must stay coherent and the run is bound to the conversationId.
A trigger’s when carries a couple of event-specific fields:
Idle delay in seconds for
after_idle, 0–300. The backend routes an
after_idle event once the browser reports the user has been inactive this long.The event name matched for
show_on: "custom_event". Your app surfaces activity
through the browser SDK; the backend matches the named event against this field.Classifier triggers
Aconversation_classifier trigger runs an LLM gate before the experience becomes eligible. Use it for intent-driven moments that a static event cannot express “offer help when the user seems stuck” or “surface the upgrade guide when they ask about limits.” The classifier reads recent turns and returns a decision the router treats as a pass/fail gate.
Its config lives in when.classifier:
Natural-language description of what the experience should fire on. This is the
prompt the gate reasons against.
Which provider resolves the classification. Pair with
model (e.g. gpt-4o-mini
or a Claude model). A key is supplied via encryptedApiKey (stored encrypted;
the dashboard exposes hasApiKey).Minimum confidence to count as a pass. Below the floor, the gate does not fire.
How many recent conversation turns the model sees as context,
1–10.Which data the model sees:
{ includeMessage?: boolean; includeConversationDigest?: boolean }.
Mask out raw message text and pass only a digest when you want to limit what the
classifier reads.How long a resolved decision is cached,
5_000–86_400_000 ms. Within the TTL
the same decision is reused instead of re-calling the model.blocking waits for the decision before composing the widget; optimistic
proceeds and reconciles, trading strictness for latency.Conditions & operators
Conditions narrow a trigger after it matches. Each row inwhen’s sibling conditions[] tests a field and either keeps or drops the candidate. Conditions target two namespaces: user traits (traits.*, supplied via the browser user prop or the server identify() helper) and prior survey answers (answers.<questionId>).
logicalOperator (&& / ||) and optional parentheses (hasOpeningParen / hasClosingParen), so you can express plan == pro && (role == admin || seats > 5). The operators are fixed by the backend schema and shared with the SDK rule engine:
Because conditions read
traits.*, targeting is only as good as the traits you send. If a trait is missing, an equals test fails and an exists test reports absent the experience simply does not fire for that user.
Audience
Audience decides who is eligible, independent of the trigger. It has two modes:- All (
audience.type: "all") every user who hits the trigger. - Segment (
audience.type: "segment"+segment_id) only users in a named segment.
audience module. If the selected segment is later deleted, targeting falls back to “all” rather than silently blocking everyone. As with conditions, segment membership depends on the traits you pass via the browser user prop or the server identify() helper.
Schedule, frequency, priority & placement
The remaining gates control the time window, repetition, and tie-breaking. Schedule opens and closes the eligibility window.start_type is now or scheduled (with starts_at), and expiration_type is none or scheduled (with expires_at). A timezone is required once either starts_at or expires_at is set, so a “9am Monday” launch means 9am in the configured zone.
Frequency caps how often a qualifying user sees the experience:
Priority breaks ties when several experiences qualify at once. It is an integer where lower numbers run first priority
0 wins over priority 1. The router sorts survivors ascending and takes the top one.
Device (device) restricts to all, desktop, or mobile. Placement (placement) controls how the widget renders in the host overlay (centered over chat), inlineStack (unified shell), composerAbove (above the composer), or pageOverlay (full-page). Placement does not affect eligibility; it only changes presentation once the widget is pushed.
Why the browser cannot trigger a widget
The browser SDK is a passive consumer.notifyActivity(), a chat-open signal, an idle timer, and a custom event all do the same thing: they report an event to the backend, which then runs the gates above and decides. The SDK never sees the trigger rules, the conditions, the audience, or the frequency state those live only on the server. This keeps targeting tamper-resistant (a user cannot edit client state to force an offer) and lets you change targeting in the dashboard without shipping new client code. The trade-off is latency: a widget appears a network round-trip after the event, not instantly.