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:
- Defaults live in code, not in config. There is no copy of the default weights sitting in KV that can drift out of date. An untuned site has no KV entry at all.
- Partial configs are safe. Override two keys and the other twelve keep their defaults —
cfg()'s nullish fallback handles every missing section, model, or key. - The tier boundaries are tunable too. Intent has four behavioral stages — browsing → researching → evaluating → converting — and the cutoffs between them (0.2, 0.45, 0.7 by default) are config values like any weight. More on what those stages mean in Beyond the Score: Emotional State and Decision Stage.
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:
- KV read fails? The loader returns
undefinedand every model uses built-in defaults. The failure is non-fatal by design. - Config JSON is malformed? Anything that doesn't parse to a plain object is discarded — defaults again.
- Config exists but a key is missing?
cfg()'s??fallback fills it with the code default.
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:
- The harness runs the full 26-model
computeScores()pass 10,000 times (after 500 warmup iterations) against each of five realistic visitor profiles: neutral, high-intent, frustrated, bot-like, and mobile. - For every profile it asserts
p95 < 3 ms— the test literally fails the build otherwise. - A second pass benchmarks the eight most expensive individual models (intent, frustration, engagement, anomaly, churn, abandonment, form friction, next action) so that when the aggregate number slips, the regressing model is identifiable immediately.
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
- Scoring weights are per-site, not global. Overrides live in KV under
scoring:{clientId}:{siteId}; every site in a multi-site account tunes independently. - Three override sections per model —
signals,tiers,params— read through one helper whose fallback is the code default. Partial configs are always safe. - A docs site and a checkout flow disagree about what intent looks like — deep scroll is appetite on one and friction on the other. Tune the weights instead of accepting the average.
- Every model is isolated. A crashing model degrades to its defaults; the other 25 scores survive; event ingestion is never blocked.
- The whole 26-model pass lives inside a CI-enforced p95 < 3 ms budget — 10,000 iterations across five visitor profiles, asserted on every test run.
- Reset is a delete. Defaults live in code, so there's no stored default copy to drift.
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.