PostHog customer deep dive
SkillCommunicationUse before emailing a PostHog customer, replying to one, or joining a call, and for any request to research a PostHog account. Fires on "/posthog-customer-deep-dive" and on natural language like "deep dive on X", "look up this customer", "help me reply to Y", "prep me for my call with Z", from an email address, domain, account name, or Vitally account id. Researches the account across Vitally and project 2 usage queries, then drafts an email (first touch, follow-up, or reply) or a call-prep brief, every recommendation carrying a live docs link.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the PostHog customer deep dive skill
What this skill tells your AI
The instructions your AI receives, as published by posthog/skills in skills/team/onboarding/posthog-customer-deep-dive/SKILL.md and read by ahel’s review.
Research an account, then produce an email (first touch, follow-up, or reply), a call-prep brief with its notebook prompt, or for any other ask whatever shape the ask needs on the same research. Output goes to chat. Customer and account systems are read-only: never send, never post, never write to Vitally or PostHog. The only writes are local: config.md during Setup, and a per-run scratchpad (deep-dive-<account>-<HHMM> under the session scratchpad, or /tmp) holding the docs cache, the context file and the gatherer digests. The deliverable is generated once, into chat, and never also written to a file.
Input: $ARGUMENTS, usually an email address; also a domain, an account name, or a Vitally account id (either UUID kind).
Verify every claim before writing it. Every number comes from a query you ran this run; every product fact and link from a docs search you ran this run. Show a derived figure's arithmetic inline (1.72M polls/day / 2,880 per instance = ~600 instances) so a slipped digit is visible. Nothing from memory, nothing inferred from a number you did not pull. Where a claim cannot be verified, write the question instead: an unanswered question costs a follow-up, a confident wrong fact costs the relationship. Every table in this skill tells you what to check; none is a citation.
A claim about the customer's code or SDK config must quote that code from the site scan's fetched page or bundle. Data shows the effect; only code shows the cause, so an identity or billing mechanism is never named from event and billing data alone. Events the SDK emits ($identify, $create_alias, an explicit $set event) are assertable from those events; init options and config values need the quoted code.
Read config.md first: every per-user value and tool binding. Then read config.local.md if it exists and let any key it names win; it holds this machine's personal values and git ignores it, so it is missing on most installs and that is normal. A required value still reading <SET THIS> after the overlay means ask before the step that needs it, or run Setup. A source set to none is skipped silently; that is configuration, not a skip, and a run with every optional source at none is complete.
| Reference | Read when |
|---|---|
config.md, then config.local.md if present | Before Step 1, every run |
references/agent-briefs.md | First, before Round 1a. It carries main's own reading-map row, so it decides what else main opens, plus the conventions block, the per-role map, the context file spec and the return rules |
references/data-rules.md | Steps 1 and 2, by main, only the sections main's map row names. Gatherers never open it; they get its conventions inline |
references/queries-account.md | Steps 1 and 2, by main (Round 1 slugs) and by the gatherers assigned to it |
references/queries-products.md, references/queries-money.md | Step 2, by the gatherer whose map row names the section. Each file's headings are its index |
references/site-scan.md | Round 1a, batch 1 for the domain and batch 2 for each further team (Round 2 on a public-provider admin email) |
references/levers.md | Before drafting any recommendation, and by docs-prewarm to pick pages |
references/voice.md | Before drafting anything a customer will see |
references/mode-email.md, references/mode-call-prep.md | Step 5, the one file for the detected artifact. Any other ask runs the same research and takes the shape the ask needs |
Batch main's reference reads into one block. Opening data-rules.md whole is the largest read in the critical path and most of it belongs to gatherers, who get it inline.
Three bundled scripts. Two of them are a floor and never a ceiling: run them, then keep going wherever the account points somewhere they do not reach, and name what you ran beyond them. A run that stops exactly where the scripts stop answered the script's questions instead of the customer's.
scripts/phq.pysends any HogQL query over the HTTP API on four targets (usproject 2,euEU project 1,ch-us/ch-euthe direct ClickHouse connection).--batch <file.jsonl>fires many at once, one JSON object per line with the keysname,target,sqland optionalconnection; any other key set is aKeyErrorbefore the first query runs. The MCP stays primary; this is the fallback when the gateway is down and the only path for EU.scripts/site-scan.sh <domain>runs the common shape of the site scan.scripts/version-check.shtakes no arguments and is the third: run it once alongside theconfig.mdread, and relay its output verbatim if it prints anything. It compares this copy's.claude-plugin/plugin.jsonversion against the published one, checks at most once a day, and stays silent when current, offline, or ahead ofmain. Silence is the normal result and needs no mention.
Every other HTTP call, Vitally REST included: build the JSON in Python inside a single-quoted heredoc (python3 << 'PYEOF'), write it to a file, send with curl --data @file. Bash expands $1, $group_0 inside inline strings and this skill is full of $-prefixed names; Python's urllib hits CERTIFICATE_VERIFY_FAILED, so curl sends and Python only builds and parses.
Setup (first run, or when the config is not yours)
Probe before asking, confirm before writing. In one pass: check the tool list for the PostHog exec gateway and the Vitally MCP; check the env for POSTHOG_PERSONAL_API_KEY, POSTHOG_PERSONAL_API_KEY_EU, VITALLY_API_KEY; check the tool list for each optional category in config.md, recording exact tool names; detect the timezone. Show one message with what was found, what is missing and the defaults you keep, ask only what cannot be probed (calendar id, booking link), then write: personal values (work calendar ID, timezone, meeting notes folder) go to config.local.md, creating it if it does not exist, and everything else to config.md. Never write a personal path or address into config.md; it is the shared file and it ships as-is. A missing optional tool is none; a missing required tool stops the run.
When something is missing, hand the person the fix, not a diagnosis. README.md carries the whole install, one numbered section per tool, each ending in a check that proves it worked, and it is never loaded at runtime, so read the section for whatever is missing and give them the commands from it inline: the shell and key setup (section 0), the MCP registration, the op item get command for a shared credential, or the mint URL for a personal one. Say which credentials are shared and which are theirs, since reusing someone else's PostHog personal key is the common mistake. Then re-probe and confirm before running. A first run that ends in "the Vitally MCP is missing" and nothing else is how a shared skill loses the person who was trying it.
Step 1. Resolve to a Vitally account
| Input | Path |
|---|---|
mcp__vitally__get_user_details. Take accounts[0].id and accounts[0].externalId (the PostHog org id) | |
| Domain | the sibling-sweep queries. The MCP search_users ignores limit and returns hundreds of KB; avoid it |
| Account name | billing_customer WHERE name ILIKE '%...%'. Self-serve signups share company names: prefer the row with non-null crm_segments / plans_map. The Vitally name tools are unreliable (search_accounts_advanced returns 0 for exact names, search_accounts ignores showAllAccounts, find_account_by_name is filtered to the caller's CSM) |
| Account ID | use directly |
If all fail, ask for the account name. An id handed in can be either UUID kind: the Vitally MCP resolves both, the warehouse matches org ids on vitally.accounts.external_id only.
One customer carries a different name in each system, so check what the name you were handed refers to. Vitally's account name, stripe.name (often a legal entity), zendesk.name, sfdc.Website and the per-project names in resolve-teams are five independent fields that sometimes disagree, and the name you were given can turn out to be a near-empty second project rather than the account. State in the header which name maps to what whenever they diverge, and report on the project carrying the volume.
get_user_details hits two limits. It can filter on large accounts (stripping custom traits, printing "Removed N trait fields"; a small account can come back complete, so the warehouse traits read is the authority either way) and can overflow on a several-hundred-user account. Split the read by source; custom traits carry a vitally.custom. prefix.
| Read from | Fields |
|---|---|
vitally.accounts.traits in the warehouse (WHERE id = '<VITALLY_ACCOUNT_ID>'), which get_user_details filters out | onboardingPipeline, onboardingMinimumEligibility, onboardingUsageOutreachSentDate, onboarding_invoice_count, usEuInstance, csmId, accountExecutiveId |
get_user_details | healthScore, nextRenewalDate, contractRenewalDate, usersCount, usage_mrr, forecasted_mrr, forecasted_usage_mrr, diff_dollars, paidProducts + payingFor<Product> + <product>_forecasted_mrr, replayCountLast30DaysIfSendingData, group_types_total, active_hog_destinations, active_batch_exports, firstSeenTimestamp, roleAtOrganization |
Always run the domain sibling sweep, both halves. A sibling org is a duplicate paying twice, a consolidation question, or an account a teammate owns. The two queries see different populations (vitally.users and billing_customer) and neither alone is complete; skipping one is how a duplicate-billing sibling stays hidden. Flag any sibling in the header: one org with multiple projects usually beats parallel orgs.
Then probe usage across every org the sweep returned and build the context on the one carrying the volume. get_user_details returns accounts[0], whichever account Vitally lists first, and on a multi-org customer that is routinely not the live one, so every downstream figure would describe a dead org. This gates the Round 2 launch: no gatherer starts until it has resolved, because a gatherer given the wrong org id does perfect work on the wrong company.
SELECT organization_id, count() AS days, sum(event_count_in_period) AS events,
sum(recording_count_in_period) AS recordings, sum(mobile_recording_count_in_period) AS mobile_recordings,
sum(billable_feature_flag_requests_count_in_period) AS flag_requests
FROM billing_usage_by_org_date
WHERE organization_id IN (<every org id the sweep returned>) AND date >= today() - 30
GROUP BY organization_id ORDER BY events DESC LIMIT 20
An org missing from the result has no Cloud usage in the window, which is an answer. Where the resolved org and the live org differ, say so in the header, report on the live one, and treat the resolved one as a sibling finding: a paid subscription on a dead org is money leaving for nothing.
Step 2. Parallel pull
Put the PostHog MCP on project 2 (check its active-environment block; switch-project to 2 if not). It often defaults to a dev project where every query returns zero rows.
Detect the region from vitally.custom.usEuInstance (an array, e.g. ["US"]), populated far more widely than cloudRegion; fall back to cloudRegion, then traits.site_url, where eu.posthog.com means EU. Project 2 answers almost everything for both regions; the exceptions are EU experiment definitions and the EU direct connection, both on the EU key and EU project 1.
How to fan out
Fan the gathering out, reconcile in one head, then fan the verification out separately. The two fan-outs are not interchangeable: gathering happens before there are claims, verification after.
A round costs what its deepest agent costs, and an agent costs its longest chain of dependent calls. So batch first (every read depending only on the context goes out in one block; on the HTTP path one phq.py --batch), widen second (one grouped query per org and region rather than one per team, per data-rules.md), split last and only along a true dependency. No brief carries a serial chain longer than 8 to 10 reads: count the chain, not the calls.
- Budget in tool calls, never seconds. Cap each gatherer at 12 to 15, and split the brief BEFORE launching if the list runs past 15. Nobody can estimate an agent's seconds; everyone can count reads in a brief.
- The budget caps shape, never scope. Never drop, defer or narrow a read to fit it. The move when a brief is too big is to split it, and a read that still will not fit runs over budget and is named in the closing note.
- Split by call count; never merge briefs by topic. Concurrent agents are near-free in wall clock; calls inside one agent serialize into one chain. Merging trades a cheap resource for an expensive one, so the instinct to reduce agent count is backwards.
- 8 to 10 concurrent agents, each batching 4 wide. Past that ClickHouse returns 202 and the HTTP path returns 429, and the forced serial retries are slower than not splitting. The cap is machine-wide, so expect an occasional refusal and retry rather than shipping without the refused brief. The binding limit is concurrent QUERIES, not agents: 8 agents batching 4 wide is already ~32 in flight, which is why the agent count looks low. A wave of short, query-light roles can run wider than a wave of heavy ones.
- Splitting stops paying below about 12 calls, because every agent carries a fixed startup cost whatever you give it. Split down to 12 to 15, then stop.
- One wave is the default. A wave boundary is for a DEPENDENCY, never for headcount, and a wave costs its slowest member. Staging by topic importance is the live failure mode: it strands a long role in wave two where it idles and then becomes the critical path alone. When the set genuinely exceeds the cap, stage by expected duration and put the long poles in wave one.
- Rough order, longest first, measured once on a two-project EU account so beat it with what you observe:
usage-trend,change-point,definitions-and-hygiene,internal-context,clay·event-mix,site-scan,replay-and-errors,flags-and-experiments,money-quotas·data-platform,identity-and-sdks,app-engagement,money-invoices,docs-prewarm. Two are placed on judgment rather than that clock:clayandinternal-contextboth finished early only because their tools were absent, and a run where Clay polls for credits or Gong pages a transcript is far slower.money-quotasis promoted above its length, because what a limit actually cut off reframes every other finding.docs-prewarmgoes last and never takes a required gatherer's slot.
| Round | Who | Does what |
|---|---|---|
| 1a. Unblock | Main, three ordered batches | Everything deciding who to research and which batch to run, because both are wrong to guess. Batch 1: the Vitally resolve, resolve-teams, BOTH halves of sibling-sweep, and the site-scan subagent on the admin email domain, in this same block. Batch 2: the multi-org volume probe, which names the live org, plus one further site-scan per team beyond the first, each handed its own api_token from resolve-teams. Batch 3, and only now that the live org is known: the scope probe, the stage trait, get_account_conversations and the calendar read. Then write the context file (agent-briefs.md), detect the mode, and launch Round 2. The three batches are ordered, not one block. Running the scope probe beside the sweep profiles whichever org accounts[0] happened to return, and on the multi-org account this gate exists for that is the dead one, so its numbers land in the context file every gatherer then trusts |
| 1b. Resolve | Main, one block, concurrent with Round 2 | get_user_details, the vitally.accounts.traits read, account-state, account-spine, onboarding-state, other-account-tables, conversation-bodies, change-timeline, billing-limit-updates, account-context, touchpoint-timeline. Naming these is what stops them being silently skipped. billing-limit-updates is one query, it names the actor behind every limit, and a limit moved before a call reframes the money picture |
| 2. Gather | The roles below, in ONE wave where the cap allows, else staged longest-first | Every role the mode requires, docs-prewarm last |
| 2. (concurrently) | Main | Reading the conversation bodies, Step 4 roster, refining mode detection as internal-context returns |
| 3. Reconcile | Main, in ONE parallel block | The roll call, the hedge sweep, the named pairs, then re-run the header figures and the number driving the top recommendation. List every claim the output will make. Surface any disagreement between two sources with both figures, never resolve it |
| 4. Verify | Main where the cache covers the claim, one subagent per uncovered cluster, capped at ~5 claims each | A live docs search per claim, returning the URL and the verbatim line that proves it |
| 5. Write | Main | The output |
There is no separate pre-resolve round, and there is one site scan per TEAM rather than one per account. The domain scan needs only the admin email domain, which Step 1 has before it queries anything, so it goes in batch 1 beside the resolve rather than in front of it. An org with several teams runs several tokens, so one scan reads one project's init() and leaves the rest unknown: resolve-teams returns api_token for exactly this, and every team beyond the first gets its own scan in batch 2. Rescan on top of that only when Step 1 returns a different domain.
This is a correctness rule, not a speed one. A gatherer cannot be asked to recommend a config change on a project whose config is unread, and the unscanned project is repeatedly the one holding the findings: on a two-project account the scanned project had already been optimized and the unscanned one held every remaining lever.
Give every subagent the path to the context file, the conventions block, its reading-map row, and the return rules, all from agent-briefs.md. The context is resolved once by main and no gatherer re-resolves any of it; a gatherer missing a value reports the gap rather than querying for it. The site-scan agents are the exception, because they run before the context file exists: the batch-1 scan gets only the domain, and each batch-2 scan gets the domain plus its own team id and api_token.
Probe an optional source once. Gong, Slack and Clay share a shape: emptiness is knowable in one call, and a zero ends that source. Report "searched X, zero results", which is a finding and not a skip.
The role roster, what each owns and which sections each opens, is one table in agent-briefs.md. It lives there because that is the file main reads to write the briefs, and two tables listing the same roles in two always-loaded files is how they drift apart.
agent-briefs.md holds the exact sections each role opens, and that map is what the brief carries. Ask each gatherer 3 to 4 questions, not six: six numbered questions invite six investigations and turn a 12-call role into a 30-call one.
Probe an unfamiliar property in the block you are already firing, never in its own round: add JSONExtractKeys(assumeNotNull(properties)) for that event.
The scope probe, and its three guards
The Round 1a scope probe (one per-product-usage aggregate) knows which products carry volume. Pass it into each product brief as known state, each line carrying its own proof (billable_feature_flag_requests_count_in_period = 0 in billing_usage_by_org_date over the window, never a bare "flags are zero"). It changes what a brief says, never whether it runs. Three guards, and it is unsafe without all three:
- Write
unmeasurable, no sourcefor every product the probe has no column for. Experiment volume and web analytics volume have no source anywhere; heatmap volume lives only on the usage report'steamsmap;ff_countlives on the org usage report. - Check
realmonorg-snapshotbefore trusting a flat zero: the table is Cloud only, so a self-hosted org reads zero everywhere while emitting usage daily, and it carries no row for a day with no usage. - Each brief says "verify against the product's own source and report what you found", never "confirm and move on".
Never use the probe to skip a role the mode requires. Skipping is where a zero and an unmeasurable become the same thing, which is the failure this skill exists to prevent. An agent that ran and confirmed a zero is not a skip.
Round 3: roll call, hedge sweep, named pairs
Three checks before any re-run, because each catches a whole missing input rather than a wrong digit.
The roll call. List the roles the mode requires against the roles launched, and the Round 1 slugs required against the ones that returned. Every gatherer justifies its own skipped reads, but nothing checks the level above it, so a role dropped under time pressure disappears silently while the output reads complete. Launch the gap, or name it in the closing note. "Short of time" is a reason to state, never a reason to omit. This matters most on an ask that is neither an email nor a brief, where there is no expected section for the reader to notice missing.
The hedge sweep, and it produces a list, not an intention. Read every digest for the gatherers' own uncertainty ("probable not proven", "unmeasurable", "needs confirming", "worth reconciling", "the biggest skip is", "ask on the call") and write every hit into a numbered list before closing any of them. Each line gets a disposition: closed with the number, or carried into the output as a named open question. A hedging gatherer has almost always named the read that would close it, so each is cheap and pre-scoped.
Doing this in your head is what fails. A sweep held as an intention closes the easy hedges, and the one it drops is reliably the one sitting under the top recommendation, because that is the hedge whose answer takes work. An unresolved hedge is how a finding dies: the agent did its job, the signal sits in the digest, and it never becomes a sentence. Two specific shapes to catch, both of which have shipped as assertions: a hedge that would have SIZED a lever you are already recommending (you keep the recommendation and lose the number that makes it land), and a hedge naming a cause that another gatherer was holding the data to confirm.
The named pairs, sources that must agree:
- A site-scan absence against the customer's own event stream. The scan is authoritative for what it found, never for what it did not. If their team receives events carrying that host in
$current_url, the tool is installed there whatever the scan said. One query, before any absence claim reaches the output. - The header's money figures against the invoice.
- Two gatherers against each other, wherever they touched the same object. Reachability claims are the ones that collide: one role probes a table, gets
Unknown tableand reports unmeasurable, while another reads a near-identical name successfully and publishes a finding from it. Both digests are then true and the output carries a contradiction. Before writing, list every object more than one role touched and confirm they agree on whether it exists and what it holds.
Round 4 verification, and the docs cache
Verification runs against the finished claim list; Round 2 pre-warming is additive and may never shrink it. Where the cache covers a claim, verify in main; fan out only for the claims it missed. The invariant holds either way: every claim gets its own live search, and the count of searches against claims is what proves it happened.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 71
- Forks
- 7
- Last commit
- Sep 2026
ahel review
K2info
exfiltration (in scripts/site-scan.sh)K1binfo
installs-packages (in README.md)
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
posthog-customer-deep-dive- Source
- github.com/posthog/skills