Signals API
Signals is the part of ClickStream your site can read while a visitor is still on the page. It answers questions like:
- Is this a human, a crawler, a search/answer agent, a monitor, or browser automation?
- Is the human showing intent, frustration, confusion, or an action-readiness score?
- Has this browser identified itself this session?
- Which experience should my page show right now?
The Signals client reads a VisitorContext snapshot from your first-party tracking domain. The same shape is used by browser page code, React hooks, Next.js helpers, the REST endpoint, and the real-time feed.
For traffic that does not run JavaScript, pair the browser SDK with Edge capture. That is how search crawlers, answer agents, previews, monitoring checks, and automation runs get measured before HTML is returned.
Real-Time Signals Loop
The browser client reads snapshots with getVisitor(), getVisitorOrNull(), onVisitor(), and waitFor(). For applications that need page behavior to react inside the same visitor session, subscribeVisitor() opens a cost-bounded visitor stream and falls back to polling when the stream cannot be held open.
The loop has six pieces:
| Piece | Contract | Status |
|---|---|---|
| Per-visitor realtime stream | Subscribe to one visitor/session and receive updated VisitorContext frames as accepted events change scores. | subscribeVisitor() / onVisitorRealtime() |
| Event-to-signal acknowledgement | Track an event, flush it immediately, then read a fresh or safe fallback snapshot. | window.clickstream.trackEventNow() + getVisitorOrNull() |
| Freshness metadata | Every snapshot exposes when it was scored, which event advanced it, and whether the client reused stale-but-safe data. | snapshotVersion, sourceEventId, ageMs, stale, transport |
| Effects/apply helpers | Convert a visitor context into DOM-safe effects with dedupe and fail-open defaults. | @clickstreamhq/signals/effects |
| Pre-paint Next usage | Resolve a snapshot or safe fallback before first paint on selected routes with visitor + session cookies. | @clickstreamhq/next |
Per-Visitor Realtime Stream
The stream is scoped to the current browser visitor and session, not the tenant-wide Signals Feed. It requires sessionId, attaches to one Durable Object partition, caps at five minutes, closes after two idle minutes, and reserves 300 Signals Coverage units when opened. Scale+ accounts can use it directly; lower tiers stay on polling.
import { configure, subscribeVisitor } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
});
const sub = subscribeVisitor(
(visitor) => {
if (visitor.stale) return;
if (!visitor.bot.isBot && visitor.scores.conversionReadiness >= 70) {
showHighIntentAction();
}
},
{
fallbackToPolling: true,
onError(error) {
console.warn('Signals stream unavailable; keeping default UI', error);
},
},
);
// sub.unsubscribe();
Stream frames from the edge look like:
type VisitorStreamFrame = {
type: 'visitor';
visitorId: string;
sessionId: string;
context: VisitorContext;
snapshotVersion: string;
sourceEventId?: string;
};
Event-To-Signal Flush
When a UI event should immediately influence page behavior, flush the event before reading. That avoids a race where the app tracks a click, then reads the previous snapshot before scoring catches up.
await window.clickstream?.trackEventNow({
name: 'pricing_compare_opened',
category: 'intent',
value: 1,
});
const visitor = await getVisitorOrNull();
if (visitor && !visitor.stale && !visitor.bot.isBot && visitor.scores.intent >= 70) {
showComparisonConcierge();
}
Freshness Metadata
Freshness tells the app whether a decision used the latest accepted event, a recent snapshot, or a safe stale fallback.
visitor.transport; // 'rest' | 'stream' | 'cache' | 'stale'
visitor.snapshotVersion; // usually the producing event timestamp
visitor.sourceEventId; // client-generated event id when supplied
visitor.ageMs; // approximate client-observed snapshot age
visitor.stale; // true when reusing stale-but-safe data
visitor.pending; // true while the first scored event is catching up
Use freshness to bound sensitive UI effects:
if (visitor.stale) {
keepDefaultExperience();
}
Effects And Apply Helpers
Effects are named decisions that sit between raw scores and UI mutation. They make app behavior testable because you can assert "which effect was chosen" without coupling tests to every score threshold.
import { applySignals, effects } from '@clickstreamhq/signals/effects';
applySignals([
{ when: { conversionReadinessMin: 70, isBot: false }, once: true, run: effects.show('[data-offer]') },
{ when: { frustrationMin: 60 }, run: effects.addClass('[data-help]', 'is-visible') },
], { realtime: true });
Effects are intentionally small DOM mutations. Keep default UI in place and let rules enhance it only when a fresh enough snapshot matches.
Testing Pattern
Use your own first-party domain (for example, example.com with https://t.example.com) whenever an example or test needs a customer site domain:
configure({
apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!,
endpoint: 'https://t.example.com',
});
For browser-style tests, load a page on https://example.com, install the SDK against https://t.example.com, trigger one event with window.clickstream.trackEventNow(), then assert the Signals read or realtime subscription fails open when credentials, billing coverage, or WebSockets are unavailable.
The Big Idea: Lanes
A lane is a safe bucket for deciding what to do next. ClickStream labels traffic; your app decides how to treat each lane.
| Lane | What it means | Good use |
|---|---|---|
| Human interaction | A real visitor is interacting with the site. | Show human-only UI, route support, personalize application flows, measure human usage. |
| AI/search coverage | A crawler or answer agent is reading pages. | Serve structured content, measure which pages are visible to discovery systems. |
| Preview/accessibility | A link preview or renderer is asking for page metadata. | Keep cards, titles, images, and accessible summaries healthy. |
| Monitoring/site health | A scheduled check is testing availability. | Keep uptime signals separate from human usage. |
| Automation/QA | Browser automation or test traffic is exercising flows. | Measure test coverage, flag broken journeys, avoid counting tests as people. |
| Review/security | Scanner-like or scraper-like traffic needs review. | Send to security logs or investigation queues without mixing it into human metrics. |
Do not discard non-human lanes. They are useful evidence, but they should not trigger human-only actions.
Install
pnpm add @clickstreamhq/sdk @clickstreamhq/signals
Signals reads the visitor/session created by the ClickStream browser pixel. Install the pixel first, then configure Signals near your site entry point:
import { installClickstreamPixel } from '@clickstreamhq/sdk';
import { configure } from '@clickstreamhq/signals';
installClickstreamPixel({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
replay: true,
});
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
});
endpoint is your first-party tracking domain from DNS setup. When you omit endpoint, the client falls back to the shared production collector at https://feynman.clickstream.com — that works out of the box for quick tests, but production sites should set endpoint to their verified first-party tracking domain so Signals reads share the same origin and cookies as event ingestion.
If you already installed the script tag directly in HTML, only configure Signals:
<script
src="https://t.example.com/sdk.js"
data-key="cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
async
></script>
import { configure } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
});
Signals resolves the visitor id in this order:
window.clickstream.getVisitorId()— the SDK bridge. Required on CNAME proxy sites where_cs_vidis an HttpOnly server cookie that page JavaScript cannot read._cs_vidcookie — the SDK's visitor UUID._cs_vidin localStorage — the SDK's dual-storage fallback when cookies are blocked or deleted._cs_uidcookie — last resort for legacy@clickstreamhq/sdk/coreinstalls. On full-SDK sites_cs_uidholds thecs_*cross-site profile id, which is not a snapshot key, so it never wins over_cs_vid.
The session id comes from the bridge or the _cs_sid cookie. If no visitor id can be resolved yet, getVisitor() throws and getVisitorOrNull() returns null.
Copy-Paste: Pick A Lane In Page Code
import { configure, getVisitorOrNull } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
});
const visitor = await getVisitorOrNull();
if (!visitor) {
showDefaultExperience();
} else if (!visitor.bot.isBot && visitor.behavioralClass === 'human') {
if (visitor.scores.intent >= 70 || visitor.scores.conversionReadiness >= 70) {
showHumanOnlyAction();
} else {
showDefaultExperience();
}
} else if (
visitor.bot.category === 'ai_agent' ||
visitor.bot.category === 'search_crawler'
) {
renderStructuredContentHints();
} else if (visitor.bot.category === 'automation') {
markQaRun(visitor);
} else {
showAccessibleStaticFallback();
}
Keep the fallback boring and accessible. If Signals cannot load yet, your page should still work normally.
Application Patterns
Signals should change the lane, message, timing, or structure of an experience. It should not become a hard dependency for the page to render.
Good uses:
- Show a help shortcut when a human visitor looks frustrated.
- Delay a human-only sales prompt until
conversionReadiness >= 70. - Add structured facts, FAQ JSON-LD, or crawler-friendly summaries for AI/search lanes.
- Route automation and QA traffic into test coverage reports.
- Keep monitoring checks out of human conversion metrics.
- Suppress expensive downstream handoffs unless the visitor is human and has a matchable identity signal.
Avoid:
- Blocking the whole page when Signals returns
null. - Treating every bot as hostile. Many non-human lanes are useful.
- Counting automation, kiosk, crawler, or monitor traffic as human demand.
- Sending raw PII into custom event names, labels, URLs, or selectors.
Fail-open wrapper
Use this pattern when a business-critical page depends on Signals but should never break because of it:
import { getVisitorOrNull } from '@clickstreamhq/signals';
export async function chooseExperience() {
const ctx = await getVisitorOrNull();
if (!ctx) {
return { lane: 'default', variant: 'normal' };
}
if (ctx.bot.isBot) {
return {
lane: ctx.bot.category ?? 'non_human',
variant: ctx.bot.category === 'ai_agent' ? 'structured' : 'static_accessible',
};
}
if (ctx.scores.frustration >= 60) return { lane: 'human', variant: 'help_first' };
if (ctx.scores.conversionReadiness >= 70) return { lane: 'human', variant: 'high_intent' };
return { lane: 'human', variant: 'normal' };
}
Keep the decision explainable
When Signals changes UI, store the reason in your own analytics or component state:
const ctx = await getVisitorOrNull();
const decision = {
variant: 'default',
reason: 'signals_unavailable',
};
if (ctx?.bot.isBot && ctx.bot.category === 'ai_agent') {
decision.variant = 'structured_facts';
decision.reason = 'answer_engine_lane';
} else if (ctx && !ctx.bot.isBot && ctx.scores.frustration >= 60) {
decision.variant = 'help_prompt';
decision.reason = 'human_frustration_60_plus';
}
window.clickstream?.trackEvent({
name: 'experience_variant_selected',
category: 'signals',
label: decision.reason,
});
That makes later QA much easier: the dashboard shows the traffic lane, while your app logs which branch your code actually took.
Human-Only Action View
Use this when only real people should see a UI variant or support prompt.
import { configure, waitFor } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
});
try {
const visitor = await waitFor(
{
isBot: false,
behavioralClass: 'human',
conversionReadinessMin: 70,
},
60_000,
);
if (!sessionStorage.getItem('cs_action_seen')) {
sessionStorage.setItem('cs_action_seen', '1');
document.dispatchEvent(new CustomEvent('clickstream:show-human-action', { detail: visitor }));
}
} catch {
// No matching signal in time. Leave the normal page experience alone.
}
AI/Search Answer-Engine View
Most search and answer-engine requests do not execute page JavaScript, so install Edge capture for full coverage. Page code can still help when a machine-readable browser does run JavaScript:
import { useVisitor } from '@clickstreamhq/react';
export function StructuredFaqForMachineReaders({ faqs }) {
const { ctx } = useVisitor();
const shouldExposeMachineReadableView =
ctx?.bot.isBot &&
(ctx.bot.category === 'ai_agent' || ctx.bot.category === 'search_crawler');
if (!shouldExposeMachineReadableView) return null;
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: { '@type': 'Answer', text: faq.answer },
})),
}),
}}
/>
);
}
In the dashboard, compare the pages humans use with the pages crawlers and answer agents reach. Gaps are content-coverage work.
Real Product Examples
1. Documentation or content site
Goal: make sure humans get the normal reading experience, while answer engines get structured facts they can quote accurately.
const ctx = await getVisitorOrNull();
const discovery =
ctx?.bot.category === 'ai_agent' ||
ctx?.bot.category === 'search_crawler';
if (discovery) {
injectJsonLdForCurrentArticle();
exposeCanonicalSummaryBlock();
}
Pair this with Edge capture, because most search and answer-engine requests do not execute JavaScript.
2. SaaS onboarding
Goal: help people who are stuck without giving test automation the same prompts.
const ctx = await getVisitorOrNull();
const shouldOpenHelp =
ctx &&
!ctx.bot.isBot &&
ctx.behavioralClass === 'human' &&
ctx.scores.frustration >= 60 &&
ctx.session.pagesInSession >= 2;
if (shouldOpenHelp && !sessionStorage.getItem('cs_help_seen')) {
sessionStorage.setItem('cs_help_seen', '1');
openSetupHelp();
}
3. Checkout or lead form
Goal: reduce friction for humans and avoid firing revenue workflows for non-human traffic.
const ctx = await getVisitorOrNull();
const isHuman = !!ctx && !ctx.bot.isBot && ctx.behavioralClass === 'human';
if (isHuman && ctx.scores.conversionReadiness >= 70) {
showShorterForm();
}
if (isHuman && ctx.identity.status !== 'anonymous') {
enableSalesHandoff();
}
4. Internal QA coverage
Goal: let QA automation prove flows still work without polluting human analytics.
const ctx = await getVisitorOrNull();
if (
ctx?.bot.category === 'automation' ||
ctx?.bot.category === 'stealth_bot' ||
ctx?.behavioralClass === 'suspicious' ||
ctx?.behavioralClass === 'likely_bot'
) {
window.dispatchEvent(new CustomEvent('clickstream:qa-lane', {
detail: {
page: location.pathname,
sessionId: ctx.session.sessionId,
automationScore: ctx.bot.score,
},
}));
}
Automation And QA View
Automation is not automatically malicious. Treat automation, stealth, suspicious, and likely_bot traffic as separate lanes so QA runs, monitoring checks, and scripted reviews do not distort human usage data. Human-only actions should require both !ctx.bot.isBot and ctx.behavioralClass === 'human'.
import { configure, onVisitor } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
endpoint: 'https://t.example.com',
});
const sub = onVisitor((visitor) => {
const automationLike =
visitor.bot.category === 'automation' ||
visitor.bot.category === 'stealth_bot' ||
visitor.behavioralClass === 'suspicious' ||
visitor.behavioralClass === 'likely_bot';
if (!automationLike) return;
document.documentElement.dataset.clickstreamLane = 'automation_qa';
window.dispatchEvent(new CustomEvent('clickstream:automation-qa', {
detail: {
page: location.pathname,
sessionId: visitor.session.sessionId,
score: visitor.bot.score,
},
}));
});
// Later, when your component or test harness unmounts:
// sub.unsubscribe();
Practical Ideas
- Show a human-only UI variant after a visitor reaches
conversionReadiness >= 70, withsessionStoragededupe so it appears once. - Open support chat when
frustration >= 60andisBot === false. - Add accurate page facts, FAQs, and structured data for the AI/search lane.
- Compare top human pages with top crawled pages in Signals coverage proof; write content tasks for pages humans use but machines do not reach.
- Send automation-lane events to your QA dashboard so test runs prove account setup, settings, and search are still working.
- Keep security-review traffic in a separate log stream instead of hiding it or counting it as demand.
- Suppress downstream handoffs unless
behavioralClass === 'human'and the visitor has matchable identity.
Public API
import {
configure,
getVisitor,
getVisitorOrNull,
onVisitor,
waitFor,
isBot,
botCategory,
behavioralClass,
isHighIntent,
isFrustrated,
isIdentified,
} from '@clickstreamhq/signals';
configure(options)
Call once before reading signals.
configure({
apiKey: 'cs_live_...',
endpoint: 'https://t.example.com',
pollIntervalMs: 2000,
});
getVisitor(): Promise<VisitorContext>
Reads the freshest snapshot for the current visitor id (resolved via the order shown in Install). It throws when:
configure()has not been called- no visitor id can be resolved yet
- the endpoint returns an error
- the API key is below the Signals plan gate
try {
const ctx = await getVisitor();
console.log(ctx.scores.intent);
console.log(ctx.behavioralClass);
console.log(ctx.identity.clickstreamId);
} catch {
// Treat as "no usable signal yet" in page UI.
}
getVisitorOrNull(): Promise<VisitorContext | null>
Reads one snapshot and returns null instead of throwing. Use this first for page personalization so your site always falls back to the normal experience.
const ctx = await getVisitorOrNull();
if (!ctx) showDefaultExperience();
onVisitor(callback): Subscription
Polls immediately, then at pollIntervalMs, and calls your callback whenever a snapshot arrives.
const sub = onVisitor((ctx) => {
if (!ctx.bot.isBot && ctx.scores.frustration >= 60) {
openSupportChat();
}
});
sub.unsubscribe();
waitFor(criteria, timeoutMs?)
Resolves once every provided criterion is true. You can pass timeoutMs as the second argument, or inside the criteria object.
const ctx = await waitFor(
{ intentMin: 70, isBot: false },
60_000,
);
Criteria:
type WaitForCriteria = {
intentMin?: number;
engagementMin?: number;
frustrationMin?: number;
churnMin?: number;
abandonmentMin?: number;
conversionReadinessMin?: number;
behavioralClass?: 'human' | 'suspicious' | 'likely_bot' | 'bot';
isBot?: boolean;
identified?: boolean;
emotionalState?: 'neutral' | 'curious' | 'engaged' | 'frustrated'
| 'confused' | 'excited' | 'decisive' | 'hesitant';
decisionStage?: 'browsing' | 'evaluating' | 'comparing'
| 'deciding' | 'purchasing';
timeoutMs?: number;
};
identified: true matches either a promoted non-anonymous identity status or a successful identify() call in the current session (hasIdentifiedThisSession).
Helpers
Helpers accept an already-fetched VisitorContext, or fetch the current visitor when called with no context.
const ctx = await getVisitor();
isBot(ctx);
botCategory(ctx);
behavioralClass(ctx);
isHighIntent(ctx, 70);
isFrustrated(ctx, 60);
isIdentified(ctx);
// Also valid, each performs one Signals read:
await isBot();
await isHighIntent(70);
VisitorContext
interface VisitorContext {
bot: {
isBot: boolean;
score: number;
category?: 'search_crawler' | 'seo_tool' | 'ai_agent' | 'social_preview'
| 'monitoring' | 'scraper' | 'scanner' | 'automation'
| 'stealth_bot' | 'kiosk' | 'unknown_bot';
name?: string;
};
behavioralClass: 'human' | 'suspicious' | 'likely_bot' | 'bot';
identity: {
status: 'anonymous' | 'customer_identified' | 'signal_identified' | 'merged';
personId?: string;
visitorId: string;
clickstreamId: string;
observedClickstreamId?: string;
mergedClickstreamIds: string[];
isReturning: boolean;
hasIdentifiedThisSession: boolean;
};
scores: {
intent: number;
frustration: number;
engagement: number;
value: number;
churn: number;
abandonment: number;
conversionReadiness: number;
sessionMomentum: number; // -100..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;
}
Identity fields
Use the identity fields this way:
| Field | Meaning | Use it for |
|---|---|---|
identity.visitorId | The live visitor id used by /v1/signals/:visitorId. | Reading the current browser/app session. |
identity.clickstreamId | The canonical ClickStream profile id shown in People. | Stable profile links, CRM notes, support context. |
identity.observedClickstreamId | Present when the current browser/session id has been merged into another canonical profile. | Debugging why a browser id now resolves somewhere else. |
identity.mergedClickstreamIds | Older ClickStream IDs that now resolve to the canonical clickstreamId. | Search, support, and audit trails across historical IDs. |
identity.status | anonymous, customer_identified, signal_identified, or merged. | Deciding whether identity-aware workflows are available. |
For new code, store identity.clickstreamId as the stable profile key and keep identity.visitorId for the live request/session. If a user has used multiple devices or browsers, aliases may appear in mergedClickstreamIds.
REST Endpoint
The client library calls this endpoint for you:
GET https://t.example.com/v1/signals/:visitorId?sessionId=:sessionId
X-API-Key: cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
It returns the same VisitorContext shape shown above.
For native mobile apps and non-browser clients, authenticate with the dashboard-issued mobile key (cs_mob_live_*) and provide both the persisted visitor id and the current session id. Mobile and server keys are provenance-exempt, so no Origin header is sent or required:
GET https://t.example.com/v1/signals/cs_abc_123?sessionId=cs_sess_456
X-API-Key: cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
See Mobile apps for the direct event ingestion pattern and the @clickstreamhq/react-native package that wires this read for you.
Plan Gate
GET /v1/signals/:visitorId is available on Hobby so personal and low-volume sites can use session-scoped Signals snapshots directly in page code. Hobby uses the same visitor snapshot shape, with the limits shown on the Pricing page.
The official client includes the current sessionId automatically. Hobby keys must send that session hint so ClickStream can read one live session partition instead of scanning every partition. Paid keys keep the wider fallback for older integrations.
Hobby and Growth should use one-shot reads or coarse cached checks. The client coalesces repeated calls and reuses the last good snapshot during short throttles so your page can fail open. Sustained polling and the Signals Feed WebSocket are Scale tier and above.
See Also
- Edge capture: Cloudflare Worker install for crawler and answer-engine coverage
- Signals coverage proof: coverage, lane separation, and answer-engine gaps
- Signals Feed: real-time stream of labeled events
- React adapter: component-level Signals hooks
- Mobile apps: native app event ingestion and Signals reads
- Traffic classification: how labels become lanes