Engineering

Getting Started with the Signals Visitor Intelligence API

Install two packages, call configure({ apiKey }) first, and read the current visitor's intent, frustration, bot classification, and decision stage from plain page code — with a fail-open path for when the network isn't there.

July 2026 • 8 min read • Developer preview (0.1.0-alpha)

@clickstreamhq/signals is a developer preview: it's published to npm on the 0.1.0-alpha line under the alpha dist-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/sdk is 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 });

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:

FieldRangeWhat it means
intent0–100Purchase intent; 70+ is the high-intent band
frustration0–10060+ = struggling, consider intervening
engagement0–100Session engagement
value0–100Long-term value prediction
churn0–100Churn risk; 60+ = at risk
abandonment0–100Abandonment probability right now
conversionReadiness0–100Combines intent with session signals
sessionMomentum−100…100Positive = improving engagement trend
confusion0–100Lost, overwhelmed, searching, stuck
emotionalState8 statescurious, engaged, frustrated, confused, excited, decisive, hesitant, neutral
decisionStage5 stagesbrowsing, 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:

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.

Read Your First Visitor

Install the pixel, configure the Signals developer preview, and gate one element on a real score. If the read fails, your page renders anyway — that's the point.

Start free