Experiment Audit
PostHogOfficialPublished by PostHog
- Skill
- Review and audit
- Data and AI
- MIT
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill performs a structured audit of PostHog A/B experiments to ensure they are correctly configured and tracking data as expected. It verifies experiment settings, feature flag release conditions, exposure event collection, and downstream attribution. The skill follows a strict refusal protocol to ensure it only operates when properly authenticated and scoped to a customer's project, providing a detailed, Slack-ready report of findings and potential pitfalls.
What it does
- Verifies experiment configuration, including variants, traffic splits, and metric definitions.
- Checks feature flag release conditions for common misconfigurations like incorrect URL targeting.
- Validates that exposure events are being captured and correctly bucketed.
- Analyzes downstream attribution to ensure conversion events are properly linked to experiment variants.
- Identifies potential issues with identity continuity and cross-domain tracking.
- Generates a structured, actionable report addressing standard customer questions about experiment health.
When to use it
- When a customer suspects their A/B test is not collecting data correctly.
- To validate that an experiment's metrics are measuring the intended user behavior.
- When investigating why an experiment shows zero exposures or unexpected results.
- To perform a proactive health check on a running or planned experiment.
Advantages
- Provides a consistent, standardized audit process for complex experiment setups.
- Includes a robust refusal protocol to prevent misconfiguration or data leakage.
- Ensures audit findings are backed by real data queries rather than assumptions.
- Produces clear, actionable recommendations for engineering teams to resolve tracking issues.
Usage examples
- “audit the checkout-flow experiment for Acme Corp”
- The agent verifies the project scope, pulls the experiment configuration, checks exposure and attribution data, and returns a structured report of findings.
- “check experiment setup for my customer”
- The agent confirms the active project, lists recent experiments, and proceeds to audit the specified test, flagging any configuration errors or tracking gaps.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/posthog/experiment-audit/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip experiment-audit.zip -d .agents/skills
Documentation by PostHog
Experiment Audit
Verify that a customer's experiment is actually collecting variant data, that downstream attribution survives the funnel, and that the metrics measure what the customer thinks they measure. Output is a Slack-ready writeup grouped by the four questions customers almost always ask.
Step 0 — confirm scope before running
The skill assumes the active PostHog MCP is scoped to the customer's project, not yours. Always start with:
"What project am I in? List the most recent 5 experiments."
Interpret the answer:
Customer's project. Proceed to Step 1.
Your own internal project (typically "PostHog App + Website", id 2). The impersonation session isn't routing. Refuse per the Refusal protocol. Do not proceed.
MCP call fails outright (no response, auth error, tool unavailable). Refuse per the Refusal protocol. Do not proceed.
Refusal protocol
If any structural condition prevents you from actually auditing the customer's project, refuse explicitly and stop. Do not fall back to any of these anti-patterns:
Generating prompts, checklists, or instructions for the user to paste into PostHog AI, another Claude session, or the customer's own tools. That's not helping; it's silently shifting the work onto the user while hiding why this skill couldn't do it.
Producing "here's what an audit would look like" template content without real data behind it.
Guessing at findings from prior knowledge of the customer or the product.
When to refuse (each of these triggers the protocol)
reason_code | Condition |
|---|---|
wrong_project | what project am I in? returned a project ID other than the customer's (typically your own, "PostHog App + Website", id 2). Use this whenever the project identity is wrong, even if that project happens to be empty. |
empty_project | The MCP is on the customer's project (identity matches), but experiment-list returns zero results. The audit has nothing to work with. Discriminator vs wrong_project: identity matches expected. |
mcp_disconnected | No MCP server is responding at all — tool calls fail before reaching the server (transport error, "MCP not connected", /mcp shows no posthog entry). |
mcp_auth_expired | MCP server responds but returns 401/403 on a call that should succeed (the impersonation session lapsed or the token was revoked mid-audit). |
switch_project_failed | switch-project responded with an error, or the project you tried to switch to isn't in the current user's accessible projects. |
experiment_not_found | experiment-list search returned no match for the experiment the user named. Do not run a similarly-named experiment as a substitute — refuse and ask. |
tool_call_error | The MCP server responded, auth is valid, but an individual tool call returned an error that blocks a downstream step and can't be worked around. Use this for the "MCP is up but this specific call failed" case. If the exposure query itself errored (500, timeout, malformed response), this is the code — but a query that succeeds and returns zero rows is an audit finding, not a refusal (see Step 3). |
other | Anything the above doesn't cover. Only use when a specific code doesn't fit — always prefer the specific one. |
What to do on refusal
Output the refusal in chat in this exact shape, and stop:
:no_entry: Cannot complete this audit
What I tried: [one line — e.g. "Confirm scope via 'what project am I in?'"]
What went wrong: [one line — e.g. "MCP is scoped to 'PostHog App + Website' (id 2), not the customer's project."]
Why this blocks the audit: [one line — e.g. "Every downstream query would run against your own data, not theirs."]
How to fix: [concrete next step — e.g. "Inside Claude Code, run /mcp → posthog → Clear authentication → Authenticate, with the impersonation browser tab active. Then re-run this audit."]Then also append the same refusal as a JSONL entry to $CSM_IMPERSONATE_REFUSAL_LOG (env var set by the wrapper — defaults to ~/.local/state/impersonate-audit/refusals.log). Substitute your own values for every <...> placeholder below; do not copy the placeholders verbatim. Every free-text field is piped through the deterministic redactor at $CSM_IMPERSONATE_REDACT before it reaches jq, so token-shaped strings can't survive to disk even if you mis-classify them:
redact() {
# If the redactor is missing, the caller falls through to an empty
# string (bash swallows the non-zero exit inside "$(...)"). The stderr
# note surfaces the reason and no secret can leak — safer than a raw
# write. Prefer running under the wrapper where the env var is always
# set to a real script.
[[ -n "${CSM_IMPERSONATE_REDACT:-}" && -x "$CSM_IMPERSONATE_REDACT" ]] || { echo "redactor unavailable — field will be empty" >&2; return 1; }
printf '%s' "$1" | "$CSM_IMPERSONATE_REDACT"
}
jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session_id "$CSM_IMPERSONATE_SESSION_ID" \
--arg account "$CSM_IMPERSONATE_ACCOUNT" \
--arg account_name "$CSM_IMPERSONATE_ACCOUNT_NAME" \
--arg audit_file "$CSM_IMPERSONATE_AUDIT_FILE" \
--arg reason_code "<REASON_CODE>" \
--arg tried "$(redact "<one line of what you tried>")" \
--arg went_wrong "$(redact "<one line of what went wrong>")" \
--arg why_blocks "$(redact "<one line of why this blocks the audit>")" \
--arg how_to_fix "$(redact "<one line of the concrete fix>")" \
'{ts:$ts, session_id:$session_id, account:$account, account_name:$account_name, audit_file:$audit_file, reason_code:$reason_code, tried:$tried, went_wrong:$went_wrong, why_blocks:$why_blocks, how_to_fix:$how_to_fix}' \
>> "$CSM_IMPERSONATE_REFUSAL_LOG"Every free-text field in the refusal — tried, went_wrong, why_blocks, how_to_fix — must go through the redactor. account_name doesn't (it's the customer name you already have in the wrapper env).
If $CSM_IMPERSONATE_REFUSAL_LOG is unset (skill invoked outside the wrapper), skip the log-write step — do not fall back to a default path. Still emit the refusal in chat and to the audit file.
Then write the same refusal (in the human-readable format above, not JSON) to $CSM_IMPERSONATE_AUDIT_FILE — create the file or append. Build each of the four content lines through redact() before writing, exactly like the JSONL block. The audit file is a durable, human-readable artifact — often committed to a notes repo or shared with the customer — and must not carry token material that the JSONL sibling was careful to strip. Include a Session: <id> footer line so the audit file joins back to the debug log without needing the terminal banner.
cat >> "$CSM_IMPERSONATE_AUDIT_FILE" <<EOF
:no_entry: Cannot complete this audit
What I tried: $(redact "<one line of what you tried>")
What went wrong: $(redact "<one line of what went wrong>")
Why this blocks the audit: $(redact "<one line of why this blocks the audit>")
How to fix: $(redact "<one line of the concrete fix>")
Session: $CSM_IMPERSONATE_SESSION_ID
EOFThe log-write step is not optional when the env var is set. A refusal without a log entry is invisible to future debugging.
Debug log — capture the raw error for later
Alongside the structured refusal, append every raw MCP error you can see to $CSM_IMPERSONATE_DEBUG_LOG (defaults to ~/.local/state/impersonate-audit/debug.log). This is the log Jake or whoever hits the failure comes back to. The refusal log tells you that something failed; the debug log tells you what the server actually said.
Write one JSONL entry per raw error observation. The session_id field must match the one you wrote to the refusal log so the two are joinable. Every free-text field goes through the redactor before it reaches jq:
redact() {
# If the redactor is missing, the caller falls through to an empty
# string (bash swallows the non-zero exit inside "$(...)"). The stderr
# note surfaces the reason and no secret can leak — safer than a raw
# write. Prefer running under the wrapper where the env var is always
# set to a real script.
[[ -n "${CSM_IMPERSONATE_REDACT:-}" && -x "$CSM_IMPERSONATE_REDACT" ]] || { echo "redactor unavailable — field will be empty" >&2; return 1; }
printf '%s' "$1" | "$CSM_IMPERSONATE_REDACT"
}
jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session_id "$CSM_IMPERSONATE_SESSION_ID" \
--arg account "$CSM_IMPERSONATE_ACCOUNT" \
--arg event "$(redact "<event type — e.g. mcp_error, probe, tool_call_error>")" \
--arg tool "$(redact "<tool name — e.g. experiment-list>")" \
--arg status "$(redact "<HTTP status or transport class — e.g. 401, timeout, tool_not_found>")" \
--arg error_class "$(redact "<short label — e.g. AuthError, ProjectNotFound>")" \
--arg raw "$(redact "<verbatim server error string, capped at ~2000 chars>")" \
'{ts:$ts, session_id:$session_id, account:$account, event:$event, tool:$tool, status:$status, error_class:$error_class, raw:$raw}' \
>> "$CSM_IMPERSONATE_DEBUG_LOG"Acceptable event values (write one JSONL line per event as it happens): probe (the "what project am I in?" call and its result — but log only the project ID, not the full response body), tool_call_success for calls that completed but returned surprising data (empty list, unexpected id), tool_call_error for individual call failures, mcp_error for transport / auth-level failures.
Redaction is done by piping through $CSM_IMPERSONATE_REDACT — a deterministic Perl script that runs outside the LLM, so no prompt-injected error body can talk you into skipping it. What it catches:
Bearer <hex/base64/base64url>— replaces the token portion, keeps theBearerprefix.phc_...,phx_...,phs_...,sTOK_...and any known PostHog token prefix — keeps the prefix so you know the shape of what leaked, redacts the secret tail.JWTs (
eyJ<header>.<payload>.<signature>) — one-shot redacted to<REDACTED-JWT>.Cookie / Set-Cookie header values.
Framed secrets — a keyword like
token,code,access_token,refresh_token,authorization,secret,api_keyfollowed by:/=/ whitespace and a token-shaped value. The rule anchors on the keyword so it names what it protects and leaves free-standing identifiers (flag keys, class names, slugs) alone.
Note: there is deliberately no pure length+charset catch-all. That pattern ate legitimate identifiers and the debug log's value depends on preserving them so a human can grep. When a new token shape shows up in the wild, add a named rule for it.
Additional rules for the raw field content itself:
Do not paste full customer query results — the goal is to preserve the server error message, not the response body. If a call succeeded but returned surprising data, log a shape summary (
"returned 12 experiments, expected 0"), not the raw rows.Cap
rawat ~2000 characters. If longer, truncate and append... [truncated].
If $CSM_IMPERSONATE_DEBUG_LOG is unset, skip the debug-log write — do not fall back to a default path.
Reviewing refusals later
To scan past refusals: jq -c . ~/.local/state/impersonate-audit/refusals.log | tail -20
To find every refusal for one account: jq -c 'select(.account == "acme-inc")' ~/.local/state/impersonate-audit/refusals.log
To count by reason: jq -r .reason_code ~/.local/state/impersonate-audit/refusals.log | sort | uniq -c | sort -rn
To pull the full raw-error trail for one refusal (join by session_id):
SID="$(jq -r 'select(.reason_code == "mcp_auth_expired") | .session_id' ~/.local/state/impersonate-audit/refusals.log | tail -1)"
jq -c "select(.session_id == \"$SID\")" ~/.local/state/impersonate-audit/debug.logStep 1 — pull the experiment config
Use experiment-list with search to find the experiment by name. Then pull the full record. Capture:
Status (running / draft / stopped / paused) and start date.
Linked feature flag key and ID.
Variants and traffic split. Variant names must match what the customer's code reads — bucketing bugs are usually case/typo mismatches.
Holdout, bucketing key (
device_idvsuser_id), andensure_experience_continuity.Exposure event — default
$feature_flag_calledor a custom event.Primary and secondary metrics. Note action IDs, event names, breakdowns, conversion windows, attribution modes.
Filter test accounts setting.
Step 2 — pull the feature flag config
For the linked flag:
Release conditions — read each one carefully.
Watch for two specific footguns:
URL targeting via person property (
$current_url = ...) — uses the latest URL the person has been seen on, not the current page. Stale by definition. Always flag as a problem.Exact-match on a path fragment —
$current_urlis captured as the full URL (https://host/path). Exact-matching/pathwill never hit.
Audience filters (desktop-only, geo, cohort). Verify they use person properties or group properties — not URL.
Rollout percentage and any super-conditions.
ensure_experience_continuitysetting on the flag (this overrides the experiment-level setting).
Step 3 — verify exposure is happening
Pull $feature_flag_called events for the flag key since the experiment start date.
Total count. If suspiciously low for the time elapsed, dig.
Break down by
$feature_flag_response. Should split close to 50/50 between the variant names (e.g.control/test). Flag a sample ratio mismatch if imbalance exceeds ~5% with non-trivial volume.If
$feature_flag_responsereturnsfalsefor most events, the user isn't being bucketed into the experiment at all — the release condition is rejecting them. This is the most common cause of "experiment shows 0 exposures."Spot-check 5 raw event rows. Note the
$current_url,$device_type,$feature_flag,$feature_flag_response, anddistinct_id.
Step 4 — verify downstream attribution
For each metric event (CTA click, signup page visit, signup completion, conversion):
Pull sample rows and confirm they carry the
$feature/<flag-key>property with a real variant value (controlortest), notfalseor missing.Action-based metrics: check the action filters. If the variant renders different DOM IDs, the action must match all of them or one variant will artificially show 0 events. Action URL filters should match the production page, not a dev preview.
Metric scoping: if the metric is too broad (e.g. "any
$pageviewcontaining/signup"), it will credit both variants for global traffic regardless of source. Suggest scoping by$feature/<flag-key>property or session entry pathname.
Step 5 — verify identity continuity
Cross-domain handoff (Webflow → app, marketing → product, etc.) is where attribution usually dies.
Pick 5–10 users who reached the final funnel step (e.g. signup completion). Pull their event timeline.
Confirm they have a prior
$feature_flag_calledevent with a real variant value.Confirm
$identifyfires on the handoff. If users never have an$identifyevent, the anonymous device profile never stitches to the authenticated user — variant attribution is dead even with a perfectly fired flag.Confirm bucketing key +
ensure_experience_continuitysettings together don't cause re-bucketing.device_idbucketing without continuity = same user on a new device looks fresh.
Step 6 — downstream conversion metric (trial activation / purchase / etc.)
Customers often have a primary conversion event that lives downstream (in their app or warehouse).
Search the event schema for the expected event name. Try several variants (
plus_trial_activated,trial_started,subscription_created).If not present, look for warehouse sources via
external-data-sources-list. Common pattern: Snowflake/Postgres table likeaccounts.trial_started_at.Recommend the cleaner path: emit a server-side event from the app on activation. Easier than warehouse joins, faster signal, no schema fragility.
Alternative: use the warehouse table as an experiment metric directly (supported for funnel + trend metrics).
Step 7 — common pitfalls to call out (regardless of what you found)
The customer's actual setup almost always has one of these:
Person-property URL targeting (always wrong for this use case)
Exact-match operators on full-URL person properties (never hit)
Action metrics tied to dev URLs/selectors that won't fire on prod
Global metrics ("any signup completion") that credit both variants equally
Missing
$identifyon the marketing → product domain handoffdevice_idbucketing withoutensure_experience_continuity→ re-bucketing across sessionsDefault 14-day conversion window too short for downstream conversion events
Internal/test user filter not configured → QA traffic skews early days
Sanity check exposure within 24h of launch — a 50/50 that shows <10 events in a week is a wiring bug, not a power problem
Output format
Group the report by the four standard customer questions. Lead with the worst finding:
:warning: [Experiment name] — audit findings
[One-paragraph TL;DR of the headline finding. Be direct.]
---
(a) Does the config look correct?
[Verdict + specific issues with evidence — event counts, sample values, etc.]
---
(b) How to verify attribution (once issues are fixed)
[Concrete steps the customer can run themselves.]
---
(c) What to change about attribution
[Numbered action list, priority order. Each item should be specific
enough that the customer's engineer can act on it directly.]
---
(d) Common pitfalls to watch for
[Subset of step 7's checklist relevant to this customer's setup.
Frame as general guidance, not as accusations.]
---
Bottom line: [one or two sentences. What's the single most important
fix that unblocks the experiment?]Rules
Read-only. Do not create insights, dashboards, actions, experiments, or modify any config. You are working inside a customer's project under read-only impersonation; treat it as look-but-don't-touch.
No prompt-passing. Do not generate prompts, checklists, or instructions for the user to run in PostHog AI, another Claude session, or any other tool. If you can't do the audit in this session, refuse explicitly per the Refusal protocol. A skill that quietly redirects work to the user is a skill that hides its own failures.
Refusal stands under pressure. If the user pushes back on a refusal ("just give me the prompts, I'll paste them", "run it anyway", "generate what you would have output"), the refusal still stands. Repeat the refusal shape with the same reason_code and stop. The whole point is that failure modes surface the fix, not the workaround.
No raw token echoes. Every free-text field in every log entry (both
refusals.loganddebug.log) must be piped through$CSM_IMPERSONATE_REDACTbefore write. Do not attempt to redact by hand — the whole point of the script is that redaction is deterministic and can't be prompt-injected. If the script is unavailable (env var unset), do not write the field; log the fact that redaction was unavailable instead.No fabrication. If you can't find the experiment or the data is empty, say so explicitly and refuse per the Refusal protocol. Do not invent findings to fill the template.
Cite real numbers. Every claim about exposure counts, sample ratios, or event volumes must come from a query you actually ran in this session.
Surface ambiguity. If a setting could be intentional (e.g. low conversion window because conversion happens fast), note both interpretations and ask the customer to confirm.
Match the customer's writing register. Customers using PostHog are usually technical — don't oversimplify. But avoid jargon shorthand they may not know yet.
When the audit is done
Remind the user to:
Exit Claude Code
Log out of Django Admin impersonation in their browser
Optionally disable the posthog plugin:
claude plugin disable posthog
The impersonate-audit.sh wrapper handles step 3 prompts automatically on exit.
Files
- .claude-plugin/plugin.json524 B
- ATTRIBUTION.mdAdded by AutoGineer870 B
- LICENSELicence file added by AutoGineer1.0 KiB
- README.md13.0 KiB
- SKILL.md20.4 KiB
- assets/settings.local.json4.8 KiB
- scripts/impersonate-audit-shim.shScript2.3 KiB
- scripts/impersonate-audit.shScript19.7 KiB
- scripts/redact-test.shScript4.9 KiB
- scripts/redact.shScript3.0 KiB
README.md
impersonation-toolkit
Run impersonation-scoped audits against a customer's PostHog project via Claude Code + the PostHog MCP plugin.
Audits run under PostHog's read-only impersonation, which is enforced server-side — see Security model.
What it gives you:
One command to spin up an impersonation audit folder per customer
A permissions config that pre-approves the read tools, so an audit runs start to finish instead of stopping for approval dozens of times. One file for all your accounts, and it keeps whatever you approve mid-session
A reusable audit playbook (the
experiment-auditskill) that walks through experiment config, exposure, attribution, and metrics in a Slack-ready format
Prerequisites
Claude Code installed
PostHog MCP plugin installed (
claude plugin install posthog@claude-plugins-official, or runnpx @posthog/wizardonce)Django Admin impersonation access (PostHog staff)
Install
/plugin marketplace add PostHog/skills
/plugin install impersonation-toolkit@PostHog-skillsThen drop the launcher shim into ~/bin/ so the wrapper always picks up the latest installed version of the plugin (run this once — no need to re-run after claude plugin update):
mkdir -p ~/bin
cp "$(ls -d ~/.claude/plugins/cache/PostHog-skills/impersonation-toolkit/*/ | sort -V | tail -1)scripts/impersonate-audit-shim.sh" ~/bin/impersonate-audit
chmod +x ~/bin/impersonate-auditConfirm ~/bin is on your PATH (add export PATH="$HOME/bin:$PATH" to your ~/.zshrc or ~/.bashrc if not).
The shim makes two separate checks before handing off: is the plugin installed (hard error, with the install command) and is the copy stale (warning only, at most once a day, with the update command). Silence the freshness warning with CSM_IMPERSONATE_STALE_DAYS=0, or widen it to a week with =7.
Usage
impersonate-audit <account name>For example:
impersonate-audit globex
impersonate-audit "Acme Inc" # quotes optional — the words get joinedThe script will:
Create (or reuse)
~/impersonate/<account-slug>/— lowercase, with runs of spaces and punctuation collapsed to single hyphens, soAcme IncandCafé Söda, Inc.becomeacme-incandcafe-soda-incPause and remind you to impersonate the customer's user in Django Admin (manual step, and read-only — that's the default and what an audit needs)
Launch Claude Code at the impersonation root, with that folder ready for output
Inside Claude Code: run
/mcp, find theposthogplugin, clear authentication then authenticate — this binds a fresh token from your active impersonation sessionSanity check by asking Claude
what project am I in?— should be the customer's project, not yoursRun the audit by asking e.g.
audit <customer>'s experiment named "<experiment name>"— theexperiment-auditskill auto-triggersOn exit, the script auto-disables the posthog plugin if it's still connected and reminds you to log out of Django Admin
Audits are written to <account-slug>/YYYY-MM-DD.md, one file per account per day.
Other flags:
| Flag | What it does |
|---|---|
-d, --dir <path> | Use a different impersonation root for this run |
--migrate | Normalise an existing tree to the current layout (see Migrating) |
--reset-settings | Overwrite the shared permissions file from the plugin's template |
-h, --help | Usage |
Configuration
Two optional knobs, and that's the whole surface. Everything else the audit needs (account slug, folder, audit filename) is derived per run from the name you pass — there is nothing to set per account.
| Variable | Default | What it does |
|---|---|---|
CSM_IMPERSONATE_HOME | ~/impersonate | Where per-account folders live |
CSM_CUSTOMER_CONTEXT_DIR | ~/Documents/Obsidian Vault/Customers if that directory exists, otherwise unset | Where your own per-account notes live. The account-audit skill reads the matching account's notes before writing findings. Leave it unset and the skill skips notes entirely rather than hunting around your filesystem for a vault you don't have |
Set them either in the config file, created (fully commented out) on first run at ${XDG_CONFIG_HOME:-~/.config}/impersonate-audit/config:
CSM_IMPERSONATE_HOME="$HOME/work/impersonate"
CSM_CUSTOMER_CONTEXT_DIR="$HOME/notes/customers"...or by exporting them from your own env file (csm.env, ~/.zshrc, whatever) — the environment wins over the config file, and --dir wins over both.
Migrating an existing tree
If you were on 1.0.x you have ~/impersonate/Acme Inc/audit-2026-08-11.md and a .claude/settings.local.json in every account folder. One command folds all of it into the current layout:
impersonate-audit --migrateIt renames folders to their slug form, drops the audit- prefix from audit files, and merges every per-account permissions file into the shared one at the root (so the approvals you granted in each folder carry over rather than being thrown away). It prompts first and skips anything that would collide. Running a normal audit for an account whose folder still has the old name offers the same rename for just that account.
Security model
Audits are read-only, enforced server-side by PostHog. The OAuth token minted from a read-only impersonation session has every write scope stripped before issue, and the API rejects mutating requests made with it. The toolkit mints no credentials of its own and can grant itself nothing.
Permissions file — what it does
Purely operational: a PostHog audit calls dozens of read tools, and without an allowlist each one stops for approval. assets/settings.local.json pre-approves the read tools so a run goes start to finish, and leaves everything else to prompt normally.
The template also keeps an ask list for mutation-shaped tool names (*-create, *-update, *-delete, execute-sql). Not a safety layer — the server rejects writes with a read-only token regardless — but two useful side-effects:
Awareness. A prompt during a read-only audit is a signal worth noticing, even though the request would fail.
Documentation of intent. The list tells a new CSM which tools are treated as sensitive by convention.
It is merged into a single .claude/settings.local.json at the root of your impersonation tree. Claude launches at that root, so one file covers every account. Account folders are where output lands, not separate workspaces:
~/impersonate/ <- Claude runs here
├── .claude/settings.local.json <- the only permissions file
├── acme-inc/2026-08-11.md
└── initech-co/2026-08-13.mdPermissions are granted once. Anything you approve mid-session lands in that shared file and applies to every account from then on.
Two consequences worth knowing:
Template updates are merged, not copied over the top, so
claude plugin updatecan't wipe approvals you granted. Entries the template moves betweenallowandaskfollow the template. Merging needsjq; without it an existing file is left untouched rather than clobbered.--reset-settingsforces a clean copy from the template.Every session's working directory contains every account's folder, so a session for one customer can read another customer's audit if you ask it to. If you'd rather each session only see one account, that's yours to arrange — point
--dirat a per-account root, or make the impersonation root a git repo (Claude Code resolves project settings to the repo root, which lets you launch inside an account folder and still share one settings file). The wrapper follows an existing repo root if it finds one; it won't create one for you.
Updating
claude plugin updateThe shim in ~/bin/impersonate-audit always discovers the newest installed version — no manual re-symlinking needed. It also nudges you (once a day, non-fatally) when the installed copy has gone stale, so you don't audit on a months-old playbook without noticing.
File layout
impersonation-toolkit/
├── .claude-plugin/plugin.json # Plugin manifest (discovered by marketplace)
├── SKILL.md # Audit playbook — auto-triggers on "audit X's experiment"
├── README.md # This file
├── scripts/
│ ├── impersonate-audit.sh # Wrapper invoked by the ~/bin/impersonate-audit shim
│ ├── impersonate-audit-shim.sh # The shim itself — copy it to ~/bin/impersonate-audit
│ └── redact.sh # Deterministic token redactor for the two logs
└── assets/
└── settings.local.json # Permissions template, merged into the shared settings fileAnd what it creates on your machine:
${XDG_CONFIG_HOME:-~/.config}/impersonate-audit/config # your two optional knobs
${XDG_STATE_HOME:-~/.local/state}/impersonate-audit/
├── refusals.log # one JSONL entry per refused audit
└── debug.log # raw MCP errors, joinable to refusals.log by session_id
$CSM_IMPERSONATE_HOME/ # default ~/impersonate, where Claude runs
├── .claude/settings.local.json # shared permissions
└── <account-slug>/<YYYY-MM-DD>.md # one audit per account per dayDebugging failed audits
When the skill can't complete an audit — wrong MCP project, disconnected MCP, missing experiment, empty data — it refuses explicitly instead of falling back to producing prompts for you to paste elsewhere. Two logs get you the full picture:
refusals.log— one JSONL entry per refused audit. Structured summary: account, timestamp, reason_code, what was tried, how to fix. This is the log to scan first.debug.log— verbose companion. Raw MCP error responses, tool names, HTTP statuses, and error classes. Token-shaped strings are redacted before write. Cross-reference viasession_id(also printed in the launch banner).
Both live under ${XDG_STATE_HOME:-~/.local/state}/impersonate-audit/. Neither rotates automatically; move old files aside when they get big.
# most recent refusals
jq -c . ~/.local/state/impersonate-audit/refusals.log | tail
# refusals for one account
jq -c 'select(.account == "acme-inc")' ~/.local/state/impersonate-audit/refusals.log
# count by reason
jq -r .reason_code ~/.local/state/impersonate-audit/refusals.log | sort | uniq -c | sort -rn
# pull the full raw-error trail for the most recent auth-expired refusal
SID="$(jq -r 'select(.reason_code == "mcp_auth_expired") | .session_id' \
~/.local/state/impersonate-audit/refusals.log | tail -1)"
jq -c "select(.session_id == \"$SID\")" ~/.local/state/impersonate-audit/debug.logReason codes: wrong_project, empty_project, mcp_disconnected, mcp_auth_expired, switch_project_failed, experiment_not_found, tool_call_error, other. See SKILL.md for the exact discriminator on each. (A query that returns zero rows is an audit finding, not a refusal — Step 3 of the playbook diagnoses that.)
Redaction is done by scripts/redact.sh — a small Perl script the wrapper points the skill at via $CSM_IMPERSONATE_REDACT. It runs outside the LLM, so a prompt-injected MCP error body can't talk the skill into skipping it. Every free-text field in either log — plus the human-readable refusal in the audit .md file — is piped through the script before write.
Patterns covered: Bearer tokens, PostHog phc_/phx_/phs_/sTOK_ prefixes, JWTs (eyJ<hdr>.<pld>.<sig>), cookie / set-cookie header values, and framed secrets (a keyword like token, code, authorization, secret, api_key followed by : / = / whitespace and a token-shaped value). Each rule names the shape it redacts; there's no length-based catch-all because that pattern ate legitimate identifiers. When a new token shape shows up, add a named rule to scripts/redact.sh and add a matching case to scripts/redact-test.sh so the pattern is regression-tested.
Caveats
The Django Admin impersonation step is intentionally manual (security boundary). The script can't bypass it.
The PostHog MCP plugin's tool-name prefix is assumed to be
mcp__plugin_posthog_posthog__*. If permission rules don't seem to apply, run the first audit, watch for unexpected prompts on read tools, and check the actual tool names exposed via/mcp.The read allowlist is broad but not exhaustive across all 300+ PostHog MCP tools, so a read tool with an unconventional name will still prompt. Harmless, just a bit of friction — PR additions to
assets/settings.local.jsonin this plugin's directory.Account slugs are derived from the name you type, so
InitechCoandInitech Coare different folders (initechcovsinitech-co). Stay consistent, or point at the existing folder with--dirif you split one by accident.
Maintainer
Sebastian Muriel — ping in #project-customer-analytics with feedback, gaps, or PRs.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an MCP server, Needs an API key
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- PostHog
- Publisher
- PostHog (Official)
- Source
- PostHog/skills · skills/team/customer-success/impersonation-toolkit · commit a5d30e7
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by PostHog or PostHog.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download experiment-audit.zip