skills/posthog/SKILL.md
--- name: posthog description: Interact with PostHog (EU region) — install SDKs, query events, manage feature flags, run experiments, inspect insights. Use when user wants to track events, add analytics, create/toggle a feature flag, set up an A/B test, query product data, or wire PostHog into a TanStack Start app. category: analytics argument-hint: [install | flag <list|get|create|toggle> | experiment <list|get> | query <hogql> | event <recent> | render-debug-snippet] [--project <id>] [--region
npx skillsauth add RonanCodes/ronan-skills skills/posthogInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
CLI-first PostHog ops via the public API (EU region, eu.posthog.com). Covers SDK install, feature flags, experiments, event queries, and HogQL.
/ro:posthog install [--react|--node|--both] # wire SDK into current app
/ro:posthog flag list # list all feature flags
/ro:posthog flag get <key>
/ro:posthog flag create <key> --rollout 50 # new boolean flag at 50%
/ro:posthog flag toggle <key> # enable/disable
/ro:posthog experiment list
/ro:posthog experiment get <id>
/ro:posthog query "SELECT event, count() FROM events GROUP BY event LIMIT 20"
/ro:posthog event recent [--event <name>] # tail recent events
~/.claude/.env:
POSTHOG_PERSONAL_API_KEY — all-access, for management APIPOSTHOG_HOST=https://eu.posthog.com — management API hostPOSTHOG_INGEST_HOST=https://eu.i.posthog.com — SDK ingest host--project <id> or POSTHOG_PROJECT_ID env var (numeric — look up via list projects below)Personal vs Simplicity (pick the right key). For ronanconnolly personal apps (souso, side projects) use
POSTHOG_PERSONAL_API_KEY_RONAN(phx_…, added 2026-06). The barePOSTHOG_PERSONAL_API_KEY/POSTHOG_PROJECT_KEYand_DATAFORCEvariants are the Simplicity/Dataforce org, don't mix personal-app analytics in. NOTE:POSTHOG_PERSONAL_API_KEY_RONANis a personal API key (management/queries), NOT the browser ingest key. Fetch the project'sphc_ingest key via the API (GET ${POSTHOG_HOST}/api/projects/→ the project'sapi_token) before wiringposthog-js.
pnpm add posthog-js
Create src/lib/posthog.ts. Default is max-data capture (replays, heatmaps, exceptions, performance, network bodies). For apps with auth or PII, see the "Safety" section below for the dial-down.
import posthog from "posthog-js";
if (typeof window !== "undefined") {
posthog.init(import.meta.env.VITE_POSTHOG_PROJECT_API_KEY, {
api_host: import.meta.env.VITE_POSTHOG_INGEST_HOST,
person_profiles: "identified_only",
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
disable_session_recording: false,
session_recording: {
maskAllInputs: false,
recordCrossOriginIframes: true,
},
enable_heatmaps: true,
capture_performance: true,
capture_exceptions: true,
});
}
export { posthog };
Expose in Vite env (.dev.vars):
VITE_POSTHOG_PROJECT_API_KEY=phc_...
VITE_POSTHOG_INGEST_HOST=https://eu.i.posthog.com
The project API key (phc_..., NOT the personal key) lives per-app — generate at https://eu.posthog.com/project/<id>/settings.
The SDK flags above pair with the project-level server switches below. Both sides need to be on, or the data won't flow.
Client SDK says "send this data"; project settings say "accept it". Flip both on install:
curl -s -X PATCH "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"session_recording_opt_in": true,
"capture_console_log_opt_in": true,
"capture_performance_opt_in": true,
"autocapture_opt_out": false,
"autocapture_exceptions_opt_in": true,
"heatmaps_opt_in": true,
"surveys_opt_in": true,
"session_recording_sample_rate": "1.00",
"session_recording_minimum_duration_milliseconds": 0,
"session_recording_network_payload_capture_config": {"recordHeaders": true, "recordBody": true}
}'
This is the default for new apps. Tone it down (see Safety) when adding auth, payments, or high traffic.
pnpm add posthog-node
// src/lib/posthog-server.ts
import { PostHog } from "posthog-node";
export const posthog = new PostHog(process.env.POSTHOG_PROJECT_API_KEY!, {
host: process.env.POSTHOG_INGEST_HOST ?? "https://eu.i.posthog.com",
});
Important: call await posthog.shutdown() at the end of each server function (Workers terminates quickly; unflushed events are lost).
Autocapture + pageviews are free, but custom events are what make the launch plan measurable. When /ro:posthog install runs, also wire a typed track() wrapper and fire events on the user paths that actually drive launch metrics.
track() wrapper (always ship this)Autocapture misses anything behind a handler (e.g. window.open(...)), and direct posthog.capture calls have no type safety — a string typo silently creates a new event. Fix with a typed wrapper:
// src/lib/posthog.ts
export interface EventPayloads {
share: {
channel: 'x' | 'reddit' | 'whatsapp' | 'copy' | 'native'
// Include the content identifier so launch funnels can slice by item:
item_id: string | number | null
item_date?: string
}
cta_clicked: { cta: string; location: string }
form_submitted: { form: string; success: boolean }
search_performed: { query_length: number; results: number }
// ...app-specific events below
}
export function track<K extends keyof EventPayloads>(
event: K,
...props: EventPayloads[K] extends undefined ? [] : [EventPayloads[K]]
): void {
if (typeof window === 'undefined') return
posthog.capture(event, props[0] as Record<string, unknown> | undefined)
}
Why a wrapper and not direct posthog.capture:
EventPayloads map catches event-name typos at compile time.track() is a no-op until init resolves, so handlers can call it unconditionally.grep "track('share'" src finds every call site in a second. Compare with posthog.capture which is used across many libs.Every consumer-facing launch needs these events instrumented before the launch day, not after. Missing events on day-one means the launch analytics story is blind when it matters most.
| Event | Fire on | Payload | Why it matters |
| --- | --- | --- | --- |
| share | Every share handler (native, X, Reddit, WhatsApp, copy-link, etc.) | { channel, item_id } | Tells you which channel drives the viral loop. Autocapture misses these because they're window.open calls. |
| cta_clicked | Every primary button (sign up, buy, launch app, follow, install) | { cta, location } | Funnel foundation. |
| form_submitted | Every form | { form, success } | Conversion leaf. |
| search_performed | Every search box | { query_length, results } | Learn what users can't find. Do NOT log the raw query unless no-PII policy says it's safe. |
| content_viewed | Content page open (blog, doc, product) | { type, slug } | Compounds into "which content converts." |
| feature_used | Each distinctive feature action | { feature, ...context } | The event that lets you say "X% of users hit this feature." |
| error_shown | Every user-visible error state | { error, boundary } | Complements Sentry — catches UX errors that aren't exceptions. |
Project-specific additions (pattern: a verb + an object, present tense, snake_case):
hint_revealed, detail_expanded, source_toggled, date_changed.product_viewed, added_to_cart, checkout_started, checkout_completed.workspace_created, invite_sent, onboarding_step_completed.snake_case, present-tense verbs with the object. share not shared or ShareClick.puzzle_id not puzzleId. PostHog's insight UI autocompletes snake_case properties better and matches HogQL conventions.query_length not query. Never put emails, UUIDs tied to PII, or free-text input in an event.checkout_completed_v2) rather than mutating history.# Sanity-check ingest right before go-live (eu.posthog.com, per org default):
/ro:posthog event recent --event share
/ro:posthog query "SELECT event, count() FROM events WHERE timestamp > now() - INTERVAL 1 HOUR GROUP BY event ORDER BY count() DESC"
If a planned event doesn't appear, the handler isn't wired. Fix before launch day — debugging share-funnel attribution on the traffic spike is miserable.
In PostHog UI, spin up these three insights before launch, not after:
share, group by channel, 7-day window. Tells you where the viral loop is actually working.$pageview by day.$pageview → (first feature_used) → share or cta_clicked. Conversion rate + drop-off steps.All calls go to ${POSTHOG_HOST}/api/projects/<project-id>/feature_flags/ with Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}.
curl -s "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/feature_flags/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
| jq '.results[] | {key, active, rollout_percentage: .filters.groups[0].rollout_percentage}'
curl -s -X POST "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/feature_flags/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"key": "new-checkout",
"name": "New checkout flow",
"active": true,
"filters": { "groups": [{ "properties": [], "rollout_percentage": 50 }] }
}'
# First GET to find the id, then PATCH:
curl -s -X PATCH "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/feature_flags/${FLAG_ID}/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"active": false}'
Pass variants in filters.multivariate.variants. See experiments below.
curl -s "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/experiments/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
| jq '.results[] | {name, feature_flag_key, start_date, end_date, parameters}'
Creating via API is possible but the dashboard is cleaner for setup; use API for monitoring (win probability, conversion deltas).
PostHog's SQL-like layer over events:
curl -s -X POST "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/query/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"query": {
"kind": "HogQLQuery",
"query": "SELECT event, count() FROM events WHERE timestamp > now() - INTERVAL 24 HOUR GROUP BY event ORDER BY count() DESC LIMIT 20"
}
}' | jq '.results'
curl -s "${POSTHOG_HOST}/api/projects/${PROJECT_ID}/events/?limit=10" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
| jq '.results[] | {event, timestamp, distinct_id, properties: (.properties | {"$current_url", "$lib"})}'
curl -s "${POSTHOG_HOST}/api/projects/" \
-H "Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}" \
| jq '.results[] | {id, name, organization}'
⚠️ Free-plan project cap. PostHog's free plan caps projects per org (6 at time of writing). Creating one past the cap returns 403 permission_denied (hit on Settle 2026-06-07, org was 6/6). When wiring a new app: list projects first; if at the cap, either free a slot (delete an unused project), upgrade, or reuse an existing project (last resort — mingles analytics, only if the apps genuinely share an audience). The client wiring can ship dormant: posthog-js no-ops until VITE_PUBLIC_POSTHOG_KEY is set, so create the project and flip the key whenever a slot frees up, no code change.
Emit a Markdown-ready PostHog debug-info block for pasting into a bug report or GitHub issue. Designed to be called by other skills (e.g. /ro:gh-ship, a future bug-report skill) so they don't have to remember the URL shapes.
/ro:posthog render-debug-snippet --project 173014 --region eu [--session-id <id>]
Resolution order for --project and --region:
.ronan-skills.local.json in the cwd: { "posthog": { "project_id": "...", "region": "us|eu" } }..env / .dev.vars: POSTHOG_PROJECT_ID, POSTHOG_REGION.Output shape (--session-id omitted → leaves <session-id> as a placeholder for the operator to fill in):
Session: https://us.posthog.com/project/sTMFPsFhdP1Ssg/replay/<session-id>?t=<seconds>
Admin: http://go/adminOrgEU/<org-id> (project ID 173014)
Notes:
Session: URL hardcodes the us.posthog.com/project/sTMFPsFhdP1Ssg/replay/ host because that's the replay UI's stable address regardless of which region the data lives in — PostHog renders EU replays through the US UI.Admin: URL uses Ronan's go-link short URL when --region eu; for --region us switch to http://go/adminOrgUS/<org-id>.Global (~/.claude/.env):
POSTHOG_PERSONAL_API_KEY — for this skill's management callsPOSTHOG_SIMPLICITY_LABS_API_KEY — org-scoped, can substitute for some opsPOSTHOG_HOST — https://eu.posthog.comPOSTHOG_INGEST_HOST — https://eu.i.posthog.comPer-app (.dev.vars + wrangler secret):
POSTHOG_PROJECT_API_KEY (phc_...) — client-side SDK initVITE_POSTHOG_PROJECT_API_KEY + VITE_POSTHOG_INGEST_HOSTRonan's org is on the EU region. Do not use us.posthog.com or app.posthog.com — they'll 401. The skill hard-codes EU hosts in env for this reason.
POSTHOG_PERSONAL_API_KEY client-side. The client SDK only needs the project API key (phc_...), which is safe to ship.DELETE on a flag cannot be undone from the API. Prefer active: false over delete.The install default captures a lot on purpose (small side-project apps benefit from more signal). Dial back when:
session_recording.maskAllInputs: true. Default masks all input values; keeps passwords, emails, tokens out of replays.recordBody: true from the project config (re-PATCH with recordBody: false). Add ph-no-capture class to sensitive DOM nodes. Use maskTextSelector for card numbers etc.session_recording_sample_rate from "1.00" to "0.10" or less. Replays are the expensive product; 10% sampling still catches issues.capture_performance_opt_in and capture_console_log_opt_in to false if consoles might leak tokens; review autocapture for PII-bearing form fields./ro:sentry — the other half of observability/ro:new-tanstack-app — scaffolds with posthog slot readytesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".