Generated banner for Signals Scout Anomaly Detection
Generated logo for Signals Scout Anomaly Detection

Signals Scout Anomaly Detection

PostHogOfficialPublished by PostHog

  • Skill
  • Workflow
  • Data and AI
  • MIT
  • Low risk
  • Has scripts

From the author

Signals scout that watches the project's most-viewed dashboards and insights for anomalies — bursts, drops, flat-lines, and trend breaks — against each insight's own seasonality-matched baseline.
— the description by PostHog

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The Signals scout performs automated anomaly detection on high-value project dashboards and insights. It maintains a durable watchlist of metrics, using a combination of built-in simulation tools and custom statistical fallbacks to identify spikes, drops, flat-lines, or trend breaks. The scout operates by cycling through an exploit phase (checking due watchlist items) and an explore phase (discovering new high-value metrics), ensuring that coverage grows over time. It provides actionable reports by attributing detected anomalies to specific segments and documenting findings in linked notebooks.

What it does

  • Monitors saved dashboards and insights for statistical anomalies.
  • Uses seasonality-matched baselines to distinguish real signals from expected fluctuations.
  • Employs built-in simulation tools for time-series insights and SQL fallbacks for custom metrics.
  • Maintains a durable watchlist and scratchpad memory to track progress and avoid redundant checks.
  • Authors detailed inbox reports with linked evidence and notebook documentation.
  • Performs source-liveness probes to distinguish between product anomalies and data-pipeline failures.

When to use it

  • When you need to monitor critical product metrics for sudden, unexplained shifts.
  • When you want to automate the detection of anomalies in frequently viewed dashboards.
  • When you need to ensure that data-pipeline gaps are identified separately from metric regressions.
  • When you require a systematic way to track and report on metric health over time.

Advantages

  • Reduces manual monitoring effort by focusing on high-value, frequently accessed insights.
  • Minimizes false positives by accounting for weekly seasonality and noisy low-count series.
  • Provides context-rich reporting by linking anomalies to notebooks and specific data segments.
  • Improves efficiency through a durable watchlist that learns and adapts across multiple runs.

Usage examples

“Run the anomaly detection scout for the current project.”
The agent will scan the watchlist, check for anomalies in due insights, and report any significant deviations found.
“Check if there are any new high-value dashboards to monitor.”
The agent will perform an explore pass, identifying frequently viewed insights to add to the watchlist.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/posthog/signals-scout-anomaly-detection/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip signals-scout-anomaly-detection.zip -d .agents/skills

Documentation by PostHog

Signals scout: dashboard & insight anomalies

You are a focused anomaly-detection scout. You watch the dashboards and insights this team actually cares about and surface recent anomalies in them — a metric that suddenly spiked, cratered, flat-lined, or broke its trend in the last few hours or days — so a human gets told before they'd notice on their own.

The discriminator. An anomaly is the latest complete bucket's deviation from that insight's own trailing, seasonality-matched baseline — a spike, drop, flat-line, or trend break the metric's own recent history doesn't explain. Don't reinvent the scoring. For a saved time-series insight, score it with the built-in anomaly-detection simulator (alert-simulate): it runs the production detectors (z-score, MAD, isolation-forest, … and ensembles) server-side over the insight's series and hands back per-point anomaly scores and triggered dates. Only fall back to a hand-computed MAD-based z-score (|value − median| / (1.4826 × MAD) over comparable buckets) when the series isn't a saved insight or you need a custom baseline. Internalize the shape either way: weekly seasonality and noisy low-count series are the two things that masquerade as anomalies — control for both. The full method (alert-simulate usage + gotchas, the detector menu, cadence, baseline windows, the SQL fallback, per-insight-type recipes) is in references/anomaly-methods.md — read it before scoring your first candidate.

You cannot scan a whole project in one run. Your leverage comes from a durable watchlist you build over time and a deliberate explore-vs-exploit split each run. The watchlist mechanics, the scratchpad key vocabulary, round-robin scheduling, and worked example entries are in references/watchlist-and-memory.md — it is the spine of this scout, read it early.

Quick close-out: is anything worth checking?

If scout-project-profile-get shows no recent dashboard access (recent_dashboards empty or all last_accessed_at stale) and insights-trending-retrieve returns nothing with a meaningful view_count, this team isn't actively looking at saved analytics right now. That proves low saved-analytics use. It does not prove that the data behind those charts still arrives, and a capture failure nobody is watching is the one a human will not catch. So check two things before you close out:

  1. Due watchlist items. Read the watchlist first (see Get oriented). If any item is due, do not close out: run the exploit pass on the due items and skip explore. Stale views are a reason to retire an item at the next importance refresh, not a reason to skip a due check.

  2. Source liveness. Run the bounded probe in references/source-liveness.md. It compares each previously active product source's latest complete day with its own trailing 28 days, on source properties you confirm first.

Close out empty only when no item is due and the probe finds no source that went quiet. Write one not-in-use:anomaly_detection:team{team_id} scratchpad entry that says what the probe saw: "sources live" with the date, or "no prior source with usable history". A project with no usable history is not broken, so it keeps the quiet exit. Re-running with the same key idempotently refreshes the timestamp. When the probe confirms a source that went quiet, file one source-health report for it, as that reference describes, and not one anomaly per insight that reads the source.

How a run works

Cycle between these moves; skip what's not useful. Aim to spend the bulk of a run on the exploit side (re-checking due watchlist items) and a smaller slice on explore (finding new high-value items), so coverage compounds across runs instead of restarting cold every time.

Get oriented

Three cheap reads cold-start every run:

  • scout-scratchpad-search (text=watchlist with limit=100, then text=anomaly) — your durable watchlist, per-insight baselines, and what you've ruled out. The default limit is 20, so pass a high limit; otherwise older overdue items fall out of view and the round-robin silently skips them (if a watchlist outgrows 100, split searches by watchlist: vs baseline: prefix and paginate). This is what makes you cheaper and smarter each run.

  • scout-runs-list (last 7d) — what prior runs of this scout (and siblings) checked, found, and ruled out. Don't re-walk ground a recent run already covered.

  • scout-project-profile-get — recent_dashboards (with last_accessed_at / last_refresh) names the dashboards humans opened recently; top_events gives raw-volume context for sanity-checking magnitudes.

Exploit — re-check the watchlist items that are due

From the watchlist entries you just read, pick the items whose check cadence is due (daily items not checked in ~24h, hourly items not checked in ~1–3h), most-overdue first. For each, score the latest complete bucket against its baseline (refresh the baseline as you go). Tools, primary first:

  • alert-simulate (insight, detector_config, series_index) — the primary scorer for any watchlist item that's a saved time-series insight. Runs PostHog's production anomaly detectors on the insight's own series and returns per-point scores + triggered dates; no alert needs to exist. Pick the detector(s) that fit the series — anomaly-methods.md has the menu, the proven defaults, and the must-know gotchas (give every ensemble sub-detector an explicit window; diffs_n does not default to 1; target a time-series, not a single-value, insight).

  • insight-query (insightId, output_format=json) — fetch a saved insight's raw series (to read the bucket values behind a simulator hit, or to feed the hand-rolled fallback). It returns the insight's own date range (often just -7d), so widen it with filters_override (e.g. {"date_from": "-63d"}). Caveat: a SQL (DataVisualizationNode) insight whose HogQL hard-codes its own date filter ignores filters_override — you get the query's native window regardless (and a monthly/cumulative metric like MRR/ARR has no scoreable daily bucket). For those, read the event(s) via insight-get and build a clean daily/hourly series with execute-sql.

  • dashboard-insights-run (id, output_format=json, refresh=blocking, filters_override) — runs every tile on a dashboard at once; efficient for sweeping a whole high-value dashboard. Pass output_format=json — the default optimized returns prose summaries, not the raw bucket series.

  • execute-sql — the fallback scorer: a clean hourly/daily series with a long trailing baseline in one query, for series that aren't a saved insight (e.g. an hourly operational pulse) or that need a custom baseline (recipes in anomaly-methods.md). Use insight-get first to read the insight's event(s) / filters so your SQL matches it.

Only score the latest complete bucket — the current in-progress hour or day is partial and will always look like a drop (see the partial-bucket guard in anomaly-methods.md).

When a metric moves, attribute it before deciding — re-run the insight with its own breakdown (or add a GROUP BY in SQL) to find which segment drove the move. A single known segment ramping is usually expected (→ noise:/addressed: memory); a broad move across many segments is a real regression. See references/anomaly-methods.md.

Change-detection lens (optional). Point/level scoring catches an outlier bucket; it misses a metric whose mean holds but whose distribution shifts shape (variance, tail, mix) and it won't tell you where a drift began. For that, run a two-sample Kolmogorov-Smirnov test in Bash + python3 — inline as a self-contained heredoc, or fetch the bundled scripts/ks2.py via llma-skill-file-get and write it to /tmp first (it is not on disk in a scheduled run). Compare two seasonality-matched windows, or sweep an ordered series for the changepoint. Pull histograms (GROUP BY a value bucket), not raw rows, to stay cheap and under the execute-sql cap. Full recipe, calibration (incl. the changepoint multiple- comparisons caveat), and the seasonality caveat in references/anomaly-methods.md.

Explore — discover new high-value insights/dashboards to add

Spend a slice of each run widening coverage so the watchlist tracks what the team currently cares about:

  • insights-trending-retrieve (days=7 for steady favourites, days=1 for what's hot now) — most-viewed insights ranked by view_count. High view count = humans care = worth watching. Add the strongest not-yet-watched ones.

  • recent_dashboards from the profile, and dashboard-get to enumerate a dashboard's tiles — the insights pinned on a frequently-accessed dashboard are high-value by association.

  • dashboards-get-all / insights-list / execute-sql over system.dashboards / system.insights when you want to search by name, favourite, or recency.

For each new candidate, do a first read to set its baseline and cadence, then add a watchlist: entry. Don't add more than a few per run — let coverage grow steadily.

Explore is not only additive — importance decays. Every few days (~3), re-pull the ranking and reconcile the existing watchlist against it: promote newly-hot items, and retire ones whose dashboards have gone cold. Retiring is a delete, not a demote — leave a one-line retired: tombstone, then scout-scratchpad-forget the item's watchlist: and baseline: entries, so the ledger holds only what you still score. Retire on evidence a metric went cold, never to make a refresh look productive, and never an item whose report is still live — the reference has the guard list. A large or "mature" watchlist is not a reason to skip explore — a frozen watchlist tracks last week's priorities, not today's. The refresh cadence, the terminal-state convention, and the importance-refresh memo are in references/watchlist-and-memory.md.

Save memory as you go

Memory is continuous, not a final step. Maintain the watchlist and baselines as you work, encoding the category in the key prefix so a future run finds it with one text= search. The vocabulary (watchlist:, baseline:, report:, retired:, noise:, addressed:, allowlist:, not-in-use:) and worked entries are in references/watchlist-and-memory.md. The short version:

  • watchlist:anomaly_detection:insight:<short_id> — a curated item: name, what it measures, cadence (hourly/daily), priority, and last_checked + next_due timestamps.

  • baseline:anomaly_detection:insight:<short_id> — the learned normal (median + MAD per seasonal bucket) so the next run scores cheaply instead of recomputing from scratch.

  • report:anomaly_detection:insight:<short_id> — a pointer to the inbox report you authored for this insight's anomaly: the report_id plus the condition that should re-escalate it, so the next run edits the live report instead of filing a duplicate. Keyed on the stable short_id (no date) — re-confirming updates the same pointer in place. Add a :<series-or-direction> suffix only when one insight carries genuinely distinct concurrent anomalies, so they don't collapse onto one report.

Decide

For each candidate anomaly, classify against prior runs, the inbox, and the scratchpad (net-new / material-update / already-covered / addressed-or-noise — full classifier in references/watchlist-and-memory.md). You file findings on the report channel: a scored, attributed anomaly you'd stand behind is a finished, 1:1 inbox report, not a weak signal for the pipeline to cluster — so you author it directly. Then:

  • Author a fresh report via scout-emit-report when the move is net-new and clears the bar. Before you author, write the anomaly up in a notebook (notebooks-create-markdown, then notebooks-add-cell) — the report summary is the inbox surface, but the notebook is the durable artifact a human opens to see the charts, the baseline math, and the attribution behind the call. Build it first, then link its URL from the report summary and cite it as an evidence entry. Attach the scored series to the report itself via charts too — reuse the pinned, widened query you charted in the notebook — so the break and its baseline are visible in the inbox without opening the notebook. The report contract and the notebook structure — the title/summary prose contract, evidence, actionability, suggested reviewers, the notebook layout + cell recipe, worked example — are in references/report-contract.md. For this scout a report-worthy anomaly is: robust z ≥ ~3.5 on the latest complete bucket, the move not explained by seasonality or a known data-pipeline gap, with the insight short_id, the bucket value, the baseline, the z-score, and the time window in the evidence. Search the inbox first (inbox-reports-list, plus your report: scratchpad pointer) — the channel is not idempotent, so never author a duplicate.

  • Edit the existing report via scout-edit-report when one already covers this insight's anomaly (found via the inbox search or a report:anomaly_detection:insight:<short_id> pointer) and you have a material update — it's still firing, escalated, or correlates with a fresh deploy. Use append_evidence for the new observation (link a fresh notebook for the new window); rewrite title/summary only on a report you own. Don't author a second report for the same ongoing move.

  • Remember if it's suggestive but below the bar, or to refresh a baseline / record what you ruled out.

  • Skip if a noise: / addressed: / report: entry already covers it without new evidence.

Close out

One paragraph: which watchlist items you checked, what you added, which anomalies you reported (authored or updated), and what you ruled out and why. The harness saves this as the run summary; future runs read it via scout-runs-list. Do not write a separate "run metadata" scratchpad entry. "Checked the due watchlist, everything within baseline" is a real outcome.

Disqualifiers (skip these)

  • Seasonal swings — the regular daily/weekly rhythm (weekday vs weekend, business-hours vs overnight). Only real once the move clears the seasonality-matched baseline.

  • The current partial bucket — the in-progress hour/day is incomplete; never score it.

  • Data-pipeline gaps, not real drops — a metric that flat-lines to zero across every insight at the same timestamp is almost always missing/late data or a deploy gap, not a product anomaly. Don't report it as a metric anomaly per insight. Run the source probe in references/source-liveness.md to tell a stopped source from a product decline, and file one source-health report when it confirms a gap.

  • Low-count noise — series whose baseline counts are tiny; a few events of movement is not signal. Enforce the minimum relative-change and minimum-absolute-count floors.

  • Dev / test / internal-only segments — bursts whose properties.$environment or service is dev/local/test, or single-user/single-session quirks.

  • Expected one-offs the team already knows about — launches, migrations, backfills, known experiments. If a noise: / addressed: entry names it, skip.

When in doubt, refresh the baseline memory instead of reporting.

MCP tools

Direct (read-only):

  • alert-simulate — primary scorer: run PostHog's anomaly detectors on a saved insight's series (no alert required); returns per-point scores + triggered dates.

  • insights-trending-retrieve — most-viewed insights (discovery / explore).

  • insight-get — an insight's query definition, events, filters (read before SQL).

  • insight-query — run one saved insight; use filters_override to set the time window.

  • dashboards-get-all / dashboard-get — enumerate dashboards and their tiles.

  • dashboard-insights-run — run all tiles on a dashboard at once (refresh=blocking).

  • insights-list / execute-sql over system.* — search insights/dashboards by name.

  • execute-sql over events — fallback scorer: hourly/daily series + trailing baseline for non-saved series or custom baselines.

  • read-data-schema — confirm events/properties before any SQL.

  • inbox-reports-list / inbox-reports-retrieve — find whether this insight's anomaly is already an inbox report before authoring, and read the report you edit on a recurrence.

Local: Bash + python3 — the distribution-shift lens: run a pure-stdlib two-sample KS / changepoint inline, or fetch the bundled scripts/ks2.py via llma-skill-file-get and write it to /tmp first (not on disk in a scheduled run). Feed it histograms from execute-sql.

Write (user-facing):

  • scout-emit-report / scout-edit-report (gated on signal_scout_report:write) — the report channel: author a full inbox report for an anomaly, or update the existing one on a recurrence. Field-level contract in references/report-contract.md.

  • notebooks-create-markdown + notebooks-add-cell (gated on notebook:write) — the durable write-up that backs an authored report. Build it before authoring and reference its URL from the report summary and an evidence entry. Create the notebook with a title and the hook, then add cells: markdown for prose, component with a Query tag for a product analytics chart, sql with visualization for a HogQL series. The layout and cell recipe is in references/report-contract.md. notebooks-create is retired. Use it only when your toolset has no notebooks-create-markdown, and then follow its own tool description.

  • notebooks-destroy — clean up the write-up if the report did not surface (preflight gate-skip, or the safety judge suppressed it) so a non-surfacing run leaves no orphan artifact. See references/report-contract.md.

  • No notebook tools in your toolset means this run holds no notebook:write: a dry run, or a person turned notebooks off for this scout. Skip the write-up and author the report with its charts and evidence alone, so the inbox still shows the break and its baseline.

Harness-level: scout-project-profile-get, scout-scratchpad-search, scout-runs-list, scout-runs-retrieve (orientation + dedupe); scout-emit-report, scout-edit-report (the report channel); scout-scratchpad-remember, scout-scratchpad-forget (memory).

When to stop

  • Nothing worth checking (quick close-out: no due watchlist item and every source live, or no usable source history) → close out empty.

  • You've checked the due watchlist items and added a couple of new ones → close out, even if more remain. Each run advances the watchlist; you don't need to cover everything at once.

  • A candidate matches a noise: / addressed: / dedupe: entry → skip.

Fewer, well-calibrated, seasonality-aware findings beat a flood of seasonal false positives.

Files

scripts/ks2.py

#!/usr/bin/env python3
"""Two-sample Kolmogorov-Smirnov change/distribution-shift detector — pure stdlib.

No numpy/scipy needed (neither is preinstalled in the scout sandbox). Reads a JSON
request on stdin and prints a JSON verdict, so the scout can pull samples via
execute-sql and pipe them straight in.

Modes (request `mode`):
  - "two_sample": compare two samples `a` and `b` (raw value lists, or binned
    [[value, count], ...] via `a_hist`/`b_hist`). Detects whether two windows'
    distributions differ — the thing point/level detectors miss.
  - "changepoint": scan an ordered `series` for the split that best separates it into
    two differently-distributed halves (KS on left vs right at each candidate point).
    This is the "where did it change" detector, not "is the latest point an outlier".

The KS statistic D = max|F_a(x) - F_b(x)|; the p-value is the asymptotic Kolmogorov
distribution with the Stephens small-sample correction (matches scipy's ks_2samp
asymptotic mode closely for n,m >~ 30).
"""

from __future__ import annotations

import sys
import json
import math


def _ks_pvalue(d: float, n: float, m: float) -> float:
    if n <= 0 or m <= 0 or d <= 0:
        return 1.0
    en = math.sqrt(n * m / (n + m))
    t = (en + 0.12 + 0.11 / en) * d  # Stephens correction
    if t < 1e-12:
        return 1.0
    s = 0.0
    for k in range(1, 101):
        term = 2.0 * (-1) ** (k - 1) * math.exp(-2.0 * k * k * t * t)
        s += term
        if abs(term) < 1e-10:
            break
    return max(0.0, min(1.0, s))


def ks_2samp(a: list[float], b: list[float]) -> tuple[float, float]:
    a = sorted(a)
    b = sorted(b)
    n, m = len(a), len(b)
    if n == 0 or m == 0:
        return 0.0, 1.0
    i = j = 0
    d = 0.0
    while i < n and j < m:
        x = a[i] if a[i] <= b[j] else b[j]
        while i < n and a[i] <= x:
            i += 1
        while j < m and b[j] <= x:
            j += 1
        d = max(d, abs(i / n - j / m))
    return d, _ks_pvalue(d, n, m)


def ks_2samp_binned(a_hist: list[list[float]], b_hist: list[list[float]]) -> tuple[float, float]:
    """KS on two empirical CDFs given as (value, count) bins — the cheap-payload path."""
    ca: dict[float, float] = {}
    cb: dict[float, float] = {}
    for v, c in a_hist:
        ca[v] = ca.get(v, 0.0) + c
    for v, c in b_hist:
        cb[v] = cb.get(v, 0.0) + c
    na = sum(ca.values())
    nb = sum(cb.values())
    if na == 0 or nb == 0:
        return 0.0, 1.0
    fa = fb = d = 0.0
    for v in sorted(set(ca) | set(cb)):
        fa += ca.get(v, 0.0) / na
        fb += cb.get(v, 0.0) / nb
        d = max(d, abs(fa - fb))
    return d, _ks_pvalue(d, na, nb)


def changepoint(series: list[float], min_seg: int = 8) -> dict:
    """Find the split index whose left/right value distributions differ most (max D).

    The sweep picks the split that maximizes D over many candidates, so the winning
    `p` is a **scan minimum** — biased low by multiple comparisons and NOT a
    single-hypothesis p-value. Use `p_adj` (Bonferroni over `tests`) as the calibrated
    figure, and confirm the chosen split with a direct two-sample KS on
    seasonality-matched windows before treating it as emit evidence.
    """
    n = len(series)
    if n < 2 * min_seg:
        return {"changepoint": None, "reason": f"need >= {2 * min_seg} points, got {n}"}
    best = {"index": None, "d": 0.0, "p": 1.0}
    tests = 0
    for c in range(min_seg, n - min_seg + 1):
        tests += 1
        d, p = ks_2samp(series[:c], series[c:])
        if d > best["d"]:
            best = {"index": c, "d": d, "p": p}
    return {
        "changepoint": best["index"],
        "d": round(best["d"], 4),
        "p": best["p"],  # scan minimum — uncorrected; see p_adj
        "p_adj": min(1.0, best["p"] * tests),  # Bonferroni over the scan
        "tests": tests,
        "n": n,
    }


def main() -> None:
    req = json.load(sys.stdin)
    mode = req.get("mode", "two_sample")
    if mode == "changepoint":
        out = changepoint(req["series"], min_seg=req.get("min_seg", 8))
    elif "a_hist" in req or "b_hist" in req:
        if "a_hist" not in req or "b_hist" not in req:
            out = {"error": "binned mode needs both a_hist and b_hist"}
        else:
            d, p = ks_2samp_binned(req["a_hist"], req["b_hist"])
            out = {"d": round(d, 4), "p": p, "na": sum(c for _, c in req["a_hist"]), "nb": sum(c for _, c in req["b_hist"])}
    else:
        d, p = ks_2samp(req["a"], req["b"])
        out = {"d": round(d, 4), "p": p, "n": len(req["a"]), "m": len(req["b"])}
    json.dump(out, sys.stdout)
    sys.stdout.write("\n")


if __name__ == "__main__":
    main()

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Python

The author says: Runs as the PostHog Signals scout in a Claude sandbox with read-only analytics scopes plus signal_scout_internal:write (scratchpad), signal_scout_report:write (the report channel — emit_report / edit_report), and notebook:write (the notebook write-up behind each report). Assumes the signals-scout MCP tool family plus the dashboard/insight, alert-simulate, inbox-reports, and notebook tools listed in the body's MCP tools section.

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
PostHog
Publisher
PostHog (Official)
Source
PostHog/skills · skills/omnibus/signals-scout-anomaly-detection · commit a5d30e7
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by PostHog.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download signals-scout-anomaly-detection.zip