@clickstreamhq/signalsis a developer preview: it's published to npm on the 0.1.0-alpha line under thealphadist-tag. Every API in this tutorial is the real, current surface — verbatim-checkable against the package — but expect it to evolve before a stable release. Of the ClickStream packages, only@clickstreamhq/sdkis stable today.
What a Visitor Intelligence API Actually Returns
Most analytics tell you what visitors did yesterday. A visitor intelligence API answers a different question: what is this visitor doing right now, while your page can still respond? That's what ClickStream Signals is — a read endpoint (/v1/signals/:visitorId) that page JavaScript can query for the current visitor's bot classification, behavioral class, identity status, and an 11-field behavioral score snapshot. One HTTPS round trip, documented at roughly 50–150 ms.
Two properties make it different from bolting a personalization vendor onto your stack. First, it's first-party: in production the read goes to your own verified tracking domain, same-origin with the SDK's cookies. Second, it's read-only and fail-open by design: the library never installs anything, never writes events, and every recommended pattern degrades to your default page when Signals is slow, rate-limited, or blocked.
Install: Two Packages, One Pixel
npm install @clickstreamhq/sdk @clickstreamhq/signals
The split matters. @clickstreamhq/sdk is the write side — the tracking pixel that produces the events Signals scores. (It's a 344-byte loader that pulls a ~56.5 KB gzipped bundle from your own subdomain; the install guide covers the script-tag path if you'd rather not bundle it.) @clickstreamhq/signals is the read side — this tutorial. If the pixel isn't installed, there's nothing to read: the visitor has no events, so there's no snapshot.
Load the SDK before your first Signals read. With zero configuration, the signals client resolves the visitor ID through the window.clickstream bridge the full SDK installs, then falls back to the _cs_vid cookie and its localStorage copy. On CNAME first-party tracking domains the cookie is HttpOnly, so the bridge is the only zero-config path that works — another reason the SDK goes first.
configure({ apiKey }) Comes First — Always
Every read requires a prior configure() call. Skip it and getVisitor() throws SignalsNotConfiguredError with a message that tells you exactly what to do: "ClickStream Signals client is not configured. Call configure({ apiKey }) before using signals."
import { configure } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxx',
endpoint: 'https://t.example.com', // your verified first-party tracking domain
});
endpoint is optional. Omit it and the client falls back to the shared ClickStream collector (https://feynman.clickstream.com) — fine for a first experiment, but in production set it to your first-party tracking domain so reads stay same-origin with the SDK's cookies. The config also accepts pollIntervalMs (default 2,000 ms, floor 1,000), cacheTtlMs, staleTtlMs, and custom resolveVisitorId/resolveSessionId resolvers for non-standard setups.
Three Ways to Read: getVisitor, getVisitorOrNull, waitFor
The package ships three one-shot reads, and choosing the right one is most of the fail-open story:
import { getVisitor, getVisitorOrNull, waitFor } from '@clickstreamhq/signals';
// Strict: rejects with SignalsRequestError on non-success,
// SignalsNotConfiguredError if configure() never ran.
const visitor = await getVisitor();
// Fail-open: null when Signals is unavailable, rate-limited past
// the stale-reuse window, misconfigured, or blocked by the browser.
const maybeVisitor = await getVisitorOrNull();
// Conditional: resolves when every criterion is met, rejects on timeout.
const buyer = await waitFor({ intentMin: 70, isBot: false, timeoutMs: 15000 });
getVisitor()is for code that needs to know why a read failed — logging, debugging, internal tools.getVisitorOrNull()is the one to copy-paste into site personalization. If Signals can't answer, you getnull, your default page renders, and nobody notices. Billing and rate limits never block rendering — that's a design commitment, not a happy path.waitFor(criteria)polls until the context satisfies every field you pass — score minimums likeintentMinandconversionReadinessMin, plusisBot,identified,emotionalState, anddecisionStage— then resolves with the matching snapshot. Default timeout: 30,000 ms, after which it rejects.
The Visitor Shape, Field by Field
Everything hangs off one object, VisitorContext. The paths below are the real ones — worth internalizing before you write a gate:
const visitor = await getVisitorOrNull();
if (visitor) {
visitor.scores.intent; // 0-100 purchase intent; 70+ = high intent
visitor.scores.frustration; // 0-100; 60+ = struggling, intervene
visitor.bot.isBot; // boolean, network-level classification
visitor.bot.category; // e.g. 'search_crawler' — present when isBot
visitor.bot.name; // e.g. "Googlebot" — present when UA matched
visitor.bot.score; // 0-100 bot confidence
visitor.behavioralClass; // 'human' | 'suspicious' | 'likely_bot' | 'bot'
visitor.identity.status; // 'anonymous' | 'signal_identified' | 'merged'
}
The 11-Field ScoreSnapshot
visitor.scores is a curated subset of the collector's 26-model scoring output — only the scores useful inside page logic are surfaced. All eleven fields:
| Field | Range | What it means |
|---|---|---|
intent | 0–100 | Purchase intent; 70+ is the high-intent band |
frustration | 0–100 | 60+ = struggling, consider intervening |
engagement | 0–100 | Session engagement |
value | 0–100 | Long-term value prediction |
churn | 0–100 | Churn risk; 60+ = at risk |
abandonment | 0–100 | Abandonment probability right now |
conversionReadiness | 0–100 | Combines intent with session signals |
sessionMomentum | −100…100 | Positive = improving engagement trend |
confusion | 0–100 | Lost, overwhelmed, searching, stuck |
emotionalState | 8 states | curious, engaged, frustrated, confused, excited, decisive, hesitant, neutral |
decisionStage | 5 stages | browsing, evaluating, comparing, deciding, purchasing |
The scalar scores are covered in depth in Scoring Intent, Frustration, and Engagement in Real Time; the two categorical fields get their own treatment in Beyond the Score: Emotional State and Decision Stage.
Two Independent Bot Axes
visitor.bot is network-level classification: user-agent and infrastructure matching against 11 bot categories, from search_crawler to stealth_bot to kiosk. visitor.behavioralClass is a separate, behavioral verdict built from the session so far. They can disagree on purpose — automation running a clean user agent can be isBot: false but likely_bot behaviorally. For human-only actions, check both: !visitor.bot.isBot && visitor.behavioralClass === 'human'.
Helpers That Encode the Dashboard's Thresholds
Four predicates wrap the most common checks so your page-level gate matches the badge an operator sees in the dashboard. Each works two ways: pass a context you already fetched (synchronous), or call it bare and it fetches for you (returns a promise).
import { isBot, isHighIntent, isFrustrated, isIdentified } from '@clickstreamhq/signals';
const visitor = await getVisitor();
isBot(visitor); // visitor.bot.isBot
isHighIntent(visitor); // visitor.scores.intent >= 70
isFrustrated(visitor); // visitor.scores.frustration >= 60
isIdentified(visitor); // non-anonymous identity, or identify() this session
// Thresholds are exported, and overridable per call:
isHighIntent(visitor, 80);
The defaults are exported as DEFAULT_HIGH_INTENT_THRESHOLD (70) and DEFAULT_FRUSTRATED_THRESHOLD (60). If you're gating something on "high intent," use the helper rather than hand-rolling a comparison — when your team debates a threshold later, there'll be exactly one place it lives.
Subscriptions: onVisitor Polling and the Realtime Stream
One-shot reads answer "who is this visitor at page load." For state that evolves — a session climbing from browsing to deciding — subscribe instead:
import { onVisitor } from '@clickstreamhq/signals';
const subscription = onVisitor((visitor) => {
document.documentElement.dataset.decisionStage = visitor.scores.decisionStage;
});
// later: subscription.unsubscribe();
onVisitor() fires immediately with the first snapshot, then polls at the configured interval. A failed poll tick logs a console.warn and keeps going — a transient 5xx shouldn't kill a long-lived listener.
When polling isn't fast enough, subscribeVisitor() (alias: onVisitorRealtime) opens a visitor-scoped WebSocket stream. It's deliberately opt-in and worth understanding before you reach for it: realtime is Scale and above, requires a session ID, reserves 300 Signals Coverage units when it opens, and falls back to onVisitor() polling by default when the stream is capped, idle, or rejected.
import { subscribeVisitor } from '@clickstreamhq/signals';
const sub = subscribeVisitor(
(visitor) => {
if (visitor.stale) return;
if (!visitor.bot.isBot && visitor.behavioralClass === 'human' && visitor.scores.intent >= 70) {
document.documentElement.dataset.signalEffect = 'high_intent_action';
}
},
{ fallbackToPolling: true },
);
If you want the firehose server-side instead — every visitor, not just the one on the page — that's the separate Signals Feed, covered in Consume the Signals Feed: a WebSocket Subscriber in 50 Lines.
Tiers, Latency, and What a Read Costs
The parts of the surface this tutorial leaned on are broadly available: snapshot reads work on every plan, including the free Hobby tier. The full 26-model behavioral scoring set is Growth and above (Hobby still gets snapshots plus human/bot/AI labels), and the realtime stream is Scale and above. ClickStream's billing counts human pageviews, and Signals reads are metered separately as Signals Coverage — when that budget exhausts, reads don't error; the server returns a conservative placeholder context (coverageMode: 'degraded', all scores zeroed) so score gates fail closed and the page keeps rendering. The full defensive playbook for slow, stale, or absent signals is in Fail-Open Personalization.
On latency: budget roughly 50–150 ms for the round trip. That's fine after first paint, which is exactly where a browser read belongs. If you need the answer before HTML is sent, that's a server-side read — see Pre-Paint Personalization in Next.js for that pattern.
The Bottom Line
The whole getting-started path is four moves:
- Install both packages — the SDK writes events, Signals reads them.
configure({ apiKey })before any read — otherwiseSignalsNotConfiguredError, by design.- Use
getVisitorOrNull()for personalization — fail open, render the default when there's no answer. - Gate on real fields —
visitor.scores.intent,visitor.scores.frustration,visitor.bot.isBot— or better, the helpers that encode the canonical thresholds.
From here, the natural next step depends on your stack: React hooks if you're in a component tree, or the effects engine if you want declarative one-liners over raw reads.
A visitor intelligence API is only as trustworthy as its failure mode. This one's failure mode is your website, unchanged.