@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:

Use it from:

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:

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:

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