Signals API

Signals is the part of ClickStream your site can read while a visitor is still on the page. It answers questions like:

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:

PieceContractStatus
Per-visitor realtime streamSubscribe to one visitor/session and receive updated VisitorContext frames as accepted events change scores.subscribeVisitor() / onVisitorRealtime()
Event-to-signal acknowledgementTrack an event, flush it immediately, then read a fresh or safe fallback snapshot.window.clickstream.trackEventNow() + getVisitorOrNull()
Freshness metadataEvery 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 helpersConvert a visitor context into DOM-safe effects with dedupe and fail-open defaults.@clickstreamhq/signals/effects
Pre-paint Next usageResolve 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.

LaneWhat it meansGood use
Human interactionA real visitor is interacting with the site.Show human-only UI, route support, personalize application flows, measure human usage.
AI/search coverageA crawler or answer agent is reading pages.Serve structured content, measure which pages are visible to discovery systems.
Preview/accessibilityA link preview or renderer is asking for page metadata.Keep cards, titles, images, and accessible summaries healthy.
Monitoring/site healthA scheduled check is testing availability.Keep uptime signals separate from human usage.
Automation/QABrowser automation or test traffic is exercising flows.Measure test coverage, flag broken journeys, avoid counting tests as people.
Review/securityScanner-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:

  1. window.clickstream.getVisitorId() — the SDK bridge. Required on CNAME proxy sites where _cs_vid is an HttpOnly server cookie that page JavaScript cannot read.
  2. _cs_vid cookie — the SDK's visitor UUID.
  3. _cs_vid in localStorage — the SDK's dual-storage fallback when cookies are blocked or deleted.
  4. _cs_uid cookie — last resort for legacy @clickstreamhq/sdk/core installs. On full-SDK sites _cs_uid holds the cs_* 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:

Avoid:

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

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:

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:

FieldMeaningUse it for
identity.visitorIdThe live visitor id used by /v1/signals/:visitorId.Reading the current browser/app session.
identity.clickstreamIdThe canonical ClickStream profile id shown in People.Stable profile links, CRM notes, support context.
identity.observedClickstreamIdPresent when the current browser/session id has been merged into another canonical profile.Debugging why a browser id now resolves somewhere else.
identity.mergedClickstreamIdsOlder ClickStream IDs that now resolve to the canonical clickstreamId.Search, support, and audit trails across historical IDs.
identity.statusanonymous, 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