@clickstreamhq/signalsis a developer preview, published to npm at 0.1.0-alpha under thealphadist-tag. Every API in this post is the real, current surface of the/effectssubpath — 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:
when— either a criteria object or a predicate(ctx) => boolean. Omit it and the rule matches every snapshot.run— the effect:(ctx) => void | Promise<void>. Anything callable, not just the catalogue.once— run once, then disable this rule. Defaultsfalse.id— optional label for your own logging.
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 (>=).
| Criterion | Matches when |
|---|---|
intentMin | scores.intent ≥ value (0–100; 70+ is the high-intent band) |
engagementMin | scores.engagement ≥ value |
frustrationMin | scores.frustration ≥ value (60+ = struggling) |
churnMin | scores.churn ≥ value |
abandonmentMin | scores.abandonment ≥ value |
conversionReadinessMin | scores.conversionReadiness ≥ value |
behavioralClass | Exact match: 'human', 'suspicious', 'likely_bot', or 'bot' |
isBot | bot.isBot equals the value |
identified | Visitor has a non-anonymous identity status, or called identify() this session |
emotionalState | Exact match on scores.emotionalState (curious, engaged, frustrated, confused, excited, decisive, hesitant, neutral) |
decisionStage | Exact match on scores.decisionStage (browsing, evaluating, comparing, deciding, purchasing) |
primaryLanguage | Case-insensitive match on the visitor's resolved base language tag (e.g. 'es') |
languageGeoMismatch | Exact 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:
| Effect | Signature | What it does |
|---|---|---|
show | show(selector, display = '') | Sets inline display (default clears it, restoring the stylesheet value) |
hide | hide(selector) | Sets display: none |
setText | setText(selector, text) | Sets textContent; text may be a string or (ctx) => string |
addClass | addClass(selector, className) | Adds a class |
removeClass | removeClass(selector, className) | Removes a class |
setAttribute | setAttribute(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
| Scenario | Reach for |
|---|---|
| One decision at page load, then done | getVisitorOrNull() and an if |
| Rules that should track the visitor through the session | applySignals, polling |
| Run-once effects (banners, offers) without flag bookkeeping | applySignals with once: true |
| Sub-second reaction on one critical page, Scale+ plan | applySignals with realtime: true |
| Visitor context inside React components | the React hooks |
| Decisions before HTML is sent | server-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.