The Two Fields That Aren't Numbers
This post completes our behavioral models series. Across ten deep-dives we walked through the 26 scoring models that ClickStream runs against every event — a pipeline held to a p95 < 3ms per-event budget as a CI-enforced benchmark. Almost everything those models produce is a number: intent 0–100, frustration 0–100, churn risk, abandonment probability, session momentum.
But the visitor snapshot that your page code actually reads — the 11-field ScoreSnapshot in @clickstreamhq/signals — contains exactly two fields that are not numbers. Straight from the published type definitions:
/** Behavioral emotional state from the current session. */
emotionalState:
| 'curious'
| 'engaged'
| 'frustrated'
| 'confused'
| 'excited'
| 'decisive'
| 'hesitant'
| 'neutral';
/** Decision stage the visitor appears to be in. */
decisionStage: 'browsing' | 'evaluating' | 'comparing' | 'deciding' | 'purchasing';
The snapshot is a curated subset of the collector's full 26-model output — only the scores that are useful inside a customer's page logic are surfaced, and internal outputs like anomaly type and next-action alternatives stay collector-side. Nine of the surfaced fields are 0–100 scores (session momentum runs -100..100). The two label fields exist because some questions don't have numeric answers. "How frustrated is this visitor?" is a number. "Is frustration the defining feature of this session, or just background noise under genuine purchase momentum?" is a classification.
Visitor Emotion Detection from Behavior, Not Biometrics
First, what visitor emotion detection analytics mean here — and what they don't. There is no camera, no microphone, no biometric input of any kind. The emotional state classifier is a pure derivation from behavioral scores and session features that the pixel already observes: dwell time, pages per session, scroll depth, navigation entropy, and the intent, frustration, engagement, confusion, and momentum scores computed by the other models. It answers one question: which emotional label best explains the behavior in this session?
The eight emotional states
| State | What the behavior looks like | Sensible response |
|---|---|---|
curious |
Engaged multi-page reading with deep scrolls and no frustration signals | Let them explore; surface related content |
engaged |
Sustained, active attention on the task at hand | Stay out of the way |
frustrated |
Frustration or confusion signals dominating the session — rage clicks, error loops, dead ends | Open a help path; do not upsell |
confused |
Wandering navigation, searching without finding | Clarify and simplify the current page |
excited |
High engagement, real intent, and accelerating session momentum all at once | Clear the path to convert; remove friction |
decisive |
Direct, purposeful movement toward a specific outcome | Shorten the route; skip the tour |
hesitant |
Stalling at commitment points — forms, pricing, checkout | Reassure: guarantees, transparent pricing |
neutral |
Nothing clears a confidence bar | Treat as "no signal," not a mild emotion |
How the classifier decides
The classification runs as a priority-ordered rule cascade over the behavioral scores — first match wins. Three properties of that design are worth understanding:
- Negative states get priority. Frustration is checked first: if the frustration or confusion score crosses its threshold, the session is labeled accordingly no matter how strong the other signals are. A visitor who is both interested and stuck needs the stuck part addressed first.
- Positive states require converging evidence.
excitedonly fires when engagement, intent, and positive session momentum are all elevated simultaneously.curiousrequires engaged multi-page reading — more than two pages, scroll depth past 60% — with frustration staying low. One strong signal alone is not enough. - The default is honest. When no rule matches with confidence, the state is
neutral— deliberately reported with low internal confidence rather than forcing a more interesting label onto ambiguous behavior.
Every classification carries a confidence value internally. The snapshot exposes only the label; the confidence stays collector-side, which is exactly why the cascade is tuned to prefer neutral over a shaky guess. The thresholds behind each rule are defaults, and like the rest of the scoring pipeline they are tunable per site — a content site's "deep engagement" is not a checkout flow's.
For the full treatment of the underlying models — including the confusion detector's behavioral signals and how confusion differs from frustration — see Part 3 of the series.
The Five Decision Stages
decisionStage answers a different question: not how the session feels, but where in the decision process the visitor appears to be. The five values — browsing, evaluating, comparing, deciding, purchasing — read like a funnel, but the field is not a funnel report. It's a live label recomputed as the session evolves, and visitors move in both directions: a surprise shipping cost can knock someone from deciding back to comparing in a single pageview.
How the stage is computed
The stage comes from the decision confidence model, which blends behavioral evidence into a single confidence estimate and then reads the stage off an escalating ladder of thresholds:
- Intent carries the most weight in the blend — by default roughly a third of it.
- Momentum evidence adds confidence: deep scrolls, multiple pages this session, a form started, an identification event.
- Form engagement is a strong promoter: completing a form contributes more than starting one, and starting one contributes more than passive reading.
- Frustration dampens everything. The blend is multiplied down as frustration rises — a visitor fighting your UI is not confidently deciding, whatever their click volume says.
- Explicit decision signals can promote a stage. High intent paired with an explicit signal — the visitor identified themselves, or explicit intent behavior near saturation — reaches the later stages even when the passive blend alone wouldn't.
All of the weights and thresholds above are defaults with per-site overrides, covered in depth in Part 4 of the series. Note that decisionStage is also distinct from the intent model's own four behavioral stages (browsing, researching, evaluating, converting): intent's stages classify how much the visitor wants an outcome, while decision stage estimates how far along the choice itself is. The two usually agree; when they diverge, the divergence is the insight.
Reading Labels and Numbers Together
The labels earn their place in the snapshot when you read them against the numbers. The same intent score means different things under different states. Consider two hypothetical visitors, both with intent at 72 — above the 70 threshold that isHighIntent() uses by default:
| Snapshot (hypothetical) | What it suggests | Playbook |
|---|---|---|
intent 72, excited, deciding |
Momentum is real and building | Do nothing clever — remove steps between them and checkout |
intent 72, frustrated, comparing |
Wants the outcome, blocked by the experience | Fix the blocker: help widget, clearer comparison page |
frustration 65, hesitant, deciding |
Close to committing but wavering at the commitment point | Reassurance: guarantee, transparent pricing, no popups |
engagement 80, curious, browsing |
Research session, not a purchase session | Capture the relationship, not the sale — newsletter, docs |
A numeric threshold alone — isFrustrated() fires at 60+, isHighIntent() at 70+ — tells you that you should react. The two labels tell you which reaction fits the session.
Using the Fields in Code
Both fields ship on visitor.scores in @clickstreamhq/signals, the developer-preview Signals client (0.1.0-alpha; the core @clickstreamhq/sdk at 1.4.0 is the stable package). If you're new to the API, start with Getting Started with the Signals API. Configure the client, then read the snapshot:
import { configure, getVisitor } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxx',
endpoint: 'https://t.example.com', // your first-party tracking domain
});
const visitor = await getVisitor();
visitor.scores.emotionalState; // 'curious' | 'engaged' | 'frustrated' | ...
visitor.scores.decisionStage; // 'browsing' | 'evaluating' | 'comparing' | ...
if (visitor.scores.emotionalState === 'frustrated' && visitor.scores.frustration >= 60) {
// surface help — this is not the moment to upsell
}
Both labels are first-class criteria across the API. waitFor accepts them, so you can await a stage transition instead of polling for it yourself:
import { waitFor } from '@clickstreamhq/signals';
// Resolves when the visitor reaches the deciding stage
// (rejects at the default 30s timeout if they never do)
const visitor = await waitFor({ decisionStage: 'deciding' });
And the effects engine takes them in declarative rules:
import { applySignals } from '@clickstreamhq/signals';
applySignals([
{
when: { emotionalState: 'frustrated', frustrationMin: 60 },
run: () => showHelpWidget(),
once: true,
},
]);
One operational detail worth knowing: before the first scored event for a fresh visitor propagates, the snapshot reports emotionalState: 'neutral' and decisionStage: 'browsing' with the snapshot flagged as pending. Those are also the values your code should treat as the safe no-op case — the same fail-open posture covered in Fail-Open Personalization. A rule keyed on frustrated or deciding simply never fires when Signals is slow, stale, or out.
Honest Limits: Inference, Not Mind Reading
Emotional state and decision stage are behavioral inferences. They deserve the same epistemically honest framing we've used throughout this series:
- The input is behavior on your site, full stop. Clicks, scrolls, navigation, dwell, forms. Nothing else exists to the classifier.
- The label can be wrong. A visitor triple-clicking because their trackpad is failing looks frustrated. Someone reading deeply while on a phone call looks disengaged. That's why classifications carry confidence internally and why the default is
neutral, not a guess. - States are session-scoped. The emotional state describes the current session's behavior. ClickStream does not build cross-site emotional profiles, and there is no "this person is an anxious buyer" attribute accumulating anywhere.
- Use it to help, not to exploit. Showing a help widget to a frustrated visitor is good UX. High-pressure tactics aimed at visitors in vulnerable states is the failure mode — don't build it, and disclose behavioral analytics in your privacy policy.
A behavioral model can tell you a visitor is stalling at your pricing page for the third time. It cannot tell you why. The label is a hypothesis about the session — treat it like one.
Series Complete: The Full Picture
With the two non-numeric fields covered, the behavioral models series is complete. The introduction maps all ten parts; the short version of where these two fields sit: 26 models run at the collector on every event, nine of their outputs surface as numeric scores in the snapshot, and these two labels compress everything the models observed into the terms a product decision actually gets made in. If you want to see your own traffic classified this way, the Signals overview is the place to start, and installation takes a single script tag.