Engineering

Custom Behavioral Scoring: Your Site, Not a Global Average

Per-site model weights stored in KV, read through one helper with a built-in default — so a docs site and a checkout flow stop being measured with the same yardstick.

July 2026 • 8 min read

One Global Model Scores Every Site Wrong

Most behavioral scoring products ship a single model calibrated for an average website, then apply it to yours. The weights that decide what counts as purchase intent were chosen for somebody else's funnel. If your site looks like that average, the scores are fine. If it doesn't — and documentation sites, checkout flows, media sites, and B2B pricing pages emphatically don't look like each other — the model quietly misreads your visitors.

ClickStream's answer is custom behavioral scoring: all 26 behavioral models ship with defaults, and every signal weight, tier threshold, and parameter can be overridden per site. Not per account — per site. This post walks through the actual implementation in the collector: the KV config keyed by clientId:siteId, the one-line read helper that makes missing keys safe, the fail-open isolation around each model, and the CI-enforced latency budget the whole pass lives inside.

How Per-Site Scoring Weights Are Stored

Scoring overrides live in Cloudflare KV under the key scoring:{clientId}:{siteId}, alongside the rest of the client configuration. The siteId in the key is the point: a multi-site client can tune each property independently. Your docs subdomain, your marketing site, and your app can each carry their own weights — or none at all.

The config shape is small enough to read in full. It's a map from model ID to three optional override sections:

/** Per-model config block: signal weights, tier thresholds, and params */
export interface ModelOverrides {
  signals?: Record<string, number>;
  tiers?: Record<string, number>;
  params?: Record<string, number>;
}

/** Top-level scoring config keyed by model ID */
export type ScoringConfig = Record<string, ModelOverrides>;

/**
 * Helper to read a config value with a fallback default.
 * Every model call-site uses this so that missing keys are always safe.
 */
export function cfg(
  overrides: ModelOverrides | undefined,
  section: 'signals' | 'tiers' | 'params',
  key: string,
  defaultValue: number,
): number {
  return overrides?.[section]?.[key] ?? defaultValue;
}

That cfg() helper is the whole trick. Every weight a model uses is read through it, with the built-in default passed at the call site. Here is what that looks like inside the intent classifier — the model behind visitor.scores.intent:

if (features.maxScrollDepth >= 75) score += cfg(ov, 'signals', 'deepScroll', 0.2);
if (features.formStarted) score += cfg(ov, 'signals', 'formStarted', 0.25);
if (features.formCompleted) score += cfg(ov, 'signals', 'formCompleted', 0.35);
if (features.hasIdentified || features.hasHEM) score += cfg(ov, 'signals', 'identified', 0.3);

// Categorize
if (score >= cfg(ov, 'tiers', 'converting', 0.7)) category = 'converting';
else if (score >= cfg(ov, 'tiers', 'evaluating', 0.45)) category = 'evaluating';
else if (score >= cfg(ov, 'tiers', 'researching', 0.2)) category = 'researching';
else category = 'browsing';

Three properties fall out of this design:

Why a Docs Site and a Checkout Flow Need Different Intent Weightings

Work the defaults by hand and the problem with a global average becomes concrete.

The docs site. A developer arrives directly, spends twenty minutes on an integration guide, and scrolls 90% of the way through. Under default weights that visit earns deepScroll (0.2) + longSession (0.1) + avgTimeOnPage (0.05) ≈ 0.35 — which lands in researching, one tier up from the bottom. There's no form to start on a docs page, no campaign code, nothing else to add. The most engaged reader your documentation ever sees can't climb past the second of four stages, because the default weights reserve the big numbers for form and identity events that your docs simply don't produce.

The checkout flow. The opposite failure. Form signals dominate by default — formStarted (0.25), formCompleted (0.35), identified (0.3) — and that's roughly right for checkout. But deep scrolling on a checkout page often isn't appetite; it's a visitor hunting for shipping costs or a way out. A signal that means "strong interest" on a long-form article can mean "friction" three clicks from payment.

The fix is not a smarter global model. It's letting each site say what its own evidence means. A hypothetical tuning for both sites, using only real config keys:

Config key (intent-classifier) Default Docs site (hypothetical) Checkout flow (hypothetical)
signals.deepScroll 0.2 0.35 — reading depth is the intent signal 0.05 — deep scroll here skews toward friction
signals.longSession 0.1 0.2 — long sessions are the point 0.05 — a long checkout is a stuck checkout
signals.formStarted 0.25 0.1 — almost no forms exist 0.3 — the signal that matters most
tiers.evaluating 0.45 0.35 — reachable by behavior docs actually produce 0.5 — keep the tier strict

The docs-site column, as the JSON you'd actually store:

{
  "intent-classifier": {
    "signals": { "deepScroll": 0.35, "longSession": 0.2, "formStarted": 0.1 },
    "tiers": { "evaluating": 0.35 }
  }
}

The same three sections exist for every model in the pass — frustration thresholds, engagement weights, churn parameters — keyed by model IDs like frustration-detector and engagement-scorer. Tune one model or twenty; the shape is identical.

Fail-Open Isolation: A Crashing Model Degrades to Defaults

Letting customers inject numbers into a scoring pipeline raises an obvious question: what happens when a weight combination — or a plain bug — makes a model throw? The orchestrator's answer is four lines long:

// Safe model call: isolates each model so a single failure preserves all other scores
function safe<T>(fn: () => T, fallback: T): T {
  try { return fn(); } catch { return fallback; }
}

Every model is invoked through safe() with its own default result as the fallback:

const intent = safe(() => classifyIntent(features, config?.['intent-classifier']), defaults.intent);
const frustration = safe(() => detectFrustration(features, config?.['frustration-detector']), defaults.frustration);

So a crashing model degrades to its defaults — intentScore: 0, intentCategory: 'browsing' for intent — while the other 25 results are computed normally. The fallback table, getDefaultScoreResult(), exists for exactly one stated reason in the source: "ensures primary event write is never blocked." Scoring is enrichment. It is never a gate on ingestion, the same way billing checks never block event collection.

The config plumbing fails open at every other layer too:

The worst case a bad config can produce is scores you disagree with. It cannot produce dropped events. This is the same fail-open philosophy the read side applies to personalization — covered in Fail-Open Personalization — applied to the write side.

The p95 < 3 ms Budget the Whole Pass Lives Inside

Per-site tuning would be a bad trade if it slowed ingestion down, so it's worth being precise about what the scoring pass costs and how that cost is enforced.

computeScores() is pure synchronous code — no I/O, no async, no network calls inside the pass. The single KV read that fetches your overrides happens once per event batch, and even that is cached in-isolate for 60 seconds (bounded to 2,000 sites per isolate), so a busy site does one config read per cache window rather than one per batch. Inside the models, an override costs a property lookup with a ?? fallback — tuned and untuned sites run the same code path.

The budget itself is enforced by a benchmark that runs as part of the normal test suite, which means CI fails if scoring gets slow:

One honest caveat, because this site is picky about claims: that number is a CI-enforced benchmark on the test runner, asserted on every test run — it is not a production SLA, and we don't publish one. What it guarantees is that no code change that pushes the 26-model pass past a 3 ms p95 can merge.

Tuning, Resetting, and How Fast Changes Land

You don't write KV entries by hand. Saving model tuning in the dashboard calls an admin endpoint (PUT /admin/scoring-config/:clientId/:siteId) with the full config JSON, which is validated and written to KV. Because the collector caches config in-isolate for 60 seconds, an edit propagates to live scoring within about a minute.

The reset path is worth noticing: saving an empty config doesn't write a config full of defaults — it deletes the KV entry. Reset is a delete, not a write. Combined with defaults living in code, this makes configuration drift structurally impossible: there is never a stored copy of the defaults to go stale, and a site with no overrides is byte-for-byte identical to a site that reset them.

Reading the Tuned Scores

Everything above happens on the write side, at ingestion. On the read side, tuned scores surface through the same Signals API as everyone else's — your weights just decide who crosses the thresholds:

import { configure, getVisitorOrNull, isHighIntent } from '@clickstreamhq/signals';

configure({ apiKey: 'cs_live_xxx' });

const visitor = await getVisitorOrNull();
if (visitor && isHighIntent(visitor)) {
  // visitor.scores.intent >= 70 — scored under YOUR site's weights,
  // not a global average
}

visitor.scores.intent is the 0–100 surface of the intent model tuned above; isHighIntent() defaults to the 70 threshold. The Signals getting-started guide covers the full read surface. One version note: @clickstreamhq/signals is a developer preview on the 0.1.0-alpha line — of the ClickStream packages, only @clickstreamhq/sdk (1.4.0) is stable today. And none of this exists without events to score: the SDK has to be installed on your site first.

The Bottom Line

A scoring model you can't tune is a global average with your logo on it. Put the weights in config, keep the defaults in code, and never let scoring block an event.

Score Visitors by Your Site's Rules

Ship with sensible defaults, then tune the weights your funnel actually needs — without ever risking an event. See plans or start free.

Start free