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
scripts/impersonate-audit.sh
#!/usr/bin/env bash
#
# impersonate-audit.sh — orchestrate a customer impersonation audit via PostHog MCP
#
# Assumes the `posthog` plugin is already installed in Claude Code (one-time
# setup, done previously via `npx @posthog/wizard` or `claude plugin install`).
# This script just shepherds you through re-authing the plugin against a fresh
# impersonation session, runs the audit, and reminds you to clean up.
#
# Usage:
# impersonate-audit.sh [options] <account name>
#
# Options:
# -d, --dir <path> Override the impersonation root for this run
# --migrate Normalise existing folders/files to the current layout
# --reset-settings Overwrite the shared permissions file from the template
# -h, --help Show usage
#
# Configuration (first match wins):
# --dir flag > $CSM_IMPERSONATE_HOME > config file > ~/impersonate
#
# Every knob is CSM_-prefixed, so you can keep them in your own env file
# (csm.env, ~/.zshrc, whatever) instead of the config file. Defaults reproduce
# the behaviour this script had before these knobs existed:
# CSM_IMPERSONATE_HOME default ~/impersonate
# CSM_CUSTOMER_CONTEXT_DIR default ~/Documents/Obsidian Vault/Customers,
# used only if that directory actually exists
# CSM_IMPERSONATE_CONFIG default ${XDG_CONFIG_HOME:-~/.config}/impersonate-audit/config
#
# The config file is created (fully commented out) on first run.
#
# Layout — Claude runs at the root, so one settings file covers every account:
# $CSM_IMPERSONATE_HOME/ working directory for every session
# ├── .claude/settings.local.json shared permissions, merged not clobbered
# └── <account-slug>/YYYY-MM-DD.md one audit per account per day
#
# Flow:
# 1. Resolve config, slugify the account name, create its folder
# 2. Remind you to impersonate the customer's user in Django Admin (manual)
# 3. Remind you to /mcp re-auth the posthog plugin inside Claude Code
# 4. Launch `claude` at the root, with the account's folder ready for output
# 5. On Claude exit, print cleanup reminders
set -euo pipefail
CONFIG_FILE="${CSM_IMPERSONATE_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/impersonate-audit/config}"
# Where per-account notes lived before this was configurable. Still the default,
# but only honoured if the directory is actually there.
DEFAULT_CONTEXT_DIR="$HOME/Documents/Obsidian Vault/Customers"
# The settings template ships alongside this script inside the plugin.
# $BASH_SOURCE resolves to the actual script path (the shim in ~/bin/
# `exec`s here, so we land at the plugin cache location).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TEMPLATE_FILE="$PLUGIN_DIR/assets/settings.local.json"
usage() {
cat <<'EOF'
Usage: impersonate-audit [options] <account name>
impersonate-audit globex
impersonate-audit "Acme Inc" -> ~/impersonate/acme-inc/
impersonate-audit --dir ~/work/audits acme
Options:
-d, --dir <path> Impersonation root for this run (overrides config + env)
--migrate Normalise existing folders/files to the current layout
--reset-settings Overwrite the shared permissions file from the template
-h, --help Show this help
Config file: ${XDG_CONFIG_HOME:-~/.config}/impersonate-audit/config
Anything you export in your own env file (csm.env, ~/.zshrc, ...) wins over it:
CSM_IMPERSONATE_HOME=... account folders live here (default ~/impersonate)
CSM_CUSTOMER_CONTEXT_DIR=... your per-account notes live here (default
~/Documents/Obsidian Vault/Customers if present,
otherwise the notes step is skipped)
EOF
}
# ---------------------------------------------------------------- arguments
CLI_HOME=""
MODE="run"
RESET_SETTINGS=0
POSITIONAL=()
while (($#)); do
case "$1" in
-d|--dir)
if [[ -z "${2:-}" ]]; then
echo "Error: --dir needs a path" >&2
exit 1
fi
CLI_HOME="$2"
shift 2
;;
--migrate)
MODE="migrate"
shift
;;
--reset-settings)
RESET_SETTINGS=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
while (($#)); do
POSITIONAL+=("$1")
shift
done
;;
-*)
echo "Error: unknown option '$1'" >&2
usage >&2
exit 1
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done
# Unquoted multi-word names ("Acme Inc") arrive as separate arguments — join
# them back so you don't have to remember the quotes.
ACCOUNT_NAME=""
if ((${#POSITIONAL[@]})); then
ACCOUNT_NAME="${POSITIONAL[*]}"
fi
# ------------------------------------------------------------------- config
# Env wins over the config file, so snapshot it before sourcing.
ENV_HOME="${CSM_IMPERSONATE_HOME:-}"
ENV_CONTEXT_DIR="${CSM_CUSTOMER_CONTEXT_DIR:-}"
if [[ ! -f "$CONFIG_FILE" ]]; then
mkdir -p "$(dirname "$CONFIG_FILE")"
cat > "$CONFIG_FILE" <<'EOF'
# impersonate-audit configuration — plain KEY=VALUE, sourced by the wrapper.
# Uncomment and edit what you want to change. Anything exported in your shell
# (csm.env, ~/.zshrc, ...) wins over this file.
# Where per-account impersonation folders live.
# CSM_IMPERSONATE_HOME="$HOME/impersonate"
# Where your per-account notes live, if you keep any. The audit skill reads the
# matching account's notes from here before writing findings.
# Defaults to "$HOME/Documents/Obsidian Vault/Customers" when that directory
# exists. If it doesn't and you don't set this, the skill skips notes entirely
# rather than hunting around your filesystem.
# CSM_CUSTOMER_CONTEXT_DIR="$HOME/notes/customers"
EOF
echo "Created config: $CONFIG_FILE"
fi
# shellcheck source=/dev/null
source "$CONFIG_FILE"
if [[ -n "$ENV_HOME" ]]; then
CSM_IMPERSONATE_HOME="$ENV_HOME"
fi
if [[ -n "$CLI_HOME" ]]; then
CSM_IMPERSONATE_HOME="$CLI_HOME"
fi
if [[ -n "$ENV_CONTEXT_DIR" ]]; then
CSM_CUSTOMER_CONTEXT_DIR="$ENV_CONTEXT_DIR"
fi
CSM_IMPERSONATE_HOME="${CSM_IMPERSONATE_HOME:-$HOME/impersonate}"
CSM_IMPERSONATE_HOME="${CSM_IMPERSONATE_HOME%/}"
# Unset falls back to the old Obsidian location, but only when it's really
# there — otherwise it stays empty and the skill skips the notes step.
CSM_CUSTOMER_CONTEXT_DIR="${CSM_CUSTOMER_CONTEXT_DIR:-}"
if [[ -z "$CSM_CUSTOMER_CONTEXT_DIR" && -d "$DEFAULT_CONTEXT_DIR" ]]; then
CSM_CUSTOMER_CONTEXT_DIR="$DEFAULT_CONTEXT_DIR"
fi
CSM_CUSTOMER_CONTEXT_DIR="${CSM_CUSTOMER_CONTEXT_DIR%/}"
if [[ "$CSM_IMPERSONATE_HOME" == "$HOME" ]]; then
echo "Error: CSM_IMPERSONATE_HOME cannot be your home directory." >&2
echo " Claude Code won't share one settings file from there. Pick a subfolder." >&2
exit 1
fi
# ----------------------------------------------------------------- helpers
# Lowercase; accents folded to their base letter; every run of non-alphanumeric
# characters becomes a single hyphen. "Café Söda, Inc." -> cafe-soda-inc
slugify() {
local s="$1" folded
# Decompose then drop the combining marks (macOS); fall back to iconv's own
# transliteration (glibc); fall back to leaving the string alone.
if folded="$(printf '%s' "$s" | iconv -f UTF-8 -t UTF-8-MAC 2>/dev/null | LC_ALL=C sed $'s/[\xcc\xcd][\x80-\xbf]//g')" && [[ -n "$folded" ]]; then
s="$folded"
elif folded="$(printf '%s' "$s" | iconv -f UTF-8 -t ASCII//TRANSLIT 2>/dev/null)" && [[ -n "$folded" ]]; then
s="$folded"
fi
printf '%s' "$s" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
}
# Merge $1 (template or legacy settings) into $2, preserving anything Claude
# Code learned in $2. Entries the template moved between allow/ask follow the
# template. Without jq we leave an existing file alone rather than clobber it.
merge_settings() {
local src="$1" dst="$2" tmp
if [[ ! -f "$src" ]]; then
return 1
fi
mkdir -p "$(dirname "$dst")"
if [[ ! -f "$dst" ]]; then
cp "$src" "$dst"
return 0
fi
if ! command -v jq >/dev/null 2>&1; then
echo "⚠️ jq not found — leaving $dst as-is. Use --reset-settings to overwrite it." >&2
return 0
fi
tmp="$(mktemp "${dst}.XXXXXX")"
if jq -s '
.[0] as $tpl | .[1] as $cur
| (($cur.permissions.allow // []) - ($tpl.permissions.ask // []) + ($tpl.permissions.allow // []) | unique) as $allow
| (($cur.permissions.ask // []) - ($tpl.permissions.allow // []) + ($tpl.permissions.ask // []) | unique) as $ask
| $cur * { permissions: (($cur.permissions // {}) + { allow: $allow, ask: $ask }) }
' "$src" "$dst" > "$tmp"; then
mv "$tmp" "$dst"
else
rm -f "$tmp"
echo "⚠️ Could not merge permissions into $dst (invalid JSON?). Left untouched." >&2
fi
}
confirm() {
local answer
read -r -p "$1 [y/N] " answer
[[ "$answer" =~ ^[Yy] ]]
}
# Rename $1 to $2. Returns 2 on a genuine collision. On case-insensitive
# filesystems "InitechCo" and "initechco" are the same path, so a case-only
# rename has to bounce through a temporary name.
rename_path() {
local from="$1" to="$2" tmp
if [[ "$from" == "$to" ]]; then
return 0
fi
if [[ -e "$to" ]]; then
if [[ ! "$from" -ef "$to" ]]; then
return 2
fi
tmp="${from}.rename-$$"
mv "$from" "$tmp"
mv "$tmp" "$to"
return 0
fi
mv "$from" "$to"
}
# ------------------------------------------------------------ settings root
# Claude launches at the impersonation root, so one .claude/settings.local.json
# there covers every account — including the permissions you approve
# mid-session. Account folders are output, not workspaces.
#
# One wrinkle: if the root sits inside a git repo (someone keeping audits in
# their notes repo, say), Claude Code resolves project settings to that repo's
# root instead. Follow it rather than writing a file that would be ignored.
SETTINGS_ROOT="$CSM_IMPERSONATE_HOME"
GIT_TOP=""
mkdir -p "$CSM_IMPERSONATE_HOME"
if command -v git >/dev/null 2>&1; then
if GIT_TOP="$(git -C "$CSM_IMPERSONATE_HOME" rev-parse --show-toplevel 2>/dev/null)" \
&& [[ -n "$GIT_TOP" && "$GIT_TOP" != "$HOME" ]]; then
SETTINGS_ROOT="$GIT_TOP"
fi
fi
# ---------------------------------------------------------------- migration
# Fold a legacy layout into the current one: "Acme Inc" -> acme-inc,
# audit-2026-08-11.md -> 2026-08-11.md, per-account permissions -> shared file.
migrate_all() {
local entry base slug target legacy_settings moved=0 renamed=0 folded=0
shopt -s nullglob
for entry in "$CSM_IMPERSONATE_HOME"/*/; do
entry="${entry%/}"
base="$(basename "$entry")"
[[ "$base" == ".claude" ]] && continue
slug="$(slugify "$base")"
target="$CSM_IMPERSONATE_HOME/$slug"
if [[ "$base" != "$slug" ]]; then
if rename_path "$entry" "$target"; then
echo " move $base -> $slug"
entry="$target"
moved=$((moved + 1))
else
echo " skip $base -> $slug (a different folder already uses that name)"
fi
fi
local audit
for audit in "$entry"/audit-*.md; do
local newname
newname="$(basename "$audit")"
newname="${newname#audit-}"
if rename_path "$audit" "$entry/$newname"; then
echo " file $(basename "$entry")/$(basename "$audit") -> $newname"
renamed=$((renamed + 1))
else
echo " skip $(basename "$audit") -> $newname (target already exists)"
fi
done
legacy_settings="$entry/.claude/settings.local.json"
if [[ -n "$SETTINGS_ROOT" && -f "$legacy_settings" && "$entry" != "$SETTINGS_ROOT" ]]; then
merge_settings "$legacy_settings" "$SETTINGS_ROOT/.claude/settings.local.json"
rm -f "$legacy_settings"
rmdir "$entry/.claude" 2>/dev/null || true
echo " perms $(basename "$entry")/.claude/settings.local.json -> shared file"
folded=$((folded + 1))
fi
done
shopt -u nullglob
echo
echo "Migrated: $moved folder(s) renamed, $renamed audit file(s) renamed, $folded permissions file(s) folded in."
}
if [[ "$MODE" == "migrate" ]]; then
echo "Migrating $CSM_IMPERSONATE_HOME to the current layout."
echo "Folders become lowercase-hyphenated, audit-<date>.md becomes <date>.md, and"
echo "per-account permissions merge into the shared file at the root."
echo
if confirm "Proceed?"; then
migrate_all
else
echo "Nothing changed."
fi
exit 0
fi
# --------------------------------------------------------------- run set-up
if [[ -z "$ACCOUNT_NAME" ]]; then
usage >&2
exit 1
fi
ACCOUNT_SLUG="$(slugify "$ACCOUNT_NAME")"
if [[ -z "$ACCOUNT_SLUG" ]]; then
echo "Error: '$ACCOUNT_NAME' has no usable characters for a folder name." >&2
exit 1
fi
ACCOUNT_DIR="$CSM_IMPERSONATE_HOME/$ACCOUNT_SLUG"
# Offer to pick up a pre-slug folder for this same account rather than starting
# an empty one next to it. Case-only differences count: on a case-insensitive
# filesystem "InitechCo" already answers to "initechco", but the folder still
# shows up mixed-case until it's renamed.
shopt -s nullglob
for candidate in "$CSM_IMPERSONATE_HOME"/*/; do
candidate="${candidate%/}"
if [[ "$(basename "$candidate")" != "$ACCOUNT_SLUG" && "$(slugify "$(basename "$candidate")")" == "$ACCOUNT_SLUG" ]]; then
echo "Found an older folder for this account: $(basename "$candidate")"
if confirm "Rename it to '$ACCOUNT_SLUG'?"; then
if rename_path "$candidate" "$ACCOUNT_DIR"; then
echo "Renamed. (Run --migrate to normalise every other account too.)"
else
echo "⚠️ A different folder already uses '$ACCOUNT_SLUG' — left as-is." >&2
fi
fi
break
fi
done
shopt -u nullglob
mkdir -p "$ACCOUNT_DIR"
SETTINGS_FILE="$SETTINGS_ROOT/.claude/settings.local.json"
# A leftover per-account permissions file from the old layout is dead weight now
# that settings live at the root — fold whatever it learned into the shared
# file so those approvals aren't lost, then get rid of it.
LEGACY_SETTINGS="$ACCOUNT_DIR/.claude/settings.local.json"
if [[ "$SETTINGS_ROOT" != "$ACCOUNT_DIR" && -f "$LEGACY_SETTINGS" ]]; then
merge_settings "$LEGACY_SETTINGS" "$SETTINGS_FILE"
rm -f "$LEGACY_SETTINGS"
rmdir "$ACCOUNT_DIR/.claude" 2>/dev/null || true
echo "Folded this account's old permissions file into the shared one."
fi
if [[ ! -f "$TEMPLATE_FILE" ]]; then
echo "⚠️ Settings template not found at $TEMPLATE_FILE — skipping permissions setup." >&2
elif ((RESET_SETTINGS)); then
mkdir -p "$(dirname "$SETTINGS_FILE")"
cp -f "$TEMPLATE_FILE" "$SETTINGS_FILE"
echo "Permissions reset from the plugin template: $SETTINGS_FILE"
else
merge_settings "$TEMPLATE_FILE" "$SETTINGS_FILE"
fi
AUDIT_FILE="$ACCOUNT_DIR/$(date +%Y-%m-%d).md"
# Derived from the account name you passed — none of these are yours to set.
# The audit skill reads them instead of guessing paths or searching for notes.
export CSM_IMPERSONATE_HOME
export CSM_IMPERSONATE_ACCOUNT="$ACCOUNT_SLUG"
export CSM_IMPERSONATE_ACCOUNT_NAME="$ACCOUNT_NAME"
export CSM_IMPERSONATE_ACCOUNT_DIR="$ACCOUNT_DIR"
export CSM_IMPERSONATE_AUDIT_FILE="$AUDIT_FILE"
export CSM_CUSTOMER_CONTEXT_DIR
# Session ID lets you correlate refusal-log entries with the raw-error debug
# log — `jq 'select(.session_id == "...")'` across both files.
if command -v uuidgen >/dev/null 2>&1; then
CSM_IMPERSONATE_SESSION_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
else
CSM_IMPERSONATE_SESSION_ID="$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
fi
export CSM_IMPERSONATE_SESSION_ID
# Cross-session refusal log. The skill appends one JSONL entry when it can't
# complete an audit — grep / jq across it to see what's failing over time.
CSM_IMPERSONATE_REFUSAL_LOG="${CSM_IMPERSONATE_REFUSAL_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/impersonate-audit/refusals.log}"
mkdir -p "$(dirname "$CSM_IMPERSONATE_REFUSAL_LOG")"
export CSM_IMPERSONATE_REFUSAL_LOG
# Raw-error debug log. Verbose companion to refusals.log — the skill appends
# raw MCP tool responses, HTTP status, and error class here. Cross-reference
# via session_id.
CSM_IMPERSONATE_DEBUG_LOG="${CSM_IMPERSONATE_DEBUG_LOG:-${XDG_STATE_HOME:-$HOME/.local/state}/impersonate-audit/debug.log}"
mkdir -p "$(dirname "$CSM_IMPERSONATE_DEBUG_LOG")"
export CSM_IMPERSONATE_DEBUG_LOG
# Deterministic redactor. Free-text fields written to either log are piped
# through this before write, so token-shaped strings can't survive to disk
# even if the skill mis-classifies them. Ships with the plugin.
CSM_IMPERSONATE_REDACT="${CSM_IMPERSONATE_REDACT:-$SCRIPT_DIR/redact.sh}"
export CSM_IMPERSONATE_REDACT
cd "$CSM_IMPERSONATE_HOME"
cat <<EOF
============================================================
Impersonation audit — $ACCOUNT_NAME
Working dir: $CSM_IMPERSONATE_HOME
Folder: $ACCOUNT_SLUG/ (notes, exports, scratch files)
Audit: $ACCOUNT_SLUG/$(basename "$AUDIT_FILE")
Perms: $SETTINGS_FILE
Session: $CSM_IMPERSONATE_SESSION_ID
Refusals: $CSM_IMPERSONATE_REFUSAL_LOG
Debug log: $CSM_IMPERSONATE_DEBUG_LOG
Redactor: $CSM_IMPERSONATE_REDACT
EOF
if [[ -n "$CSM_CUSTOMER_CONTEXT_DIR" ]]; then
echo " Notes: $CSM_CUSTOMER_CONTEXT_DIR"
fi
cat <<EOF
============================================================
STEP 1: Impersonate the customer's user in Django Admin
- Open PostHog Django Admin in your browser
- Find the user → click "Impersonate" → give a reason
- Read-only is the default and is what an audit needs. PostHog strips
write scopes from the token it mints and rejects mutating requests,
so nothing this session does can change their project.
- Keep that browser tab open through the whole audit
EOF
read -r -p "Press enter once impersonation is active in your browser..." _
cat <<EOF
STEP 2: Re-authenticate the posthog plugin in Claude Code
- I'll launch Claude next, in $CSM_IMPERSONATE_HOME
(audit output lands in $ACCOUNT_SLUG/)
- Inside Claude Code, type: /mcp
- Find 'posthog' → "Clear authentication" → then "Authenticate"
- A browser tab opens with the PostHog OAuth consent screen. Pick:
· Scope: read-only (an audit reads; it never writes)
· Organization: the customer's org
· Project: the customer's project
Your Django impersonation session already scopes the token read-only
regardless of what you click here — the point is to avoid building
muscle memory of granting write scopes while impersonating.
- Takes ~10 seconds.
- Sanity check: ask Claude "what project am I in?" — you should see
the customer's project, NOT 'PostHog App + Website' (id 2). If you
see project 2, the impersonation didn't carry — repeat /mcp re-auth
with the impersonation tab actively focused.
STEP 3: Run the audit
- Once the project check passes, just say:
"audit $ACCOUNT_NAME's experiment named '<experiment name>'"
or, for the whole instance:
"run an account audit on $ACCOUNT_NAME"
- The audit skill auto-loads and runs the playbook.
EOF
read -r -p "Press enter to launch Claude Code..." _
# Make sure the plugin is enabled before launching (it may have been
# disabled at the end of a previous run)
claude plugin enable posthog >/dev/null 2>&1 || true
claude || true
echo
echo "============================================================"
echo " Teardown"
echo "============================================================"
echo
if [[ -f "$AUDIT_FILE" ]]; then
echo "📄 Audit written to $AUDIT_FILE"
echo
fi
PLUGIN_STATUS=$(claude mcp list 2>/dev/null | grep "plugin:posthog" | head -1 || true)
if echo "$PLUGIN_STATUS" | grep -q "Connected"; then
echo "⚠️ Impersonation MCP is still connected. Disabling now..."
echo
claude plugin disable posthog
echo
echo "✅ Plugin disabled."
echo
echo "One thing left for you: log out of Django Admin in your browser."
else
echo "✅ Impersonation MCP is already disconnected."
echo
echo "One thing left for you: log out of Django Admin in your browser."
fi
echo
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