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.
Scanned 9/1/2026
Install to Claude Code
npx -y skills add PostHog/posthog --skill signals-scout-anomaly-detection --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Signals Scout Anomaly Detection?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/posthog-signals-scout-anomaly-detection-posthog)More formats (shields.io, HTML) on the badges page.
---
name: signals-scout-anomaly-detection
description: >
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.
compatibility: >
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.
allowed_tools:
- emit_report
- edit_report
metadata:
owner_team: signals
scope: anomaly_detection
---
# 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 PostHog's own 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`](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`](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. Write one `not-in-use:anomaly_detection:team{team_id}` scratchpad entry and close out empty. Re-running with the same key idempotently refreshes the timestamp.
## 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`](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`](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`](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`](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`](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`) — 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 widened-window node you embedded 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 + embedded-chart recipe, worked example — are in [`references/report-contract.md`](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. `append_note` with the new evidence (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. Note it (it may be worth its own report) but don't report it as a metric anomaly per insight.
- **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`](references/report-contract.md).
- `notebooks-create` (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. Layout + embedded-chart recipe (embed the anomalous insight with a `SavedInsightNode`; chart a SQL-fallback series with a `DataVisualizationNode`) is in [`references/report-contract.md`](references/report-contract.md).
- `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`](references/report-contract.md).
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) → 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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!