@clickstream/signals
The Signals API exposes the ClickStream visitor snapshot as a typed TypeScript / JavaScript surface. Consumers call getVisitor() and get back a VisitorContext with:
- Bot classification + confidence score
- Behavioral class bucket (
human/suspicious/likely_bot/bot) - Identity resolution status + clickstream id
- 11 behavioral scores (intent, frustration, engagement, value, churn, abandonment, conversion readiness, session momentum, confusion, emotional state, decision stage)
- Session + device summary
Use it from:
- Browser page code via the client library
@clickstream/signals - React components via
@clickstream/reacthooks (useVisitor) - Next.js Server Components / Route Handlers / middleware via
@clickstream/next - Any HTTP client via the underlying REST endpoint
GET /v1/signals/:visitorId
If you want a server-push stream of every scored event as it happens, use the Signals Feed WebSocket (Scale+).
Install
pnpm add @clickstream/signals
Quick start
import { configure, getVisitor, isBot, isHighIntent } from '@clickstream/signals';
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
// Required. Your first-party tracking domain — see /dns for setup.
endpoint: 'https://t.example.com',
});
// Read the snapshot once
const visitor = await getVisitor();
if (isBot(visitor)) {
// AI crawler or scraper — serve structured content, skip personalization
} else if (isHighIntent(visitor)) {
// Score >= 70 — show the limited-time upsell
}
Public surface
import {
configure,
getVisitor,
onVisitor,
waitFor,
isBot,
botCategory,
behavioralClass,
isHighIntent,
isFrustrated,
isIdentified,
} from '@clickstream/signals';
configure(options)
One-time global configuration. Must be called before the first getVisitor() / onVisitor() / waitFor(). Idempotent — later calls overwrite the prior config.
configure({
apiKey: 'cs_live_...', // required
endpoint: 'https://t.x.com', // optional; defaults to production collector
pollIntervalMs: 2000, // optional; defaults to 2000 (floor 250)
});
getVisitor(): Promise<VisitorContext | null>
Reads the current visitor snapshot. Returns null when:
- The
_cs_uidcookie isn't set (pre-SDK-init, or browser declined cookies) - The visitor's DO instance has aged out (no events in the last ~30 min)
- The endpoint returned an error (logged to
console.warn)
const ctx = await getVisitor();
if (ctx) {
console.log(ctx.scores.intent); // 0–100
console.log(ctx.behavioralClass); // 'human' | 'suspicious' | ...
console.log(ctx.identity.hasIdentifiedThisSession);
}
onVisitor(callback): Subscription
Subscribes to visitor updates. Fires immediately with the current snapshot, then every time it changes — which happens on:
- Any new event the SDK sends (pageview, click, scroll, form, custom, identify)
- Score recomputation when the signals server writes a new snapshot
- Session expiration + new session start
Returns a Subscription with an .unsubscribe() method — call it to detach, otherwise you'll leak polling timers.
const sub = onVisitor((ctx) => {
if (ctx.scores.frustration >= 60) openSupportChat();
});
// later:
sub.unsubscribe();
The default poll interval is 2 seconds. Set pollIntervalMs in configure() to tune. The floor is 250 ms — setting it lower is silently clamped up.
waitFor(predicate, timeoutMs): Promise<VisitorContext>
Resolves when a condition is met, rejects after timeoutMs. Useful for "wait until intent crosses 70" / "wait until user identifies".
try {
const ctx = await waitFor(
{ intentMin: 70 },
10_000
);
// Intent is >= 70 — show the upsell modal
} catch {
// 10 seconds elapsed without crossing the threshold
}
Predicate shape:
type SignalPredicate = Partial<{
intentMin: number; // 0–100
frustrationMin: number; // 0–100
engagementMin: number;
valueMin: number;
churnMin: number;
conversionReadinessMin: number;
behavioralClass: 'human' | 'suspicious' | 'likely_bot' | 'bot';
isBot: boolean;
identified: boolean; // hasIdentifiedThisSession
emotionalState: string;
decisionStage: string;
}>;
All fields are AND-combined — waitFor({ intentMin: 70, engagementMin: 50 }) only resolves when both conditions hold.
Predicate helpers
Thin wrappers around common checks on a resolved VisitorContext. Prefer these over re-implementing the threshold logic in every component:
isBot(ctx) // ctx.bot.isBot
botCategory(ctx) // 'search_crawler' | 'ai_agent' | ... | undefined
behavioralClass(ctx) // 'human' | 'suspicious' | 'likely_bot' | 'bot'
isHighIntent(ctx) // ctx.scores.intent >= 70
isFrustrated(ctx) // ctx.scores.frustration >= 60
isIdentified(ctx) // ctx.identity.hasIdentifiedThisSession
VisitorContext shape
This is the object every read path returns. All numeric scores are on a 0–100 scale unless the field notes otherwise.
interface VisitorContext {
bot: {
isBot: boolean;
score: number; // 0–100, higher = more bot-like
category?: string; // 'search_crawler', 'ai_agent', …
name?: string; // 'Googlebot', 'ChatGPT', …
};
behavioralClass: 'human' | 'suspicious' | 'likely_bot' | 'bot';
identity: {
status: 'anonymous' | 'signal_identified';
clickstreamId: string; // first-party _cs_uid value
isReturning: boolean;
hasIdentifiedThisSession: boolean;
};
scores: {
intent: number;
frustration: number;
engagement: number;
value: number;
churn: number;
abandonment: number;
conversionReadiness: number;
sessionMomentum: number; // -1..+1, not 0..100
confusion: number;
emotionalState: 'neutral' | 'curious' | 'engaged' | 'frustrated'
| 'confused' | 'excited' | 'decisive' | 'hesitant';
decisionStage: 'browsing' | 'evaluating' | 'comparing'
| 'deciding' | 'purchasing';
};
session: {
sessionId: string;
durationMs: number;
pagesInSession: number;
};
device: {
type: 'desktop' | 'mobile' | 'tablet' | 'unknown';
browser: string;
os: string;
isMobile: boolean;
};
snapshotAt: string; // ISO-8601 when the snapshot was taken
}
Plan gate
The Signals API is Builder tier and above. Free-tier API keys get a 403 plan_upgrade_required on the underlying endpoint; the client library surfaces this as a rejected promise with a SignalsPlanUpgradeError. See the Pricing page for the full feature matrix.
Builder + Scale + Network + Custom all have read access. Scale+ additionally unlocks the Signals Feed WebSocket for real-time stream consumption.
Recipes
1. High-intent upsell modal
Show a limited-time offer the moment intent crosses 70 — but only once per session.
import { configure, waitFor } from '@clickstream/signals';
configure({ apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY });
const OFFER_SHOWN_KEY = 'cs_upsell_shown';
if (!sessionStorage.getItem(OFFER_SHOWN_KEY)) {
waitFor({ intentMin: 70 }, 60_000)
.then(() => {
sessionStorage.setItem(OFFER_SHOWN_KEY, '1');
document.dispatchEvent(new CustomEvent('showUpsellModal'));
})
.catch(() => { /* timeout — visitor never crossed threshold */ });
}
2. AI-crawler structured content
Serve JSON-LD to AI agents and crawlers so your content appears in answer engines, while keeping the full UI for humans.
import { useVisitor } from '@clickstream/react';
export function ProductPage({ product }) {
const visitor = useVisitor();
const isAIOrCrawler =
visitor?.bot.isBot &&
(visitor.bot.category === 'ai_agent' ||
visitor.bot.category === 'search_crawler');
return (
<>
{isAIOrCrawler && (
<script type="application/ld+json">{JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
offers: { '@type': 'Offer', price: product.price },
})}</script>
)}
<ProductDetails {...product} />
</>
);
}
3. Bot gate for sensitive forms
Refuse to render the signup form for bots, redirect scrapers to a public registration page, allow humans.
import { configure, getVisitor, isBot } from '@clickstream/signals';
configure({ apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY });
const ctx = await getVisitor();
if (isBot(ctx)) {
// Scraper detected — redirect to a static marketing page
window.location.replace('/public/signup-info');
} else {
// Real human — mount the signup form
mountSignupForm();
}
4. Frustration-triggered support
Open support chat when frustration crosses 60 — but debounce so the same visitor doesn't get repeatedly nagged.
import { configure, onVisitor } from '@clickstream/signals';
configure({ apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY });
let lastChatOpenedMs = 0;
onVisitor((ctx) => {
if (ctx.scores.frustration < 60) return;
if (Date.now() - lastChatOpenedMs < 5 * 60 * 1000) return;
lastChatOpenedMs = Date.now();
window.Intercom?.('show');
});
5. LTV-gated premium offer
Hide the premium tier from low-value visitors (short session, low engagement) and show it to high-value ones.
import { useVisitor } from '@clickstream/react';
export function PricingPage() {
const visitor = useVisitor();
const ltvBucket = visitor?.scores.value ?? 50;
return (
<>
<BasicPlan />
<ProPlan />
{ltvBucket >= 70 && <EnterprisePlan />}
</>
);
}
Note: scores.value is the model's LTV estimate on a 0–100 scale; cutoff values should be calibrated against your own conversion data. Start permissive (50) and tighten as you gather signal.
See also
- Signals Feed (WebSocket) — real-time stream of every labeled event (Scale+)
- Install guide — how to bootstrap the SDK
- Event schema — raw events before scoring
- API keys + auth — permission scopes
- Rate limits — per-tier caps