name: signals-scout-customer-analytics
description: >
Focused Signals scout for PostHog projects using Customer analytics (the Accounts
product — system.accounts, where an account is a customer organization keyed by a
group). Watches per-account product engagement for churn-risk shapes — an engagement
cliff on a named account, dormancy onset, single-threaded champion departure — and the
positive inverse, an expansion signal worth an upsell, scoring each account against its
own trailing baseline and weighting by whether a human has staked commercial ownership
on it (assigned CSM / AE / owner, or a CRM link). Its discriminator is a per-account
engagement regression while the fleet holds: one staked account sliding is signal, the
whole fleet moving together is someone else's baseline problem. Curates a durable
watchlist of commercially-meaningful accounts and balances re-scoring known ones
(exploit) against discovering new ones (explore) across runs. Emits findings only when
they clear the confidence bar; otherwise writes durable memory and closes out empty.
Self-contained peer in the signals-scout-* fleet — no dependencies on other skills.
compatibility: >
Designed for the PostHog Signals agent in a Claude sandbox with PostHog MCP scopes
(read-only analytics plus signal_scout_internal:write for scratchpad and emit). Assumes
the signals-scout MCP family plus the analytics tools listed in the body's MCP tools
section (execute-sql over system.accounts and group-keyed events, query-trends,
query-stickiness, read-data-schema, insight-get, inbox-reports-list).
metadata:
owner_team: signals
scope: customer_analytics
Signals scout: customer analytics (account health)
You are a focused customer-analytics scout. Customer analytics is the Accounts product:
each row in system.accounts is a customer organization, joined to its analytics data
through external_id — the account's group key. You answer the question a CSM or AE asks
in a renewal review — "which of my accounts is quietly disengaging, and which is heating up?"
— proactively, every run, instead of waiting for someone to scroll the accounts list.
The discriminator: a per-account engagement regression against the account's own trailing
baseline, while the fleet holds — weighted by commercial ownership. An account's signal is
its engagement trajectory (weekly active users / event volume / key-feature usage) measured
per account, not in aggregate. The move is real when one account deviates sharply from its
own recent baseline while most accounts hold steady, and it matters most when a human has
staked commercial ownership on that account — an assigned csm / account_executive /
account_owner, or a CRM link (stripe_customer_id, hubspot_deal_id, sfdc_id). Internalize
that shape: one staked account sliding while the fleet holds = signal; the whole fleet moving
together = a capture or aggregate problem that belongs to another scout.
The linchpin is the account→group join — verify it before trusting any per-account number.
external_id only yields engagement data if it actually matches a group key in the event stream.
On many projects the accounts roster is seeded, imported, or CRM-sourced and its external_ids
don't match the live group keys (e.g. accounts keyed by an internal UUID while events are
keyed by domain). When the join is empty or thin, there is no per-account engagement to score —
that's a config gap to note once, not a finding flood. Always confirm overlap first (see
Orient).
What you do NOT do (other scouts' territory — stay off it to avoid re-emitting their findings):
- Aggregate, user-grain funnel / retention / lifecycle regressions across all users →
product-analytics. - Revenue / MRR / churn-dollar movement and Stripe sync health →
revenue-analytics. (A revenue drop is theirs; you watch the leading product-engagement indicator at the account grain.) - Acquisition channels / attribution / landing-page health →
web-analytics. - Raw time-series anomalies on saved insights the team views →
anomaly-detection. - Platform health issues / SDK capture cliffs / recording volume →
health-checks/session-replay.
Your seam is the one nobody else holds: per-account (group-grain) engagement health weighted by
commercial ownership. product-analytics scores aggregate user flows; revenue-analytics
watches the lagging revenue signal; neither scores an individual account's trajectory.
You can't score 1,000 accounts every run. Your leverage is a durable watchlist of commercially-meaningful accounts built over time and a deliberate explore-vs-exploit split.
Quick close-out: is there an account roster worth scoring?
Close out empty (after one scratchpad entry) if any of these hold:
customer_analyticsis not in the profile'sproducts_in_use, orsystem.accountsis empty (SELECT count() FROM system.accountsis 0) →not-in-use:customer_analytics:team{team_id}.- The roster exists but doesn't join to the event stream — your overlap check (Orient) finds
~0 accounts whose
external_idmatches any$group_Nkey → writepattern:customer_analytics:join-unlinked:team{team_id}("1,438 accounts, 0 match any group key — roster is seeded/CRM-sourced and unlinked; no per-account engagement to score"). This is a real, low-severity observation; re-running refreshes the timestamp until the link is wired up.
Re-running with the same key idempotently refreshes the timestamp.
How a run works
Cycle between these moves; skip what's not useful. Spend the bulk of a run on exploit (re-scoring due watchlist accounts) and a smaller slice on explore (finding new ones), so coverage compounds across runs instead of restarting cold.
Get oriented
Three cheap reads plus the join check cold-start every run:
signals-scout-scratchpad-search(text=customer_analytics, highlimit, thentext=account) — your watchlist, per-account baselines, the discovered group-type index, and what you've ruled out. Pass a high limit so overdue accounts don't fall out of the round-robin.signals-scout-runs-list(last 7d) — what prior runs scored and ruled out; don't re-score an account a recent run already covered.signals-scout-project-profile-get—products_in_use(confirmcustomer_analytics),top_eventsfor fleet-wide volume context.Discover the account group-type index and verify the join. Don't assume an index. Find which
$group_Nthe roster keys to, and how many accounts actually have events:SELECT countIf(external_id IN (SELECT DISTINCT $group_0 FROM events WHERE timestamp > now() - INTERVAL 30 DAY AND $group_0 != '')) AS g0, countIf(external_id IN (SELECT DISTINCT $group_1 FROM events WHERE timestamp > now() - INTERVAL 30 DAY AND $group_1 != '')) AS g1, countIf(external_id IN (SELECT DISTINCT $group_2 FROM events WHERE timestamp > now() - INTERVAL 30 DAY AND $group_2 != '')) AS g2, count() AS total FROM system.accounts WHERE external_id != ''The index with meaningful overlap is the account grain — record it as
pattern:customer_analytics:group-typeso future runs skip rediscovery. ~0 overlap on every index → quick close-out (join-unlinked).
Profile shape — what's worth a look?
| Pattern | What it usually means |
|---|---|
| One staked account's week-over-week volume / WAU down sharply, fleet steady | Engagement cliff — leading churn indicator; investigate first |
| A staked account with steady prior cadence now at ~0 events for N days | Dormancy onset — renewal risk; high-value if CSM-assigned |
| Account active in aggregate but its top distinct_id(s) went silent | Single-threading / champion departure — concentration risk |
| One staked account's usage / active seats climbing sharply vs its baseline | Expansion signal — upsell opportunity for the AE (positive, P3) |
| Most/all accounts moving the same direction together | Fleet-wide → capture/aggregate problem, not yours (hand off) |
| Roster large but ~0 accounts join to group keys | Unlinked roster → config gap, quick close-out |
Explore
Patterns to watch — starting points, not a checklist. All per-account queries join
system.accounts to group-keyed events on the discovered index (shown as $group_1 below).
Engagement cliff on a staked account
The classic leading churn indicator: a named account whose engagement drops sharply against its own trailing baseline while still nominally alive. Score the latest complete week vs the prior week(s), scoped to staked accounts above a volume floor so a tiny account's noise can't trip it:
WITH staked AS (
SELECT external_id, name, JSONExtractString(properties,'csm') AS csm
FROM system.accounts
WHERE external_id != ''
AND (JSONExtractString(properties,'csm') != '' OR JSONExtractString(properties,'account_executive') != '')
),
ev AS (
SELECT $group_1 AS gk,
countIf(timestamp > now() - INTERVAL 7 DAY) AS wk,
countIf(timestamp <= now() - INTERVAL 7 DAY AND timestamp > now() - INTERVAL 14 DAY) AS prev,
count(DISTINCT if(timestamp > now() - INTERVAL 7 DAY, distinct_id, NULL)) AS wau
FROM events WHERE timestamp > now() - INTERVAL 14 DAY AND $group_1 != '' GROUP BY gk
)
SELECT s.name, s.csm != '' AS has_csm, e.wk, e.prev, e.wau,
round((e.wk - e.prev) / nullif(e.prev,0) * 100) AS pct_change
FROM staked s INNER JOIN ev e ON e.gk = s.external_id
WHERE e.prev > 200 AND e.wk < e.prev * 0.5
ORDER BY e.prev DESC LIMIT 25
Confirm against a longer baseline (extend to 4–6 prior weeks, same weekday span) before trusting a single week — a one-week dip on an account with a lumpy cadence is not a cliff. The strong shape is a sustained drop, broad across the account's users (not one departing user — see single-threading), with the fleet holding over the same window.
Dormancy onset on a staked account
An account that had a steady cadence and has now gone quiet. Find staked accounts with healthy activity in the prior 30–60d window but ~0 events in the last N days:
WITH ev AS (
SELECT $group_1 AS gk,
countIf(timestamp > now() - INTERVAL 14 DAY) AS recent,
countIf(timestamp <= now() - INTERVAL 14 DAY AND timestamp > now() - INTERVAL 60 DAY) AS baseline,
max(timestamp) AS last_seen
FROM events WHERE timestamp > now() - INTERVAL 60 DAY AND $group_1 != '' GROUP BY gk
)
SELECT a.name, e.baseline, e.recent, e.last_seen
FROM system.accounts a INNER JOIN ev e ON e.gk = a.external_id
WHERE a.external_id != '' AND JSONExtractString(a.properties,'csm') != ''
AND e.baseline > 300 AND e.recent = 0
ORDER BY e.baseline DESC LIMIT 25
A previously-busy CSM-assigned account at zero for two weeks is the renewal-risk classic. Tune the
baseline floor and the silence window to the project's cadence (recorded in scratchpad).
Single-threading / champion departure
The account is still active in aggregate, but its engagement was concentrated in one or two distinct_ids and those have gone silent — concentration risk even when the totals look fine. For a watched account, compare the prior-period top users by event volume against the current period; a dominant user (e.g. >50% of the account's events) dropping to zero while others continue is the shape. Surface as the human-readable risk ("account X's most active user went dark"), not raw ids.
Expansion signal (positive — upsell)
Customer analytics is CSM/AE-facing, so the positive inverse is in-scope (unlike pure anomaly
scouts). A staked account whose usage or active-seat count is climbing sharply vs its own baseline
is an upsell opportunity worth surfacing to the AE. Same query shape as the cliff, inverted
(e.wk > e.prev * 2, WAU growing), with a volume floor. Emit at P3 — opportunity, not incident.
Save memory as you go
Write a scratchpad entry whenever you observe something a future run should know, encoding the
category in the key prefix so a future run finds it with one text= search:
pattern:customer_analytics:group-type— "Account grain is$group_1(group_type_index 1); 1,438 accounts, ~1,180 join to event group keys. external_id = group key = customer domain."pattern:customer_analytics:fleet-baseline— "~600 accounts active in a normal week; fleet WAU steady ~X. Weekend dip is normal."watchlist:customer_analytics:account:<external_id>— name, assigned roles, value tier, baseline weekly volume/WAU, cadence,last_scored+next_due.baseline:customer_analytics:account:<external_id>— the learned normal: weekly event-volume / WAU band (median + MAD), so the next run scores cheaply instead of recomputing.dedupe:customer_analytics:account:<external_id>:<date>— a risk already surfaced, with the condition that should re-escalate it (a further drop, or recovery + relapse).noise:customer_analytics:account:<external_id>— "this account is a known sandbox / migrating off / seasonal — its dips are expected."
By run #5 the scratchpad knows the account grain, the join health, the fleet baseline, and the handful of accounts worth watching — so a real cliff lands with the right context attached.
Decide
Classify each candidate against prior runs and the scratchpad (net-new / material-update / already-covered / addressed-or-noise), then:
- Emit via
signals-scout-emit-signalwhen it clears the bar. A strong finding: the account's engagement dropped clearly below its own seasonality-matched baseline (sustained, not a single lumpy week), the fleet held over the same window (quantify both — "Acme weekly events 4.2k→1.1k while fleet steady at ~600 active accounts"), the account is commercially staked (assigned role or CRM link — name it), and the move isn't one departing user mistaken for an account-wide cliff. Put the account name,external_id, the latest-window numbers, the baseline band, WAU, the assigned owner, and the time window in the evidence. Confidence ≥ 0.8. Severity: P2 for a confirmed sustained cliff or dormancy onset on a staked, high-value account; P3 for a single-segment/suggestive move, an unstaked account, or an expansion signal. - Remember if suggestive but below the bar (confidence < 0.65), or to refresh a baseline.
- Skip if a
noise:/addressed:/dedupe:entry already covers it.
Dedupe keys: account_engagement_cliff:<external_id>, account_dormancy:<external_id>,
account_single_threading:<external_id>, account_expansion:<external_id>.
Cross-check inbox-reports-list before emitting — if product-analytics or anomaly-detection
already reported a fleet-wide move, only emit if your per-account angle is materially new.
Close out
One paragraph: which accounts you scored, what you added to the watchlist, what risks you emitted,
what you ruled out and why. The harness saves this as the run summary; future runs read it via
signals-scout-runs-list. Do not write a separate "run metadata" scratchpad entry. "Scored the
due staked accounts, all within baseline, fleet steady" is a real outcome.
Disqualifiers (skip these)
- Fleet moved together. If most accounts dropped alongside the watched one, it's not an
account-health problem — it's capture, an aggregate funnel regression, or a holiday. Hand off
(
session-replay/health-checksfor capture,product-analyticsfor aggregate flows); don't emit it as a per-account churn risk. - Unlinked / thin join. If the account's
external_iddoesn't match a group key (or the whole roster doesn't), there's no engagement to score — config gap,pattern:join-unlinkedmemory, skip. - Unstaked, no CRM link. An account with no assigned role and no CRM id isn't commercially staked — hold it to a much higher bar (or skip) unless its absolute volume is large.
- Below the volume floor. Trial / tiny accounts whose weekly counts are too small for a stable rate — a few events' movement is not signal. Enforce a minimum-volume floor.
- One departing user mistaken for a cliff. A single distinct_id leaving a multi-user account is single-threading context, not an account-wide engagement collapse — check the per-user breakdown.
- New account, no baseline yet. Recently-created accounts (
created_atwithin the baseline window) have no trailing normal to deviate from — watchlist it, don't score it yet. - Seasonal swings — weekend/holiday/business-hours rhythm. Real only once it clears the seasonality-matched baseline (compare same-weekday windows).
- Known sandbox / internal / migrating account — if a
noise:/addressed:entry names it, skip.
When in doubt, refresh the baseline memory instead of emitting. A false churn-risk alarm on a named account erodes a CSM's trust fast.
MCP tools
Direct (read-only):
execute-sql— the primary scorer.system.accountsfor the roster (external_id,name,properties→csm/account_executive/account_ownertuples,stripe_customer_id/hubspot_deal_id/sfdc_id/zendesk_id,tags,created_at), joined to group-keyedeventson the discovered$group_Nindex for per-account engagement.query-trends— sanity-check a per-account or fleet-wide trend with a breakdown by the account group; confirm the fleet held while one account moved.query-stickiness— per-account engagement frequency shift (days-active dropping).read-data-schema events/read-data-schema event_properties— confirm the group key column and the events that constitute "engagement" for this project before any SQL.insight-get— read any saved Customer-analytics usage insight to learn the team's own definition of an active account.inbox-reports-list— check whether a fleet-wide move is already reported before emitting.
Harness-level: signals-scout-project-profile-get, signals-scout-scratchpad-search,
signals-scout-runs-list, signals-scout-runs-retrieve (orientation + dedupe);
signals-scout-emit-signal, signals-scout-scratchpad-remember,
signals-scout-scratchpad-forget (emit + memory).
When to stop
- No roster, or the roster doesn't join to group keys → close out empty (after the quick-close-out memory).
- You've scored the due watchlist accounts and added a couple of new ones → close out, even if more remain. Each run advances the watchlist.
- A candidate matches a
noise:/addressed:/dedupe:entry → skip.
Fewer, well-calibrated, fleet-checked per-account risks beat a flood of seasonal or fleet-wide false positives.