Engineering

Real-Time Visitor Alerts in Slack via the Signals Feed

The read-only Signals Feed already streams every scored event to your own infrastructure. Add about a hundred lines of Node and one Slack incoming webhook, and it becomes an alerting system for high-intent buyers, checkout frustration, and stealth-bot bursts.

July 2026 • 10 min read • Scale plan and above

This post builds on Consume the Signals Feed, which walks the base 60-line subscriber end to end — stream-token auth, frame types, filters, reconnection. The feed is read-only, available on the Scale plan and above; nothing in this article writes anything back to ClickStream.

From a Live Stream to Real-Time Visitor Alerts

Every analytics product can tell you what happened yesterday. Real-time visitor alerts answer a different question: who is on the site right now that a human should do something about? Picture three moments (hypothetical visitors, real mechanics): a prospect your sales team has been courting hits the pricing page with an intent score in the 80s; a visitor grinds against the same checkout form for the third time with frustration climbing; a burst of traffic that behaves like automation but carries no known bot's name lands on your login page. All three are situations where minutes matter — and all three are visible, as they happen, on ClickStream's Signals Feed.

The feed is a WebSocket at /signals/stream that pushes one JSON frame per labeled event, and the base walkthrough showed that consuming it takes under 60 lines of Node with zero dependencies. This post takes the next step: a small worker that watches the stream, evaluates three alert rules, debounces per visitor, respects quiet hours, and posts to Slack through an incoming webhook. The whole thing stays a reader — the ops-creativity layer lives entirely in your process.

What Every Frame Already Carries

The reason the worker stays small is that the interesting computation already happened at the edge. Every event frame arrives pre-labeled:

The scoring conventions carry over too. The browser-side helpers treat intent >= 70 as high intent and frustration >= 60 as frustrated — the same thresholds isHighIntent() and isFrustrated() use in page code — and the worker below adopts them verbatim, so a Slack ping and an on-page reaction always agree about the same visitor.

Auth is the one part that is not copy-paste from a generic WebSocket tutorial: the feed refuses the public cs_live_/cs_test_ keys (a credential anyone can read from view-source must never read your tenant-wide stream — the same philosophy as scoped keys and domain gating) and instead takes a short-lived csst_ stream token minted from a Scale-plan dashboard session, passed via the Sec-WebSocket-Protocol handshake. The base post covers minting step by step.

Slack Auth: An Incoming Webhook, Not a Bot Token

There are two common ways to post into Slack from code: a bot token or an incoming webhook. For an alert worker, the incoming webhook is the right credential for the same reason the feed itself refuses public API keys — scope. A bot token is a workspace credential; depending on its granted scopes it can post anywhere, read conversations, or list users. An incoming webhook can do exactly one thing: post messages into the single channel it was created for. It cannot read messages, cannot enumerate anything, and if the URL ever leaks, the blast radius is spam in one channel and the fix is a one-click regenerate.

Create one in Slack under Apps → Incoming Webhooks, pick the alert channel, and treat the resulting https://hooks.slack.com/services/… URL as a secret: environment variable, never source control. Slack's published guidance rate-limits incoming webhooks to roughly one message per second — a ceiling the worker should never approach once debouncing is in place, and a healthy forcing function for building it.

Three Alerts Worth Interrupting a Channel For

Alert fatigue kills alerting channels faster than missing features do. The worker ships with three rules chosen to be rare, actionable, and addressed to a specific human:

1. A high-intent identified visitor

scores.intent >= 70 and hasIdentified === true. The intersection matters: high intent alone is a statistic, but high intent from someone who has told you who they are is a task — there is a CRM record to open and a name to act on. The alert includes decisionStage so the message reads like a briefing ("intent 84, stage: comparing"); see Beyond the Score for what the stages mean and From Anonymous to Contact for how identification happens in the first place.

2. Frustration clustering on checkout paths

scores.frustration >= 60 on a path matching /checkout, /cart, or /payment. Frustration anywhere is worth studying later; frustration inside the money path is revenue actively leaking, and the difference between a save and a lost order is often whether a human noticed within minutes. The frustration model explains what feeds the score; Route Frustrated Visitors Before They Rage covers the fuller support-triage pattern this alert is the doorbell for.

3. A stealth-bot burst

ClickStream recognizes 158 named bots across 11 categories — those arrive with bot.category and bot.name filled in, and a search crawler doing its job is rarely alert-worthy. The interesting traffic is the remainder: frames classed suspicious, likely_bot, or bot by behavior alone, with no named signature. A sustained burst of unnamed non-human traffic is what credential stuffing, scraping behind residential proxies, and carding runs look like from the analytics side. The rule keeps a rolling one-minute count of such frames and fires once when it crosses a threshold (default 30/minute — tune it to your baseline).

One boundary before the code, because it is load-bearing: these alerts route human attention; they do not change what any visitor sees. Wiring intent scores into prices or paywalls is the dark-pattern end of this space, and our position is blunt — score-gating pricing is off the table. An alert that says "go help this person" is the entire design.

The Whole Worker

Node 22+ (native WebSocket and fetch), zero dependencies. The subscriber loop at the bottom is the same protocol handling as the shipped example at examples/signals-feed-subscriber/subscribe.mjs; everything above it is the alerting layer:

#!/usr/bin/env node
// signals-slack-alerts.mjs — Signals Feed → Slack alert worker.
// Node 22+ (native WebSocket + fetch), zero dependencies.
//
//   CS_STREAM_TOKEN    Required. csst_ stream token (Scale+ dashboard).
//   SLACK_WEBHOOK_URL  Required. Slack incoming-webhook URL.
//   CS_COLLECTOR       Optional. Defaults to wss://feynman.clickstream.com.
//   QUIET_HOURS        Optional. e.g. "22-7" — hold pings, digest after.
//   BOT_BURST_PER_MIN  Optional. Unnamed non-human events/min (default 30).

const TOKEN = process.env.CS_STREAM_TOKEN;
const WEBHOOK = process.env.SLACK_WEBHOOK_URL;
if (!TOKEN?.startsWith('csst_') || !WEBHOOK?.startsWith('https://hooks.slack.com/')) {
  console.error('[alerts] set CS_STREAM_TOKEN (csst_...) and SLACK_WEBHOOK_URL');
  process.exit(1);
}
const COLLECTOR = (process.env.CS_COLLECTOR || 'wss://feynman.clickstream.com').replace(/\/$/, '');
const BURST_PER_MIN = Number(process.env.BOT_BURST_PER_MIN || 30);
const CHECKOUT_PATHS = /^\/(checkout|cart|payment)/;
const COOLDOWN_MS = 30 * 60 * 1000; // one ping per rule+visitor per 30 min

// Slack: an incoming webhook posts to exactly one channel. Failures are
// logged, never thrown — a Slack outage must not kill the subscriber loop.
async function post(text) {
  try {
    const res = await fetch(WEBHOOK, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ text }),
    });
    if (!res.ok) console.error(`[alerts] slack responded ${res.status}`);
  } catch (err) {
    console.error('[alerts] slack unreachable:', err?.message || err);
  }
}

// Debounce: at most one ping per rule+key per cooldown window.
const recentAlerts = new Map();
function debounced(rule, key) {
  const k = `${rule}:${key}`;
  const now = Date.now();
  if (now - (recentAlerts.get(k) || 0) < COOLDOWN_MS) return true;
  recentAlerts.set(k, now);
  if (recentAlerts.size > 5000) {
    for (const [old, ts] of recentAlerts) {
      if (now - ts > COOLDOWN_MS) recentAlerts.delete(old);
    }
  }
  return false;
}

// Quiet hours: hold pings, then post one digest when the window ends.
const quiet = (process.env.QUIET_HOURS || '').match(/^(\d{1,2})-(\d{1,2})$/);
const held = {};
function inQuietHours() {
  if (!quiet) return false;
  const h = new Date().getHours();
  const [start, end] = [Number(quiet[1]), Number(quiet[2])];
  return start <= end ? h >= start && h < end : h >= start || h < end;
}
function notify(rule, text) {
  if (inQuietHours()) {
    held[rule] = (held[rule] || 0) + 1;
    return;
  }
  const digest = Object.entries(held).map(([r, n]) => `${r} x${n}`).join(', ');
  if (digest) {
    post(`While you were out: ${digest}`);
    for (const r of Object.keys(held)) delete held[r];
  }
  post(text);
}

// Stealth-bot burst: rolling one-minute count of non-human frames that
// carry no named bot signature.
let unnamedTimes = [];
function unnamedPerMinute(ts) {
  unnamedTimes.push(ts);
  unnamedTimes = unnamedTimes.filter((t) => ts - t < 60_000);
  return unnamedTimes.length;
}

// The three rules.
function evaluate(ev) {
  if (ev.behavioralClass !== 'human') {
    if (!ev.bot?.name && unnamedPerMinute(ev.ts) >= BURST_PER_MIN &&
        !debounced('bot-burst', 'site')) {
      notify('bot-burst',
        `:robot_face: Stealth-bot burst: ${BURST_PER_MIN}+ unnamed non-human events/min (latest: ${ev.page})`);
    }
    return;
  }
  const s = ev.scores;
  if (!s) return;
  if (ev.hasIdentified && s.intent >= 70 && !debounced('high-intent', ev.visitorId)) {
    notify('high-intent',
      `:fire: High-intent identified visitor on ${ev.page} — intent ${s.intent}, stage: ${s.decisionStage}`);
  }
  if (s.frustration >= 60 && CHECKOUT_PATHS.test(ev.page) &&
      !debounced('frustration', ev.visitorId)) {
    notify('frustration',
      `:rotating_light: Checkout frustration on ${ev.page} — frustration ${s.frustration}, state: ${s.emotionalState}`);
  }
}

// The subscriber loop — same protocol as the base example.
function connect() {
  const url = `${COLLECTOR}/signals/stream?filter=all`;
  const ws = new WebSocket(url, ['clickstream-v1', TOKEN]);
  ws.addEventListener('message', (event) => {
    let msg;
    try { msg = JSON.parse(event.data); } catch { return; }
    if (msg.type === 'ping') {
      ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
    } else if (msg.type === 'event') {
      evaluate(msg);
    }
  });
  ws.addEventListener('close', (event) => {
    console.error(`[alerts] closed code=${event.code} reason=${event.reason || '(none)'}`);
    if (event.code === 1008 || /401|token/i.test(event.reason || '')) {
      console.error('[alerts] stream token expired — mint a fresh CS_STREAM_TOKEN');
    }
    setTimeout(connect, event.code === 4008 ? 0 : 5000);
  });
  ws.addEventListener('error', (err) => console.error('[alerts] error:', err?.message || err));
}
connect();

Run it with the two required secrets in the environment:

# mint a csst_ stream token from the dashboard (Scale+), then:
CS_STREAM_TOKEN=csst_xxxx \
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxx \
QUIET_HOURS=22-7 \
node signals-slack-alerts.mjs

Two decisions worth calling out. First, the worker subscribes with ?filter=all rather than a server-side rail like humans_only: the server-side filters exist so single-concern subscribers never see traffic they'd discard, but this worker deliberately watches both sides — humans for the intent and frustration rules, non-human frames for the burst rule — so it takes the firehose and routes on behavioralClass itself. Second, every Slack failure is logged and swallowed. The alerting layer must never crash the subscriber loop; a worker that dies because Slack hiccuped is the alerting equivalent of the anti-pattern the fail-open post exists to prevent.

Debouncing: One Visitor, One Ping

The feed emits one frame per event, and the score snapshot rides every frame. A visitor reading your pricing page attentively can produce dozens of scroll and click events in a few minutes, every one of them carrying intent: 84 — and a naive worker would post dozens of identical pings. The debounced() map enforces at most one alert per rule + visitor per 30-minute cooldown, which turns "this visitor is hot" into exactly one message. The burst rule uses a site-wide key instead of a visitor key, because a bot burst is one incident, not a thousand.

The pruning pass matters on busy sites: entries older than the cooldown are swept whenever the map grows past 5,000 keys, which bounds memory without a timer. And because the cooldown ceiling means the worker can post at most a handful of messages per minute, Slack's roughly one-per-second webhook limit stays comfortably out of reach.

Quiet Hours Without Losing the Signal

QUIET_HOURS=22-7 holds pings between 22:00 and 07:00 in the worker's local time (the wrap-around comparison handles windows that cross midnight). Held alerts increment per-rule counters, and the first alert after the window ends is preceded by a one-line digest — "While you were out: high-intent x4, frustration x2" — so the overnight story is summarized, not silently discarded. If you want the digest posted at exactly 07:00 rather than with the next alert, that's a five-line setInterval; the version above keeps the dependency count at zero and the logic obvious.

One honesty note: quiet hours summarize what the worker saw while connected. The feed is a real-time tap with no replay — events that arrive while the worker is down are gone from the stream's perspective. That's the right trade for alerting (an alert about last night's visitor is not an alert), but it means this channel is not an audit log. The durable record lives in the batch exports — CSV on Growth and above, Parquet raw export on Scale and above.

Operational Notes

The Bottom Line

Real-time visitor alerts sound like a product tier. They're actually about a hundred lines of glue between two well-scoped credentials: a read-only stream token that can only watch, and an incoming webhook that can only post to one channel. The feed does the hard part at the edge — scoring, bot classification, consent filtering — and the worker's whole job is judgment: which three of the thousands of frames per hour deserve a human's next five minutes.

A dashboard is where you find out what happened. An alert is a decision that this event, from this visitor, is worth a person's attention right now — and the entire art is saying it once.

Put a Human Where the Intent Is

Install the pixel, upgrade to Scale, and point a hundred lines of Node at your own stream. The next high-intent visitor pings your team while they're still on the page.

Start free