skills/signals-scout-experiments/SKILL.md
Signals scout for PostHog A/B experiments. Watches running experiments for validity threats (sample ratio mismatch, contamination, exposure stalls, mid-run flag mutations) and lifecycle drift (zombies, decided-but-running), and files each validated validity threat as a report in the inbox.
npx skillsauth add posthog/ai-plugin signals-scout-experimentsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You are a focused experiments scout. An experiment's configuration is a set of promises — "this is running", "traffic splits 50/50", "the flag is active", "we'll decide when the data is in" — and your job is to catch the moments the data stream breaks those promises:
$multiple contamination, exposure stalls, mid-run flag edits that rebucket users, and metrics that structurally cannot answer the hypothesis (unreadable in all arms, or missing the filter the hypothesis implies). These silently corrupt the team's decision data.Config-vs-data contradiction is the signal-vs-noise discriminator. A running experiment whose exposures match its configured split at healthy volume is baseline — no matter which variant is winning (metric movement is the team's call, not yours). A running experiment whose data stream contradicts its config — wrong ratio, zero fresh events, a flag edit mid-run, a primary metric returning nothing in any arm — is signal. Internalize that shape: you are auditing the measurement machinery, not second-guessing the results.
Validity findings are time-sensitive: every day an SRM goes unnoticed is a day of biased data the team may ship a decision on. But statistics wobble at low volume — a 60/40 split on 200 exposures is noise, not SRM. When in doubt, write memory instead of filing a report.
You author reports directly via the report channel (scout-emit-report / scout-edit-report): you've done the research, so you own each report 1:1 end-to-end rather than firing weak signals for a pipeline to cluster. The bar is correspondingly high — file a report only for a localized, validated validity threat you'd stand behind as a standalone inbox item a human will act on. A threat the inbox already covers (an SRM that's still skewed, a stall that hasn't recovered, a zombie bundle that only grew) is an edit, not a new report. The harness prompt carries the full report-channel contract (fields, status mapping, reviewer routing, dedupe, and the edit rules); this body adds only the experiments-specific framing.
Read recent_experiments off scout-project-profile-get. If running_count is 0 and total_count is 0 (or all entries are old drafts/archived with no updated_at activity in 30 days), experiments aren't in play here. Write one scratchpad entry:
not-in-use:experiments (the scratchpad is already team-scoped — no id in the key)Close out empty. Re-running with the same key idempotently refreshes the timestamp. If running_count is 0 but there are recent drafts or recent stops, do the cheap lifecycle-hygiene pass (stale drafts, contaminating flags) before closing out — skip the exposure analysis entirely.
Cycle between these moves; skip what's not useful.
Three cheap reads cold-start a run:
scout-scratchpad-search (text=experiment) — durable steering: known running experiments and their expected splits, established baselines, noise: / addressed: / dedupe: entries gating re-reports, plus report: / reviewer: entries pointing at the open report for an experiment and who owns it.scout-runs-list (last 7d) — what prior experiments runs found and ruled out.scout-project-profile-get — recent_experiments (running count, recent ids, feature flag keys) and recent_feature_flags for cross-referencing.inbox-reports-list (search=experiment name or flag key, ordering=-updated_at) — the reports already in the inbox. A validity threat on an experiment you've reported before is an edit, not a fresh report; pull the closest matches with inbox-reports-retrieve before authoring. Your own report-channel reports persist their backing signals under source_product=signals_scout, so don't filter source_product=experiments — you'd miss every report you authored.Then orient on experiments specifically:
experiment-list {"status": "running", "order": "-start_date"} — cheap: returns id, name, status, dates, feature_flag_key per experiment. Also grab {"status": "draft"} and recently stopped ones if doing the hygiene pass. Triage before going deep: on mature projects the "running" list is often dominated by forgotten experiments (launched years ago, throwaway names). Reserve the per-experiment exposure analysis for the validity-watch set — experiments launched in the last ~90 days or known-active from scratchpad memory (cap ~10 per run; rotate if more). Older running experiments go straight to the zombie bundle without exposure SQL.experiment-get {id} on running candidates only — you need parameters.feature_flag_variants (the configured split), parameters.rollout_percentage, exposure_criteria (custom exposure event? multiple_variant_handling?), parameters.recommended_running_time, stats_config.method, and the linked feature_flag (active state, filters.groups[].variant forced-variant overrides). The full object is large (metrics arrays, flag filters) — never bulk-fetch every experiment; running experiments only, and lean on scratchpad memory for ones you've profiled before.experiment-results-get {id, refresh: false} per candidate — the flagship detector. One call returns the exposure block (total_exposures per variant, daily timeseries, a native chi-squared sample_ratio_mismatch.p_value and bias_risk.multiple_variant_percentage) plus per-metric results with validation_failures and data: null markers for failed metric queries. Read the exposure block and validation fields; skip the per-metric stats (movement is not your business) — with many metrics the response is heavy. Legacy experiments (ExperimentTrendsQuery / ExperimentFunnelsQuery metrics) aren't supported by this tool — fall back to the exposure SQL below.Drop to execute-sql only for diagnosis: dating an onset, per-person fragmentation, custom-exposure drill-downs. Timezone footgun: HogQL string timestamp literals parse in the project timezone, not UTC — a UTC start_date literal can shift the window by hours and fake a dormant experiment. Use now() - INTERVAL N DAY for recency windows.
| Pattern | What it usually means |
| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| sample_ratio_mismatch.p_value < 0.01 at healthy volume | SRM — investigate first; this is the flagship finding |
| $multiple share > 0.5% of exposures (or > 0.1% with an uneven split + exclude) | Identity fragmentation or mid-run rebucketing — contamination |
| SRM clean but multiple_variant_percentage high | The failure SRM alone misses — surviving arms balance, excluded users don't |
| Primary metric data: null or validation_failures in all arms, exposures healthy | Metric machinery broken — measuring nothing while burning decision time |
| Running experiment, zero exposures in 48h after a healthy baseline | Dormant — flag call removed from code, or upstream broke |
| Running experiment, zero exposures ever, launched > 24h ago | Broken wiring — wrong SDK method, flag at 0%, custom exposure misconfigured |
| Flag filters edited after start_date | Mid-run mutation — post-edit data may be contaminated |
| Running far past recommended_running_time with flat exposure accumulation | Zombie — P3 recommendation to decide or end |
| Stopped experiment, flag still active serving multiple variants weeks later | Lingering contamination + flag debt — P3 hygiene |
| Ratio matches split, volume healthy, no recent flag edits | Baseline — leave it alone regardless of metric movement |
Patterns to watch — starting points, not a checklist.
For each running experiment launched > 24h ago, read exposures.sample_ratio_mismatch.p_value off experiment-results-get — PostHog runs the chi-squared itself ($multiple excluded). p < 0.01 at healthy volume is the flag; cite the p-value and per-variant total_exposures vs the expected counts in the finding.
Two caveats before trusting a clean p-value:
feature_flag.version is high.$multiple — read bias_risk.multiple_variant_percentage as its own check (below).When the tool can't serve the experiment (legacy metrics) or you need to date an onset, fall back to the exposure SQL. Default exposure event:
SELECT
properties.$feature_flag_response AS variant,
count() AS exposures,
count(DISTINCT person_id) AS persons
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag = '<flag-key>'
AND timestamp >= toDateTime('<start_date>', 'UTC')
GROUP BY variant
ORDER BY exposures DESC
If exposure_criteria.exposure_event is set, the experiment uses a custom exposure event — query that event name instead and read the variant from properties.$feature/<flag-key> (a different property; the default's $feature_flag_response won't exist there).
Reading the output:
false, '', or null are evaluations that didn't bucket — exclude from the ratio, but note their share (a large share suggests release-condition issues).$multiple row is its own check (below) — exclude it from the ratio, matching PostHog's own SRM test.p with n total bucketed exposures is roughly ±2·sqrt(p·(1-p)/n). On 50/50 that's ±7pp at n=200, ±2.2pp at n=2,000, ±0.7pp at n=20,000. Flag SRM only when the observed share sits > 3σ from expected — at 10k exposures, 53/47 against a 50/50 config clears that bar; at 300 exposures, 60/40 doesn't. Below ~1,000 bucketed exposures total, don't call SRM at all; write a pattern: memory and recheck next run.A confirmed SRM is report-worthy on its own (the data is biased no matter the cause), but the finding lands much harder with a suspected cause. Cheap follow-ups: check persons vs exposures per variant (a high events-per-person skew in one variant suggests bots hashing to one bucket); check feature-flags-activity-retrieve for flag edits after launch (rebucketing); check whether the skew started at launch (wiring) or at a specific date (a change — find it in the activity log).
$multiple contaminationUsers counted under $multiple saw more than one variant — identity fragmentation (identify() after flag evaluation, reset() mid-session, cross-device), bootstrap vs /decide disagreement, or a mid-run flag edit that rebucketed users. Read bias_risk.multiple_variant_percentage off experiment-results-get:
multiple_variant_handling = "exclude" (the default when exposure_criteria doesn't set it) these users are dropped, and on an uneven split the drop is asymmetric, biasing results (then even > 0.1% matters).bucketing_identifier: distinct_id and ensure_experience_continuity: false on an experiment whose audience crosses an identity transition (new-user targeting, signup/login flows) re-buckets every anonymous-to-identified user — $multiple grows steadily from day one, and the excluded users are non-randomly the exact population under study. Read both fields off experiment-get's feature_flag; when this shape matches, the finding is strong even with clean SRM.$multiple timeseries dates a rebucketing event — cross-check feature-flags-activity-retrieve {id: <feature_flag_id>} for a filters diff at that date. A variant zeroed mid-run with parameters.excluded_variants set is a deliberate arm-drop (a product feature), but it still rebuckets that arm's users — frame it as a deliberate change with statistical side effects, not a mystery mutation.SELECT person_id,
count(DISTINCT properties.$feature_flag_response) AS variants_seen,
count(DISTINCT distinct_id) AS distinct_ids
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag = '<flag-key>'
AND properties.$feature_flag_response NOT IN ('$multiple', 'false', '')
AND timestamp >= toDateTime('<start_date>', 'UTC')
GROUP BY person_id
HAVING variants_seen > 1
LIMIT 50
Variant win/loss is the team's call — but a metric that cannot produce an answer is a machinery fault, and the experiment burns calendar time measuring nothing. From experiment-results-get, with healthy exposures:
data: null (its query failed) or validation_failures in all arms (e.g. baseline-mean-is-zero on a funnel whose conversion event never fires in control) — the headline result is unreadable.Both are report-worthy: the team thinks they're collecting evidence and they aren't. A treatment-only conversion event legitimately reads ~zero in control — that's expected, not a fault (the control-arm not-enough-metric-data failure alone doesn't qualify).
A running experiment should accrue exposures continuously. Read the per-variant exposures.timeseries off experiment-results-get (cumulative daily counts — a flat tail is the stall shape), or by SQL. Query the experiment's actual exposure event: default experiments use $feature_flag_called, but if exposure_criteria.exposure_event is set, query that event name instead (filtering on properties.$feature/<flag-key> rather than $feature_flag) — running the default query against a custom-exposure experiment returns zero rows and fakes a stall:
SELECT toDate(timestamp) AS day, count() AS exposures
FROM events
WHERE event = '$feature_flag_called' -- or exposure_criteria.exposure_event
AND properties.$feature_flag = '<flag-key>'
AND timestamp >= toDateTime('<start_date>', 'UTC')
GROUP BY day ORDER BY day
$feature_flag_called (bulk accessors like getAllFlags() don't), the flag is at 0% rollout or inactive, or a custom exposure event is missing its $feature/<flag-key> property. Check experiment-get's flag state before filing a report — a paused experiment (flag deactivated, status "paused") legitimately has no fresh exposures. And before diagnosing a custom-exposure experiment as dormant, confirm with both signals: the custom event by $feature/<flag-key> and $feature_flag_called for the flag — if the flag is being called but the custom event never fires, the break is in the custom event wiring, not the experiment.advanced-activity-logs-list and feature-flags-activity-retrieve around it.feature-flags-activity-retrieve {id: <feature_flag_id>} returns the flag's edit history with diffs. Scan for changes after the experiment's start_date:
rollout_percentage redistribution (e.g. 50/50 → 70/30) — rebuckets users, creates $multiple, biases everything after the edit. Report-worthy.active flips date pause/resume windows — context for stalls, usually deliberate.Also advanced-activity-logs-list {scopes: ["Experiment"], item_ids: [<id>]} for experiment-level edits (exposure criteria swaps, metric changes near a decision point).
Cheap hygiene pass over the full list — P3 recommendations, not anomalies; bundle them into one finding rather than one per experiment:
parameters.recommended_sample_size (often the cleaner test; recommended_running_time can be 0/absent), or > 60 days with a plateaued exposure curve. The data is as good as it will get; recommend deciding. For high-stakes calls, experiment-timeseries-results (needs metric_uuid + fingerprint from the experiment's metrics array) shows whether the primary metric has been stable for weeks — a sustained flat answer strengthens "decide now".end_date set weeks ago, linked flag still active with a multivariate split (no variant shipped to 100%). Users still see random variants of a concluded test; recommend ship-variant or flag cleanup.Write a scratchpad entry whenever you observe something a future run should know. Encode the category in the key prefix — pattern:, noise:, addressed:, dedupe:, report:, reviewer::
pattern:experiments:running-inventory — "Running: new-checkout (id 42, flag new-checkout, 50/50, launched 2026-05-20, ~1.2k exposures/day, default exposure event); pricing-v2 (id 57, 33/33/33, launched 2026-06-01, custom exposure event pricing_page_viewed)."pattern:experiments:new-checkout — "Baseline ~1.2k exposures/day, observed split 50.3/49.7 on 18k exposures at 2026-06-08, $multiple 0.2%. Healthy; recheck ratio only if volume or flag version changes."noise:experiments:pricing-v2-forced-ios — "Flag has a forced-variant release condition (iOS → test) — deliberate per config; per-variant ratio will never match the nominal split. Don't call SRM on the aggregate; compare within the random cohort only."dedupe:experiments:42-srm — "Filed SRM on new-checkout (id 42) 2026-06-09: 56/44 on 22k exposures, started at flag v7 edit 2026-06-05. If still skewed next run, skip; if team reset/relaunched, watch the fresh data instead." One stable key per issue — update it in place, don't mint a dated variant.addressed:experiments:31-zombie — "Recommended ending old-onboarding (id 31, running 140 days) on 2026-05-15; team aware. Don't re-file unless it's still running in 30 days."report:experiments:new-checkout — "Report 019f0a96-… covers the new-checkout (id 42) SRM. Edit it (append_note the fresh numbers) while the skew persists and the report is still live; if it was resolved and the experiment later re-skews, that's a fresh report."reviewer:experiments:new-checkout — "new-checkout owned by alice (GitHub login) — route its reports there."By run #5 you should know every running experiment's expected split, exposure baseline, exposure-event type, and which quirks are deliberate — so a real contradiction stands out immediately and cheaply.
For a candidate that clears the bar, the call is edit an existing report, author a new one, remember, or skip — use judgment, these are the rails:
report:experiments:<slug> scratchpad pointer is the reliable path (it holds the report_id — inbox-reports-retrieve it directly); with no pointer, inbox-reports-list by the specific experiment name and flag key (ordering=-updated_at), not a broad word like experiment (which matches hundreds of unrelated UX reports).scout-edit-report) when a still-live report already covers the same experiment issue — an SRM still skewed, a stall that hasn't recovered, a $multiple trend still climbing. append_note the fresh numbers, or rewrite the title/summary on a report you authored. This is the default when a match exists. edit-report can't change status, so if the matched report is resolved / suppressed / failed, don't append (it won't resurface) — author a fresh report for the relapse and repoint the report: key.scout-emit-report) only when nothing live covers it. A good report names the experiment id and flag key, quantifies the contradiction (observed vs expected split with exposure counts, $multiple percentage, days dormant), passes the sample-size gate, and dates the onset — ideally tied to a flag version or activity-log entry. Set priority (P0–P4) + priority_explanation — validity threats on a live decision (SRM, mid-run mutation, contamination) are P2, stalls P2–P3 by blast radius, lifecycle hygiene P3; it's the report's importance in the inbox, your call to make. Set suggested_reviewers via scout-members-list (objects — a {github_login} or {user_uuid}, not bare strings; cache under reviewer:experiments:<slug>); left empty the report reaches no one. A validity threat is an investigation a human confirms, not a one-line change → actionability=requires_human_input and repository=NO_REPO (NO_REPO is what stops priority+reviewers from spawning a pointless repo-selection sandbox). After authoring, write the report:experiments:<slug> pointer with the report_id so the next run edits instead of duplicating.$multiple creeping at 0.3%, a plateau that needs one more week); skip with a one-line note if a noise: / addressed: / dedupe: entry or an existing report already covers it.Sibling scouts share memory — the feature-flags scout owns non-experiment flag wiring, and the generalist (which ran an experiment-integrity lens before this specialist existed) may hold dedupe:general:experiment-* scratchpad entries; honor them like your own. When a prior run already covered a topic, default to edit-or-skip: the same fact twice in the inbox costs more than missing one finding for one tick.
Summarize the run in one paragraph: which experiments you checked, which reports you authored or edited, what you remembered, and what you ruled out. The harness saves it as the run summary; future runs read it via scout-runs-list. Don't write a separate "run metadata" scratchpad entry. "All running experiments healthy" is a real, useful outcome.
active before calling a stall.filters.groups[].variant set) — deliberate non-random assignment; aggregate ratios won't match the nominal split by design. Note it once in noise: memory.noise: memory.holdout_id before judging a split.$feature_flag_response = false/empty) counted as variants — exclude from ratios; only their share trending up is interesting.When in doubt, write a memory entry instead of filing a report.
Direct calls (read-only):
experiment-list — cheap candidate discovery: id, name, status (draft / running / paused / stopped), dates, feature_flag_key. Filter by status; start here.experiment-results-get — the flagship detector: exposure block (total_exposures, daily timeseries, native sample_ratio_mismatch.p_value, bias_risk.multiple_variant_percentage) plus per-metric validation_failures / data: null. Heavy response with many metrics — read the exposure + validation fields, skip the per-metric stats. New-engine experiments only; pass refresh: false.experiment-get — full config for a candidate: parameters.feature_flag_variants (configured split), parameters.rollout_percentage, recommended_sample_size, parameters.excluded_variants, exposure_criteria (custom exposure_event, multiple_variant_handling, filterTestAccounts), stats_config.method, holdout_id, linked feature_flag (active, version, bucketing_identifier, ensure_experience_continuity, filters.groups[].variant overrides), metrics (each with uuid + fingerprint). Large response — candidates only.experiment-stats — project-wide velocity aggregate (launched / completed last 30d, active count). Cheap context for the hygiene pass.experiment-timeseries-results — day-by-day per-variant results for one metric (metric_uuid + fingerprint from the metrics array). Use sparingly, for the zombie "decide now" check.feature-flag-get-definition / feature-flags-activity-retrieve — flag state and edit-history diffs; the latter is how you date mid-run mutations.advanced-activity-logs-list (scopes: ["Experiment"]) — experiment-level edit timeline.execute-sql against events — exposure analysis. Properties: $feature_flag (flag key) + $feature_flag_response (variant, incl. $multiple) on $feature_flag_called; $feature/<flag-key> on custom exposure events.read-data-schema — confirm a custom exposure event and its properties exist before aggregating over them.Inbox & reviewer routing:
inbox-reports-list / inbox-reports-retrieve — the reports already in the inbox; check before authoring so you edit instead of duplicating (ordering=-updated_at).inbox-report-artefacts-list — a comparable report's artefact log, where the routed suggested_reviewers live (the report record doesn't expose them) — reviewer precedent.scout-members-list — this project's members with their resolved github_login, to route suggested_reviewers to an experiment's owner (wrap as a {github_login} object, or pass the member's {user_uuid} and let the server resolve; null github_login → try the next owner). The in-run roster; the org-scoped resolver tools aren't available in a scout run.Harness-level:
scout-project-profile-get / scout-scratchpad-search / scout-runs-list / scout-runs-retrieve — orientation + dedupe.scout-emit-report / scout-edit-report — author a report / edit an existing one (the report-channel contract is in the harness prompt).scout-scratchpad-remember / scout-scratchpad-forget — remember / prune stale memory keys.not-in-use: entry, close out empty.pattern: baselines if stale.noise: / addressed: / dedupe: entries, or an existing inbox report → edit-or-skip and close out."Looked but found nothing meaningful" is a real outcome.
data-ai
Signals scout for PostHog Tasks, the agent work items a project runs. Two lenses: delivery health (runs failing, clustered by repository and error class, and retry storms) every run, and on a slower rotation demand (recurring asks across human-authored tasks that point at a product gap). Skips the scout fleet's own run rows.
devops
Signals scout for the PostHog Conversations (support inbox) product. Watches the `$conversation_*` ticket-lifecycle events for support-delivery regressions — SLA breach-rate steps, first-response latency blowouts, backlog inflow-vs-resolution imbalance, and channel / assignment concentration — and files each dated regression as a report. Complements the per-ticket product-feedback signals the emission pipeline already fires; does not re-surface individual ticket content.
development
Populates and maintains a project's data catalog (semantic layer): canonical metrics, trust marks (certifications) on warehouse tables/views, and reviewed table relationships. Use when asked to set up / seed / bootstrap the data catalog or semantic layer, to catalog a project's metrics, to certify or deprecate data sources, to propose or review table joins, or to work through the proposal review queue. To *use* an existing catalog to answer a business-number question, see querying-posthog-data instead. Trigger terms: data catalog, semantic layer, canonical metric, certify table, deprecate source, relationship proposal, metric drift, review queue.
tools
Investigate logs in a PostHog project: verify a service or deployment is healthy, explain an error spike, triage an incident, or understand what a log stream is saying. Use when the user asks to "check the logs", asks whether a service, deploy, release, or change is working or broke anything, asks why errors are up or what changed, or wants the root cause of failures visible in logs. Routes the logs MCP tools (services overview, pattern mining, before/after pattern diffing, bucketed counts, facets, raw rows) so investigations start from summaries instead of raw rows or hand-written SQL over the logs table.