> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstflow.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Triggers & audience

> How FirstFlow decides when an experience fires and who is eligible the trigger, condition, audience, schedule, and frequency gates the backend evaluates server-side before pushing a widget.

Every experience carries a set of eligibility gates: a **trigger** (the event that makes it a candidate), optional **conditions** (trait/answer tests), an **audience** (who qualifies), a **schedule** (when the window is open), and a **frequency** cap (how often a given user sees it). FirstFlow evaluates all of these on the backend; the browser SDK never decides it only reports activity and renders the finished widget that arrives.

This is why you cannot force a widget to appear from client code. You call [`notifyActivity()`](/react/use-firstflow) 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 in `ConversationRouterService`. An event reaches the backend two ways: the [server SDK](/server/overview) 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](/concepts/flows-and-nodes), composed into a [`WidgetTree`](/concepts/widgets-and-blocks) by Anthropic (`claude-sonnet-4-6`), and pushed over Socket.IO.

```mermaid theme={null}
flowchart TD
  A["Event reaches backend<br/>(observed message · chat open · idle · custom event)"] --> B{"Derive trigger type"}
  B --> C["Match settings.triggers[].when.show_on"]
  C --> D{"Conditions<br/>traits.* / answers.*"}
  D -- pass --> E{"Audience<br/>all / segment"}
  D -- fail --> X["Not eligible"]
  E -- pass --> F{"Schedule window<br/>open?"}
  E -- fail --> X
  F -- yes --> G{"Frequency cap<br/>not exceeded?"}
  F -- no --> X
  G -- ok --> H["Sort survivors by priority<br/>(lower number wins)"]
  G -- capped --> X
  H --> I["Walk flow → Result node"]
  I --> J["Compose WidgetTree<br/>(Anthropic claude-sonnet-4-6)"]
  J --> K["Push widget.show over Socket.IO"]

  classDef gate fill:#0d9488,stroke:#0f766e,color:#fff;
  classDef terminal fill:#7c3aed,stroke:#5b21b6,color:#fff;
  classDef reject fill:#dc2626,stroke:#991b1b,color:#fff;
  class B,C,D,E,F,G gate;
  class H,I,J,K terminal;
  class X reject;
```

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

```ts theme={null}
type ExperienceSettings = {
  triggers: ExperienceTriggerRow[];          // one or more trigger rows
  device: "all" | "desktop" | "mobile";
  audience: { type: "all" | "segment"; segment_id?: string };
  schedule: {
    start_type: "now" | "scheduled"; starts_at?: string; timezone?: string;
    expiration_type: "none" | "scheduled"; expires_at?: string;
  };
  frequency: { type: "once" | "limited" | "always"; limit?: number; period?: "session" | "day" | "week" | "lifetime" };
  priority: number;
};

type ExperienceTriggerRow = {
  id: string;
  label?: string;
  when: ExperienceTriggerWhen;               // the event + optional classifier
  conditions: ExperienceTriggerCondition[];  // trait/answer tests, AND/OR
  actions: ExperienceLogicAction[];          // side effects (webhook, segment, …)
};
```

You never hand-write this JSON. You configure it in the experience's settings sidebar in the dashboard, and FirstFlow persists the validated `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 in `settings.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`.

| `show_on`                      | Fires when                                            | Scope        |
| ------------------------------ | ----------------------------------------------------- | ------------ |
| `chat_opens`                   | The chat surface opens (or reopens on reload).        | user         |
| `after_user_message`           | The server SDK observes a user message.               | conversation |
| `after_user_and_agent_message` | After a user message **and** the agent's reply.       | conversation |
| `after_idle`                   | The user is inactive for `delay_seconds`.             | user         |
| `custom_event`                 | Your app emits the named `custom_event_name`.         | user         |
| `conversation_classifier`      | An LLM gate decides from recent conversation context. | conversation |

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`](/concepts/conversations-and-sessions).

A trigger's `when` carries a couple of event-specific fields:

<ParamField path="when.delay_seconds" type="number">
  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.
</ParamField>

<ParamField path="when.custom_event_name" type="string">
  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.
</ParamField>

An experience can hold several trigger rows. Each row is an independent OR-path into the experience any one matching row (with its own conditions) makes the experience a candidate.

### Classifier triggers

A `conversation_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`:

<ParamField path="when.classifier.instructions" type="string">
  Natural-language description of what the experience should fire on. This is the
  prompt the gate reasons against.
</ParamField>

<ParamField path="when.classifier.provider" type="&#x22;openai&#x22; | &#x22;anthropic&#x22;">
  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`).
</ParamField>

<ParamField path="when.classifier.confidenceFloor" type="number">
  Minimum confidence to count as a pass. Below the floor, the gate does not fire.
</ParamField>

<ParamField path="when.classifier.contextWindowTurns" type="number">
  How many recent conversation turns the model sees as context, `1`–`10`.
</ParamField>

<ParamField path="when.classifier.signalMask" type="object">
  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.
</ParamField>

<ParamField path="when.classifier.ttlMs" type="number">
  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.
</ParamField>

<ParamField path="when.classifier.resolutionMode" type="&#x22;blocking&#x22; | &#x22;optimistic&#x22;">
  `blocking` waits for the decision before composing the widget; `optimistic`
  proceeds and reconciles, trading strictness for latency.
</ParamField>

The classifier is a gate on a trigger, not the same thing as the AI-driven **Classify** node inside a flow. The trigger classifier decides whether the experience is eligible at all; a [Classify node](/concepts/flows-and-nodes) decides which branch a flow takes once it is already running.

## Conditions & operators

Conditions narrow a trigger after it matches. Each row in `when`'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`](/react/identity) prop or the server [`identify()`](/server/analytics) helper) and prior survey **answers** (`answers.<questionId>`).

```ts theme={null}
type ExperienceTriggerCondition = {
  id: string;
  fieldPath: string;               // "traits.plan" | "answers.q_role" | …
  operator: ExperienceRuleOperator;
  value: string;
  logicalOperator?: "&&" | "||";   // joins this row to the next
  hasOpeningParen?: boolean;       // grouping
  hasClosingParen?: boolean;
};
```

Rows combine with `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:

| Operator                    | Meaning                    |
| --------------------------- | -------------------------- |
| `equals` / `not_equals`     | Exact (in)equality.        |
| `contains` / `not_contains` | Substring or membership.   |
| `in` / `not_in`             | Value is (not) in a set.   |
| `gt` / `gte` / `lt` / `lte` | Numeric comparisons.       |
| `exists` / `not_exists`     | Field is present / absent. |

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](/concepts/audience).

Segments are groups of end-users defined by trait rules and computed by the backend's `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`](/react/identity) prop or the server [`identify()`](/server/analytics) 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:

| `frequency.type` | Behavior                                                                         |
| ---------------- | -------------------------------------------------------------------------------- |
| `once`           | Show one time, ever.                                                             |
| `limited`        | Show up to `limit` times per `period` (`session`, `day`, `week`, or `lifetime`). |
| `always`         | No cap re-show whenever eligible. Guides typically use this.                     |

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

<Warning>
  Priority is **lowest-first**, not highest-first. If two experiences compete and
  one always wins unexpectedly, check that its `priority` number is lower than the
  other's a higher number loses.
</Warning>

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

## Next

Triggers select an experience; from there a [flow](/concepts/flows-and-nodes) decides what the widget says, and the result is a [WidgetTree of blocks](/concepts/widgets-and-blocks). To shape *who* qualifies, define a [segment](/concepts/audience); to understand how runs are scoped across reloads, see [Conversations & sessions](/concepts/conversations-and-sessions).
