PostHog is Django + Vite + Celery + plugin-server, backed by Postgres, ClickHouse, Kafka, Redis, and Temporal in Docker, fronted by an Envoy-style proxy at
. The dev stack runs in detached mode under
so the running processes are inspectable from this session via the
MCP server. Browser MCP servers (
,
) drive the UI; nothing about this skill ships its own driver.
For : get the app reachable so the user can drive it. Success =
serves and the core units are
. Do not chase crashed migration units, do not seed data unless asked.
For : build (Vite HMR handles this automatically for frontend; backend changes need no build), run the same launch as
, then drive the live app via a browser MCP to observe the change. The observation is the verification — tests and type checks don't substitute. For frontend/UI changes, the deep version of this workflow is the repo-local
skill in local mode: scenario design from the diff, reproducibility retries, and annotated evidence.
All paths below are relative to the repo root.
Prerequisites
- flox 1.12+ provisions the toolchain —
curl -L https://downloads.flox.dev/by-env/stable/install.sh | sudo bash
- Docker — OrbStack preferred on macOS (
brew install --cask orbstack
)
- 1Password CLI (optional) —
brew install 1password-cli
, only if contains refs
Launch
For a normal developer stack, use the saved interactive setup:
bash
hogli dev:setup # first time only — interactive wizard picks which workers to run
hogli up -d -y # start the stack detached under phrocs
hogli services:ready -y # wait for Docker services (Postgres, CH, Kafka, Redis)
hogli wait -y # broad all-process signal; confirm with the checks below
hogli doctor # optional: stale migrations, zombie phrocs, disk pressure
For agent-managed QA, use the normal background stack unless the user asks to tune services. On a devbox, prefer
hogli devbox:start --start-app
for a new/stopped box; if it is already running without the app, start PostHog inside it:
bash
hogli devbox:exec -- bash -lc 'cd ~/posthog && ./bin/hogli up -d -y'
Use
,
hogli dev:explain <intent>
, and
hogli dev:list-units <intent>
only to understand route-specific dependencies; custom generated configs are not the default QA startup path.
First boot is 60-90s while Django imports and Vite warms. Stop with
(leaves Docker services running for fast restart).
Is the stack ready?
There are two readiness bars. Don't conflate them.
Ready for — the app is reachable and you can drive it:
bash
curl -sf http://localhost:8010/_health # 200
curl -sf -o /dev/null -w '%{http_code}' http://localhost:8010/ # 200 or 302
curl -sS http://localhost:8010/api/projects/@current | grep -q '"code":"not_authenticated"' # API + DB reachable
For UI QA,
and
are hard gates; add only units the route needs, such as
,
,
,
, or
. Stop here.
Do not chase crashed units when only was asked — the stack is usable for launch, screenshot, and most UI scenes (home, login, settings, feature flags) regardless of migration state.
Ready for of HogQL-backed scenes (insights, dashboards, web analytics) and for
:
- Also requires
mcp__phrocs__get_process_status process="migrate-clickhouse"
to show status:"done" exit_code:0
. If it's , see the gotcha below.
phrocs MCP tools — for either bar:
mcp__phrocs__get_process_status
(no args) — all units, with , , memory, CPU.
mcp__phrocs__get_process_status process="frontend"
— single unit.
mcp__phrocs__get_process_logs process="backend"
— tail recent stdout/stderr.
mcp__phrocs__toggle_process process="<unit>"
— restart one unit. Auto-mode blocks this on shared stacks (treated as restart-of-shared-infra). If blocked, ask the user to approve, or run phrocs stop && hogli up -d
for a full clean restart.
Drive the UI for /verify
Every empty PostHog scene looks broken because no events exist. To verify a UI change, you need a workspace with realistic data. The canonical path is the same one the Playwright suite uses:
POST /api/setup_test/organization_with_team/
. Gated on
DEBUG=True | E2E_TESTING | CI | TEST
, all of which local dev satisfies via
. Implementation:
posthog/api/playwright_setup.py
+
posthog/test/playwright_setup_functions.py:create_organization_with_team
— 3 clusters via
, ~5-10s end to end. Reference call site:
playwright/utils/playwright-setup.ts:251
.
Avoid
for /run and /verify — it calls
with
(default at
posthog/management/commands/generate_demo_data.py:59
) and takes 5-30 minutes. It exists for humans who want a big realistic dataset to play with; it's the wrong tool for automated launch-and-screenshot.
The full browser-MCP recipe:
-
http://localhost:8010/login
.
-
the workspace bootstrap. Per-call email so reruns don't collide; password fixed at
; response gives back the team_id and a personal API key.
js
const r = await fetch('/api/setup_test/organization_with_team/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: { skip_onboarding: true } }),
})
const { result } = await r.json()
// result: { user_email, team_id, personal_api_key, organization_id, ... }
return result
-
the in-page login. Runs in the page context so Django's CSRF middleware sees the right cookies (
playwright/utils/playwright-setup.ts:282-291
).
js
const r = await fetch('/api/login/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: workspace.user_email, password: '12345678' }),
})
return r.status // 200 = logged in; 403 = you're not in-page; 400 = workspace setup didn't actually create the user
-
to
http://localhost:8010/project/{team_id}{scene-path}
.
-
text or a
element. PostHog attaches
to virtually every rendered element; use it as the "page hydrated" signal.
and
never settle because PostHog.js and Vite HMR keep polling.
-
. The accessibility snapshot from
is also enough on its own for most "is this element present?" verifications — no pixel comparison needed.
For API-only calls (creating fixtures, hitting endpoints without a browser), use the
from the setup_test response as
Authorization: Bearer <key>
— no login dance.
For deeper interaction (clicking, filling forms, inspecting console), the same MCP toolkit (
/
) covers it. No project-specific wrapper needed.
For /verify: diff → URL mapping
The frontend uses kea-router. The mapping rule:
| Edited path | Scene URL (under ) |
|---|
frontend/src/scenes/<name>/**
| usually (e.g. → ) |
frontend/src/scenes/activity/**
| (and other s) |
frontend/src/scenes/data-management/**
| |
frontend/src/scenes/settings/**
| |
frontend/src/scenes/authentication/**
| , , (un-scoped) |
products/<name>/frontend/**
| per the block in products/<name>/manifest.tsx
|
| renders everywhere; verify on any scene |
frontend/src/lib/components/<X>.tsx
| grep usages, screenshot the scene that mounts it |
Canonical sources to grep when the path isn't obvious:
frontend/src/scenes/urls.ts
— top-level URL helpers (, , etc.).
frontend/src/scenes/scenes.ts
— scene-to-route registry.
products/<name>/manifest.tsx
— per-product routes.
Gotchas
- and often crash on a cold due to a startup race. They start in parallel with , and if Postgres isn't ready yet they crash. This is the hard prereq for and for HogQL-backed scenes (insights, dashboards, web analytics) — but not for . Don't fix it unless the task needs HogQL/. When you do need to fix it, the canonical sequence is: wait for to show via
mcp__phrocs__get_process_status
, then restart the crashed migrations. mcp__phrocs__toggle_process
is the surgical tool but auto-mode blocks it on shared stacks — fall back to phrocs stop && hogli up -d
which re-runs everything in order, or run python manage.py migrate_clickhouse
directly (you'll need set -a; source .env.services; set +a
first so CLICKHOUSE_DATABASE=posthog
, otherwise it targets ). If neither works (corrupted CH replica state in ZooKeeper from a partial run), is the only path — it wipes Docker volumes, destructive.
- is a coarse all-process signal. Confirm with process-specific phrocs status, the probes, and the target route before deciding the stack is unusable.
- Vite serves on , not the URL you browse. You browse (the Envoy-style proxy). The proxy reverse-proxies Vite for and Django for everything else. Hitting directly returns 404 because Vite has no index route at the dev-server root.
- Worktrees share Docker containers but compete for ports. All worktrees on the same machine resolve to the same / containers, so DB state is global. But ports 8000/8010/8234 can only be held by one worktree at a time — kill the granian/vite/phrocs of the other worktree before here.
- CSP warnings and 401s in the browser console are normal pre-auth. The preflight/login page tries to fetch , , and PostHog.js remote config — all 401 until you sign up. WASM/CSP "Report Only" warnings come from the dev CSP.
- Direct returns 403 (CSRF). Session login must run in-page via so cookies and CSRF tokens flow. For non-browser API calls, use the from the response as
Authorization: Bearer <key>
— no CSRF on token auth.
- may use 1Password refs. Without installed, refs become literal strings (e.g. ) and downstream services fail with cryptic auth errors. Install or replace with literals.
Troubleshooting
- exits with
Another instance of bin/start is already running
— previous run still active or crashed without cleanup. mcp__phrocs__get_process_status
shows what's there; if nothing's running, and retry.
- fails with
dial unix /Users/<you>/.orbstack/run/docker.sock: ... no such file
— OrbStack is stopped. .
- returns 500 instead of 401 — Postgres or ClickHouse unreachable.
docker ps | grep posthog-
and look for unhealthy containers; waits for all of them.
- returns 400 — you're logging in as a user the workspace didn't actually create (CH crash usually). Check the response of the setup_test call; if it 500'd, fix CH first (see the gotcha).
POST /api/setup_test/organization_with_team/
returns 404 — , , , and are all false. Local dev has by default; if it's not set, is missing or points at a prod-like settings module.
POST /api/setup_test/organization_with_team/
returns 500 Table posthog.person does not exist
— crashed during boot. See the gotcha above.
- In-page returns 403 — you're calling it from outside the page context (e.g. rather than ). Use / so the call originates in-page.