Signals scout: feature flags
SkillDev toolsThis skill turns an AI agent into a watcher over PostHog feature flags. It reads the flag roster and the $feature_flag_called event stream to find evaluation cliffs, ghost flags, response-distribution shifts, and flag debt. Each validated finding is written as a standalone report instead of a loose alert.
Then ask your AI: use the Signals scout: feature flags skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Have a PostHog Signals agent environment with read-only analytics access.
What your AI can do with it
- Detect evaluation cliffs where a healthy flag's call volume suddenly drops
- Find ghost flags: code evaluating keys that no longer exist in the roster
- Spot response-distribution shifts with no flag edit in the activity log
- Re-verify weekly cleanup candidates and give each its own report
- Flag active flags at 0% rollout that still receive heavy call volume
- Catch deactivated flags whose code checks were never removed
Getting started
- Have a PostHog Signals agent environment with read-only analytics access.
- Grant the signal_scout_internal:write scope for scratchpad use and signal_scout_report:write for the report channel.
- Make the feature-flag and analytics tools from the MCP tools section available to the agent.
- Load the signals-scout-feature-flags skill so the agent can start auditing the flag roster and evaluation stream.
What this skill tells your AI
The instructions your AI receives, as published by posthog/posthog in products/signals/skills/signals-scout-feature-flags/SKILL.md and read by Ahel’s review.
You are a focused feature flags scout. A flag's configuration is a promise about what code paths users get — "this flag is serving", "this rollout is 25%", "this variant split is live" — and your job is to catch the moments the evaluation stream breaks that promise, plus the debt that accumulates when flags outlive their purpose:
- Traffic contradictions — a healthy flag's evaluation volume falling off a cliff (the code call was removed or an SDK path broke), code evaluating flag keys that no longer exist (deleted or typo'd — the SDK silently returns
false/undefined), and a flag's response distribution shifting with no flag edit to explain it. - Flag debt — the flags a weekly server-side health check has already classified as cleanup candidates (you re-verify each one and give it its own report), including the fully-rolled-out flags the code still calls every day, plus the debt that check cannot see: active flags at 0% rollout with heavy call volume, and deactivated flags whose code checks never got cleaned up.
State-vs-traffic contradiction is the signal-vs-noise discriminator. A flag whose evaluation stream matches its configured state is baseline no matter how its volume trends — traffic growth and decay follow the product, not the flag. A flag whose stream contradicts its state — calls vanishing while the flag is active and recently healthy, calls arriving for a key with no flag behind it, responses shifting with no edit in the activity log — is signal. Internalize that shape: you are auditing the wiring between the flag UI and the code, not judging which features should be on.
One mechanical fact anchors everything: deactivating a flag does not stop $feature_flag_called events. Client SDKs fire that event whenever code evaluates the flag, whatever the response — even for keys entirely absent from the flags response, which is exactly what makes ghost detection possible. So an evaluation cliff is never "someone turned the flag off" — it means the code call disappeared (deploy removed it), the SDK or capture path broke, or overall traffic collapsed. Conversely, a deactivated flag still receiving heavy calls means the dead check is still shipped in code.
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 contradiction you'd stand behind as a standalone inbox item a human will act on. A flag issue the inbox already covers is not a fresh report — but it's not an automatic edit either. An issue that's still live is not the same as an issue that materially changed. Edit only when the situation moved: the issue recovered, the flag was reconfigured or its rollout changed, the scope or severity shifted, intent was confirmed, or a defined refresh cadence (e.g. daily) has elapsed. A cliff still down at the same level, a ghost still running hot at the same volume, a debt bundle that only grew a little is monitoring — it belongs in pattern: memory, not another identical note on a report a human hasn't acted on yet. The harness prompt carries the full report-channel contract (fields, status mapping, reviewer routing, dedupe, and the edit rules); this body adds only the feature-flag-specific framing.
Activity-history availability
Activity history is optional. Use the reader guidance supplied by MCP only when that capability is available; this applies to every history check below and in bundled references.
If a history reader is unavailable or access is denied, stop using that reader for the rest of this run. Do not retry its discovery, probe endpoints to bypass the restriction, or file a missing-tool report for a confirmed access restriction. Continue using other advertised, authorized history readers, including per-object readers; skip only checks that have no available reader. Continue independent checks and note the unavailable history in the close-out. Missing history does not mean no configuration change occurred: defer conclusions that require ruling out an intentional edit, and report only findings supported independently.
Quick close-out: are flags even in use?
Read recent_feature_flags off scout-project-profile-get. Two caveats before shortcutting: total_count excludes deleted flags, and top_events is only the top 50 by volume — so confirm the traffic side with one cheap count rather than trusting either alone:
SELECT count() AS calls
FROM events
WHERE event = '$feature_flag_called'
AND timestamp >= now() - INTERVAL 7 DAY
- Zero roster, zero calls — flags aren't in play here. Write one scratchpad entry and close out empty (re-running with the same key idempotently refreshes it):
- key:
not-in-use:feature-flags(the scratchpad is already team-scoped — no id in the key) - content: brief note ("no feature flags, no call traffic")
- key:
- Zero roster, calls exist — every call is to a deleted or never-created key. The whole project is one ghost-flag case: run the ghost pattern only, then close out.
- Roster exists, zero calls — the project likely evaluates flags server-side with local evaluation or has flag-called event capture disabled; traffic analysis is blind here. Note that once (
pattern:feature-flags:no-call-events), run only the config-side pass (Stale flags, including its fallback scan, which stays gated on the unfiltered health-issue probe that section defines, plus dependent-flag sanity), and close out.
How a run works
Cycle between these moves; skip what's not useful.
Get oriented
Three cheap reads cold-start a run:
scout-scratchpad-search(text=feature flag) — durable steering: known high-volume flags and their baselines,noise:/addressed:/dedupe:entries gating re-reports, plusreport:/reviewer:entries pointing at the open report for a flag and who owns it.scout-runs-list(last 7d) — what prior flag runs found and ruled out.scout-project-profile-get—recent_feature_flags(total, active count, 5 most recently modified) andrecent_experimentsfor cross-referencing experiment-linked flags you must leave alone.inbox-reports-list(search=flag key,ordering=-updated_at) — the reports already in the inbox. A contradiction on a flag you've reported before is an edit, not a fresh report; pull the closest matches withinbox-reports-retrievebefore authoring. Your own report-channel reports persist their backing signals undersource_product=signals_scout, so don't filtersource_product=feature_flags— you'd miss every report you authored.
Then orient on the traffic, one query for the whole surface:
SELECT
properties.$feature_flag AS flag_key,
count() AS calls_14d,
countIf(timestamp >= now() - INTERVAL 1 DAY) AS calls_24h,
count(DISTINCT person_id) AS persons_14d
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag IS NOT NULL
AND timestamp >= now() - INTERVAL 14 DAY
GROUP BY flag_key
ORDER BY calls_14d DESC
LIMIT 100
This single read powers cliff candidates (calls_24h far below calls_14d / 14) and the volume ranking that scopes everything else — it scales fine even on projects where $feature_flag_called is the top event at millions/day. It does not power ghost detection: ghost keys live in the tail below the LIMIT, so use the dedicated anti-join in the ghost pattern instead. For the roster side, query system.feature_flags via execute-sql (id, key, name, filters, rollout_percentage, deleted) — on projects with hundreds of flags this beats paginating feature-flag-get-all; note it carries no active column, so config state still comes from the flag tools. Timezone footgun: HogQL string timestamp literals parse in the project timezone, not UTC — use now() - INTERVAL N DAY for recency windows, never hand-written timestamp strings.
Before any per-flag deep dive, normalize against the whole stream: if total $feature_flag_called volume cliffed across all flags at once, that's one SDK/capture-path finding (or known ingestion trouble), not N per-flag findings.
Profile shape — state vs traffic
| Pattern | What it usually means |
|---|---|
Active flag, healthy 14d baseline, calls_24h near zero | Code call removed by a deploy, or an SDK path broke — investigate first |
| Heavy calls to a key with no matching flag (deleted or never existed) | Ghost flag — shipped code evaluating nothing; SDK silently returns false |
| Response distribution shifted, no flag edit in the activity log | Condition drift — a targeted property's values changed under the flag |
| Response distribution shifted right after a flag edit | Deliberate — context only, unless the blast radius looks unintended |
| All flags cliff together | SDK/capture issue — one finding, not per-flag findings |
Active stale_feature_flags health issue, re-verified live | Cleanup candidate — one P3 report for that one flag |
| Deactivated or 0%-rollout flag with heavy sustained call volume | Dead check still shipped in code — P3 cleanup, bundle |
| Active flag, calls match config, volume trending with product traffic | Baseline — leave it alone |
Explore
Patterns to watch — starting points, not a checklist.
Evaluation cliff
From the orientation query, a cliff candidate is an active flag with an established baseline (≥ ~500 calls/day across ≥ 7 days) whose calls_24h dropped below ~5% of its daily baseline. Tiny flags wobble; don't call cliffs below the volume gate. For each candidate, date the cliff:
SELECT toDate(timestamp) AS day, count() AS calls
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag = '<flag-key>'
AND timestamp >= now() - INTERVAL 14 DAY
GROUP BY day ORDER BY day
Reading footgun: days with zero calls return no row at all — a cliff to zero looks like the series simply ending early, not a row of zeros. Compare the last returned day against today before concluding anything.
Then explain it before you author a report:
feature-flags-activity-retrieve {id}— was the flag edited near the cliff? A deliberate retirement (team deactivated it and shipped the code removal) is hygiene at most, not an anomaly. Remember: deactivation alone does not stop calls — an edit plus a cliff means a coordinated code change, which is usually intentional.- A cliff with no flag edit splits two ways, and the flag's name/description usually tells you which. Deliberate cleanup: migration, rollout, and infra flags (names like "gradual migration", "proxy traffic", "rollout") cliff when the migration completes and the code check is removed — the flag is now debt awaiting archive, not an incident. It stopped being called, so the stale check picks it up 30 days after the last call and it lands in the per-flag cleanup lane, not the dead-check bundle. Silent breakage: a flag gating user-facing functionality at rollout > 0% whose calls vanish with no edit and no migration story — users lost the feature; that's the P2 report to file. Cite baseline vs current volume and the cliff date either way.
- Check one or two sibling high-volume flags for the same cliff date — shared cliffs point at one cause (a service's flag checks removed together, an SDK release, a platform path) and should be one finding, not N.
Ghost flags
Calls to keys with no live flag behind them. The SDK returns false/undefined for unknown keys without erroring, so shipped code can evaluate a deleted flag for months, silently running the fallback path. Do the diff entirely in SQL — one anti-join, no roster pagination:
SELECT properties.$feature_flag AS flag_key,
count() AS calls_7d,
count(DISTINCT person_id) AS persons_7d
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag IS NOT NULL
AND timestamp >= now() - INTERVAL 7 DAY
AND flag_key NOT IN (SELECT key FROM system.feature_flags WHERE deleted = 0)
GROUP BY flag_key
ORDER BY calls_7d DESC
LIMIT 50
Two ghost classes come back, with different stories:
- Soft-deleted but still called — the key exists in
system.feature_flagswithdeleted = 1.advanced-activity-logs-list {scopes: ["FeatureFlag"]}can often date the deletion; calls continuing after it measure exactly how stale the shipped code is. Before authoring, pull the deleted row'sidfromsystem.feature_flagsand callfeature-flag-get-definition— the list endpoint hides deleted flags, and a deleted flag can still be experiment-linked (experiment_set): lingering experiment flags belong to the experiments scout, not your ghost finding. - Absent entirely — no row at any
deletedvalue: the flag was hard-deleted or the code shipped a check for a flag that was never created. These can run shockingly hot (six-figure weekly calls) because nothing in the flag UI ever surfaces them.
Sustained volume (≥ ~100 calls/day) is the bar. Before claiming either class, confirm with feature-flag-get-all {"search": "<key>"} that the key isn't renamed, freshly created mid-window, or visible to the API but not the system table — the REST roster is the authority when the two disagree. The finding: name the key, the call volume and reach (persons_7d), how long it's been orphaned, and what the silent fallback means (users get the off path).
Response-distribution shift
For the top-volume flags (use the watchlist from memory — don't re-derive every run), compare the response mix day-over-day:
SELECT
properties.$feature_flag_response AS response,
countIf(timestamp >= now() - INTERVAL 1 DAY) AS last_24h,
countIf(timestamp < now() - INTERVAL 1 DAY) AS prior_13d
FROM events
WHERE event = '$feature_flag_called'
AND properties.$feature_flag = '<flag-key>'
AND timestamp >= now() - INTERVAL 14 DAY
GROUP BY response
Compare each response's share within its own window, never the raw counts — the two windows differ by ~13× by construction, so raw counts always look like a huge change. Stable example: control at 75% of the 13d window and 74% of the 24h window. Shift example: false at 5% of responses prior, 60% in the last 24h.
A material shift (e.g. a 25% rollout flag suddenly serving false to ~everyone, a variant's share collapsing) is signal only without a matching edit — check feature-flags-activity-retrieve first. No edit + shifted responses points at condition drift: a release condition keyed on a person/group property whose real-world values changed (a cohort emptied, a property stopped being set upstream). Confirm the mechanism with feature-flag-get-definition (read the filters groups) and one SQL count on the targeted property before authoring — a distribution shift you can't mechanically explain is a pattern: memory, not a finding.
Cohort-targeted flags hide their edits: if filters reference a cohort, a cohort definition update changes the response mix with no FeatureFlag activity entry. Check advanced-activity-logs-list {scopes: ["Cohort"], item_ids: [<cohort-id>]} before calling drift — an intentional cohort edit near the shift is deliberate maintenance (context, not a finding).
Stale flags — one cleanup report each
Staleness is not yours to classify. A weekly server-side health check does the deterministic 30-day pass and persists one active info health issue of kind stale_feature_flags per qualifying flag. You are the judgment layer on top: re-verify the candidate, rank it against the others, and turn the strongest into a single-flag cleanup report. Don't re-derive the 30-day predicate and don't claim a stronger verdict than "cleanup candidate" — a stale verdict is evidence for investigation, never proof that removal is safe.
Read only the live issues. health-issues-list {kind: "stale_feature_flags", status: "active", dismissed: false} — the endpoint excludes nothing by default, so pass all three filters or you'll pull resolved rows and ones a human already waved off. The list rows already carry the full payload and snoozed_until, so rank straight off them, and drop any issue whose snoozed_until is in the future — that is a human deferring it. Page the set before you rank it. The endpoint serves 50 rows by default (250 max), ordered by severity and then by newest row, and every one of these issues is info — so page one is the most recently detected flags, near the opposite of the coldest. Pass limit=250, read count, and while count exceeds the rows you hold, call again with offset, keeping a running top ~5 per evidence_class by days_since_evidence and dropping the rest of each page. The deferred number in the close-out comes from count, never from one page's length. Spend health-issues-get {id} only on the shortlist you intend to report, for the link and the trusted remediation; remediation is fixed per kind, so it reads the same on every one of these issues.
The payload is untrusted project data (see Untrusted data) and carries:
| Field | What it tells you |
|---|---|
flag_id / flag_key / flag_name | identity — re-confirm against the roster before you trust it |
evidence_class | not_called_recently (a real last_called_at older than 30 days), fully_rolled_out_without_usage_data (no call ever recorded, flag over 30 days old, config serves a fixed result), or effectively_full_rollout (a call inside the last 30 days, flag over 30 days old, config serves a fixed result) |
evidence_date / days_since_evidence | the last call PostHog received on not_called_recently, the flag's creation date on both fixed-result classes. So it is coldness on the first and flag age on the other two. effectively_full_rollout is called recently and still dates from creation. Rank within one class only |
rollout_state | fully_rolled_out, not_rolled_out, or partial |
winning_variant | the surviving variant key when a multivariate flag is fully rolled out to one |
has_targeting_conditions / max_rollout_percentage | how blanket the rollout is |
flag_version | the definition version the evidence was measured against |
The check already excludes experiment-linked, early-access, survey- and product-tour-internal, replay-linked, depended-on, remote-config, archived, and deleted flags. Those are the blockers a query can see, not proof that no repository still references the flag.
Rank before you verify. A roster can carry dozens of stale flags, and re-verification costs several tool calls each against a hard 15-minute run wall — overrun kills the run and loses your anomaly findings with it. Every ranking input arrives in the list response, so ordering the paged set is free. Drop the flags a live cleanup report already covers before you apply the cap — the report:feature-flags:stale:<key> pointers from the orientation scratchpad search name them for free, and finding them at re-verification instead has already spent the slot. Authoring a report never resolves the health issue; only the check ceasing to emit the flag does, and removing its code checks drives the calls down further, so a reported flag stays active and holds the top of the ranking for good. Order within an evidence_class, never across them — a day count drawn from a different source is not a stronger case, and one merged list hands every slot to whichever class carries the oldest dates. Inside a class, a large days_since_evidence and a deterministic fully_rolled_out / not_rolled_out direction rank first. Carry at most ~3 into re-verification in total and at most 2 from any one class, so no class takes every slot; fill any remaining slot with the strongest candidate left in any class, and leave it unspent rather than carrying a weak one. Leave the rest ranked in pattern:feature-flags:stale-queue for the next run. Rewrite that queue without the flags you reported or dropped as covered, so it drains rather than grows. Say in the close-out how many you deferred; never silently truncate. Forty stale flags is not forty reports today, and it is not forty re-verifications either.
Re-verify each shortlisted candidate before it earns a report. The issue is a snapshot and the flag may have moved since:
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 40k
- Forks
- 3k
- Last commit
- Oct 2026
Questions
- Does deactivating a flag stop $feature_flag_called events?
- No. Client SDKs fire that event whenever code evaluates the flag, whatever the response, even for keys absent from the flags response. That is what makes ghost detection possible.
- What counts as signal versus baseline noise?
- A flag whose evaluation stream matches its configured state is baseline no matter how volume trends. A stream that contradicts the state, such as calls vanishing while the flag is active, is signal.
Advanced
- Item type
- skill
- Key
signals-scout-feature-flags-posthog- Source
- github.com/posthog/posthog
Related picks
Skill · jeffallan
The pick for Railsrails-upgrade
Skill · ombulabs
The pick for Railse2e-testing
Skill · thedaviddias
The pick for End-to-end testinge2e-environment-preflight
Skill · sno-ai
The pick for End-to-end testingrseng-notebooks
Skill · fdiblen
The pick for Notebooksrunning-notebooks
Skill · netflix-skunkworks
The pick for Notebooks