Engineering

The Effects Engine: Website Personalization Rules in One Import

applySignals and the named effects catalogue turn visitor scores into declarative page rules — the code the Signals page describes but never shows.

July 2026 • 8 min read • Developer preview (0.1.0-alpha)

@clickstreamhq/signals is a developer preview, published to npm at 0.1.0-alpha under the alpha dist-tag. Every API in this post is the real, current surface of the /effects subpath — verbatim-checkable against the package — but expect it to evolve before a stable release. Of the ClickStream packages, only @clickstreamhq/sdk (1.4.0) is stable today.

The Signals product page promises a rules engine: "define conditions over scores and identity, and let effects fire — without scattering conditionals through your codebase." What it doesn't show is the code. This post is that documentation: applySignals and the named effects catalogue from @clickstreamhq/signals/effects, the subpath that makes a website personalization rules engine a one-import affair.

The Manual Pattern: getVisitor and an If-Chain

First, the baseline it replaces. The core @clickstreamhq/signals client gives you a snapshot read — covered end to end in the getting-started guide — and nothing stops you from personalizing with plain conditionals:

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

configure({
  apiKey: 'cs_live_xxx',
  endpoint: 'https://t.example.com', // your verified first-party tracking domain
});

const visitor = await getVisitorOrNull();

if (visitor && !visitor.bot.isBot && visitor.behavioralClass === 'human') {
  if (visitor.scores.intent >= 70) {
    document.querySelector('[data-offer-banner]')?.removeAttribute('hidden');
  }
  if (visitor.scores.frustration >= 60) {
    document.querySelector('[data-help-widget]')?.classList.add('is-visible');
  }
}

This is correct code. It's fail-open (getVisitorOrNull() returns null when Signals is unavailable or blocked, so the default page renders), the field paths are right (visitor.scores.intent, visitor.scores.frustration, visitor.bot.isBot), and the thresholds match the package's own conventions — 70 is DEFAULT_HIGH_INTENT_THRESHOLD, 60 is the isFrustrated() cutoff, the same bands the dashboard shows operators.

The problem is what happens next. This runs once, at whatever moment you awaited it. Scores move during a session — a visitor who arrives lukewarm can turn high-intent three minutes in, and this snapshot will never notice. So you reach for onVisitor(), and now you own a subscription lifecycle, "already fired" flags for effects that must run once, and a try/catch around every effect so one broken selector doesn't kill the listener. Three rules in, you've hand-rolled a worse version of a rules engine.

applySignals: Declarative Rules in One Import

The /effects subpath ships that engine instead. Here is the usage exactly as the package README documents it:

import { applySignals, effects } from '@clickstreamhq/signals/effects';

const sub = applySignals([
  {
    when: { conversionReadinessMin: 70, isBot: false },
    once: true,
    run: (ctx) => {
      if (ctx.behavioralClass === 'human') effects.addClass('[data-offer]', 'is-ready')(ctx);
    },
  },
], { realtime: true });

One call, an array of rules, a subscription back. Each rule is a SignalRule with four fields:

The options object takes three fields: realtime (default false — more on that below), immediate (default true: rules run against the first snapshot as soon as it arrives), and onError, a callback that receives every effect failure. The return value has one method, unsubscribe() — call it on SPA route teardown.

Here's the manual example rebuilt as rules, with the boilerplate gone:

import { configure } from '@clickstreamhq/signals';
import { applySignals, effects } from '@clickstreamhq/signals/effects';

configure({
  apiKey: 'cs_live_xxx',
  endpoint: 'https://t.example.com',
});

const sub = applySignals([
  {
    id: 'high-intent-offer',
    when: { intentMin: 70, isBot: false, behavioralClass: 'human' },
    once: true,
    run: effects.show('[data-offer-banner]'),
  },
  {
    id: 'frustration-help',
    when: { frustrationMin: 60, isBot: false },
    run: effects.addClass('[data-help-widget]', 'is-visible'),
  },
  {
    id: 'spanish-cta',
    when: { primaryLanguage: 'es' },
    once: true,
    run: effects.setText('[data-hero-cta]', 'Empieza gratis'),
  },
], {
  onError: (err) => console.warn('signal effect failed', err),
});

No flags, no lifecycle bookkeeping, no per-effect try/catch. The engine keeps watching the visitor, fires each rule whenever its conditions hold, retires once rules after they run, and — a detail worth knowing — unsubscribes itself automatically when every remaining rule was once and has fired. A page whose rules have all completed stops polling on its own.

The when Clause: Thirteen Criteria, One Object

The criteria object is the same WaitForCriteria shape the core client's waitFor() uses. All fields are optional; everything you specify must hold (they AND together). The *Min thresholds are inclusive (>=).

CriterionMatches when
intentMinscores.intent ≥ value (0–100; 70+ is the high-intent band)
engagementMinscores.engagement ≥ value
frustrationMinscores.frustration ≥ value (60+ = struggling)
churnMinscores.churn ≥ value
abandonmentMinscores.abandonment ≥ value
conversionReadinessMinscores.conversionReadiness ≥ value
behavioralClassExact match: 'human', 'suspicious', 'likely_bot', or 'bot'
isBotbot.isBot equals the value
identifiedVisitor has a non-anonymous identity status, or called identify() this session
emotionalStateExact match on scores.emotionalState (curious, engaged, frustrated, confused, excited, decisive, hesitant, neutral)
decisionStageExact match on scores.decisionStage (browsing, evaluating, comparing, deciding, purchasing)
primaryLanguageCase-insensitive match on the visitor's resolved base language tag (e.g. 'es')
languageGeoMismatchExact match on the language↔geo mismatch flag

Two footnotes. The locale criteria are deliberately conservative: when the locale is unknown — an older server, or a snapshot from before locale support — the rule simply never matches. The source comment calls it "silence over a wrong-language effect." And timeoutMs, which also lives on WaitForCriteria, belongs to waitFor(); it plays no role in rule matching. For what the emotionalState and decisionStage labels actually measure, see Beyond the Score: Emotional State and Decision Stage.

When AND-ed criteria aren't enough, pass a function. This is also where freshness gates go:

{
  when: (ctx) =>
    ctx.scores.intent >= 70 && !ctx.stale && ctx.coverageMode === 'full',
  run: effects.addClass('[data-offer]', 'is-ready'),
}

The Named Effects Catalogue

The effects export is a catalogue of six factories. Each takes a CSS selector, returns a SignalEffect ready to drop into run, and applies to every element the selector matches:

EffectSignatureWhat it does
showshow(selector, display = '')Sets inline display (default clears it, restoring the stylesheet value)
hidehide(selector)Sets display: none
setTextsetText(selector, text)Sets textContent; text may be a string or (ctx) => string
addClassaddClass(selector, className)Adds a class
removeClassremoveClass(selector, className)Removes a class
setAttributesetAttribute(selector, name, value)Sets an attribute; value may be a string or (ctx) => string

The function forms make context-driven copy a one-liner — effects.setText('[data-stage]', (ctx) => ctx.scores.decisionStage) — and note that setText writes textContent, never HTML, so visitor-derived strings can't inject markup. The whole catalogue is also DOM-safe by construction: the internal selector helper returns an empty list when document is undefined, so an effect imported into SSR or test code is a no-op rather than a crash.

Failure Isolation and the once Lifecycle

The engine's listener wraps every run so that sync throws and async rejections land in the same place — your onError callback. The source states the contract outright: one failing effect never kills the subscription or the other rules. A typo'd selector in rule two doesn't stop rule three from firing, and it doesn't tear down polling. Compare that with the hand-rolled version, where a single uncaught exception in an onVisitor listener is yours to defend against.

once semantics are equally exact: the rule is removed from the active set immediately after its first successful match — before the next snapshot can arrive — and when the active set empties, the engine unsubscribes itself. If you set immediate: false, the first snapshot is skipped and rules only run on changes after that.

Polling by Default, Realtime When You Mean It

applySignals rides onVisitor() polling unless you pass realtime: true, and the default is the right call for always-on page code — the source comment says as much: polling is safer and cheaper, and it's always fail-open. With realtime: true the engine switches to subscribeVisitor(), the visitor-scoped WebSocket stream, with fallbackToPolling: true baked in so a capped, idle, or rejected stream degrades to polling instead of going dark.

Know what realtime costs before you reach for it: streams are Scale plans and above, must include a session id, attach to exactly one live-session partition, and reserve 300 Signals Coverage units when they open. Use realtime for the handful of moments where sub-second reaction genuinely changes the outcome — an exit-intent save on a checkout page — and let everything else poll.

When to Use Which

ScenarioReach for
One decision at page load, then donegetVisitorOrNull() and an if
Rules that should track the visitor through the sessionapplySignals, polling
Run-once effects (banners, offers) without flag bookkeepingapplySignals with once: true
Sub-second reaction on one critical page, Scale+ planapplySignals with realtime: true
Visitor context inside React componentsthe React hooks
Decisions before HTML is sentserver-side with @clickstreamhq/next

The honest summary: getVisitor is a primitive, applySignals is a policy. Use the primitive when you need the context for something that isn't a standing page rule — a redirect, an analytics annotation, a one-off branch. Use the engine the moment you catch yourself writing a second if against the same subscription.

Fail-Open by Construction

Declarative rules inherit the fail-open behavior of the client underneath. On rate limits, the polling path retries with backoff and reuses the last good snapshot while it can. When the Signals Coverage budget for the billing period is exhausted, reads don't error at all — the server returns a placeholder context with every score zeroed and behavioralClass: 'suspicious'. Look back at the rules above: intentMin: 70 can't match a zeroed score, and behavioralClass: 'human' can't match 'suspicious'. Score-gated and human-gated rules fail closed automatically, and the default page keeps rendering. For stricter rules, gate on ctx.coverageMode === 'full' in a predicate, as shown earlier. The full playbook — slow, stale, or absent signals — is in Fail-Open Personalization.

The Bottom Line

A website personalization rules engine doesn't need a tag manager, a visual editor, or a vendor-hosted DSL. It needs a criteria object, an effects catalogue, and a subscription that cleans up after itself — which is what @clickstreamhq/signals/effects ships in one import. Install the pixel, configure() the client, and your first rule is genuinely a one-liner: applySignals([{ when: { intentMin: 70, isBot: false }, run: effects.show('[data-offer]') }]).

An if-chain is a rules engine you have to debug. A rules engine is an if-chain someone else already debugged.

Write Your First Rule

Install the pixel, configure the Signals client, and ship a declarative personalization rule in the developer preview. If Signals is unavailable, your page renders anyway — that's the design.

Start free