PostHog is Django + Vite + Celery + plugin-server, backed by Postgres, ClickHouse, Kafka, Redis, and Temporal in Docker, fronted by an Envoy-style proxy at http://localhost:8010. The dev stack runs in detached mode under phrocs so the running processes are inspectable from this session via the phrocs MCP server. Browser MCP servers (chrome-devtools-mcp, playwright) drive the UI; nothing about this skill ships its own driver.
For /run: get the app reachable so the user can drive it. Success = http://localhost:8010 serves and the core units are ready. Do not chase crashed migration units, do not seed data unless asked.
For /verify: build (Vite HMR handles this automatically for frontend; backend changes need no build), run the same launch as /run, 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.
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.env.localcontainsop://refs
Launch
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 # wait for phrocs-managed units (Django, Vite, Celery, plugin-server)
hogli doctor # optional: stale migrations, zombie phrocs, disk pressure
First boot is 60-90s while Django imports and Vite warms. Stop with hogli down -y (leaves Docker services running for fast restart).
Is the stack ready?
There are two readiness bars. Don't conflate them.
Ready for /run — the app is reachable and you can drive it:
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
Plus these phrocs units ready:true: backend, frontend, nodejs, capture, ingestion. Stop here. Do not chase crashed migrate-* units when only /run was asked — the stack is usable for launch, screenshot, and most UI scenes (home, login, settings, feature flags) regardless of migration state.
Ready for /verify of HogQL-backed scenes (insights, dashboards, web analytics) and for POST /api/setup_test/...:
- Also requires
mcp__phrocs__get_process_status process="migrate-clickhouse"to showstatus:"done" exit_code:0. If it'scrashed, see the gotcha below.
phrocs MCP tools — for either bar:
mcp__phrocs__get_process_status(no args) — all units, withready,exit_code, 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 runphrocs stop && hogli up -dfor 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 DEBUG=True. Implementation: posthog/api/playwright_setup.py + posthog/test/playwright_setup_functions.py:create_organization_with_team — 3 clusters via HedgeboxMatrix, ~5-10s end to end. Reference call site: playwright/utils/playwright-setup.ts:251.
Avoid hogli dev:demo-data for /run and /verify — it calls generate_demo_data with n_clusters=500 (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:
new_pagehttp://localhost:8010/login.evaluate_scriptthe workspace bootstrap. Per-call email so reruns don't collide; password fixed at12345678; response gives back the team_id and a personal API key.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 resultevaluate_scriptthe in-page login. Runs in the page context so Django's CSRF middleware sees the right cookies (playwright/utils/playwright-setup.ts:282-291).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 usernavigate_pagetohttp://localhost:8010/project/{team_id}{scene-path}.wait_fortext or a[data-attr]element. PostHog attachesdata-attrto virtually every rendered element; use it as the "page hydrated" signal.networkidleandloadnever settle because PostHog.js and Vite HMR keep polling.take_screenshot. The accessibility snapshot fromwait_foris 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 personal_api_key from the setup_test response as Authorization: Bearer <key> — no login dance.
For deeper interaction (clicking, filling forms, inspecting console), the same MCP toolkit (chrome-devtools-mcp:* / playwright:*) 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 /project/{team_id}/) |
|---|---|
frontend/src/scenes/<name>/** |
usually /<name> (e.g. insights/ → /insights) |
frontend/src/scenes/activity/** |
/activity/explore (and other ActivityTabs) |
frontend/src/scenes/data-management/** |
/data-management/<sub> |
frontend/src/scenes/settings/** |
/settings/<section> |
frontend/src/scenes/authentication/** |
/login, /signup, /preflight (un-scoped) |
products/<name>/frontend/** |
per the urls: block in products/<name>/manifest.tsx |
frontend/src/layout/** |
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 (urls.insights(),urls.dashboard(...), etc.).frontend/src/scenes/scenes.ts— scene-to-route registry.products/<name>/manifest.tsx— per-product routes.
Gotchas
migrate-clickhouseandmigrate-persons-dboften crash on a coldhogli up -ddue to a startup race. They start in parallel withmigrate-postgres, and if Postgres isn't ready yet they crash. This is the hard prereq forPOST /api/setup_test/...and for HogQL-backed scenes (insights, dashboards, web analytics) — but not for/run. Don't fix it unless the task needs HogQL/setup_test. When you do need to fix it, the canonical sequence is: wait formigrate-postgresto showstatus:"done"viamcp__phrocs__get_process_status, then restart the crashed migrations.mcp__phrocs__toggle_processis the surgical tool but auto-mode blocks it on shared stacks — fall back tophrocs stop && hogli up -dwhich re-runs everything in order, or runpython manage.py migrate_clickhousedirectly (you'll needset -a; source .env.services; set +afirst soCLICKHOUSE_DATABASE=posthog, otherwise it targetsdefault). If neither works (corrupted CH replica state in ZooKeeper from a partial run),hogli dev:resetis the only path — it wipes Docker volumes, destructive.hogli waitexits 0 even when phrocs is unreachable. Don't trust its return code as ground truth — confirm withmcp__phrocs__get_process_statusor thecurlprobes.- Vite serves on
:8234, not the URL you browse. You browsehttp://localhost:8010(the Envoy-style proxy). The proxy reverse-proxies Vite for/static/*and Django for everything else. Hitting:8234/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
posthog-clickhouse-1/posthog-db-1containers, 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 beforehogli up -dhere. - CSP warnings and 401s in the browser console are normal pre-auth. The preflight/login page tries to fetch
/api/projects/@current,/api/users/@me/, and PostHog.js remote config — all 401 until you sign up. WASM/CSP "Report Only" warnings come from the dev CSP. - Direct
curl -X POST /api/login/returns 403 (CSRF). Session login must run in-page viabrowser_evaluateso cookies and CSRF tokens flow. For non-browser API calls, use thepersonal_api_keyfrom thesetup_testresponse asAuthorization: Bearer <key>— no CSRF on token auth. .env.localmay use 1Password refs. Withoutopinstalled, refs become literal strings (e.g.OPENAI_API_KEY=op://...) and downstream services fail with cryptic auth errors. Installopor replace with literals.
Troubleshooting
hogli up -dexits withAnother instance of bin/start is already running— previous run still active or crashed without cleanup.mcp__phrocs__get_process_statusshows what's there; if nothing's running,rm bin/start.lockand retry.docker infofails withdial unix /Users/<you>/.orbstack/run/docker.sock: ... no such file— OrbStack is stopped.open -a OrbStack./api/projects/@currentreturns 500 instead of 401 — Postgres or ClickHouse unreachable.docker ps | grep posthog-and look for unhealthy containers;hogli services:readywaits for all of them.POST /api/login/returns 400invalid_credentials— you're logging in as a user thesetup_testworkspace didn't actually create (CH crash usually). Check the response of the setup_test call; if it 500'd, fix CH first (see themigrate-clickhousegotcha).POST /api/setup_test/organization_with_team/returns 404 —DEBUG,E2E_TESTING,CI, andTESTare all false. Local dev hasDEBUG=Trueby default; if it's not set,.env.localis missing orDJANGO_SETTINGS_MODULEpoints at a prod-like settings module.POST /api/setup_test/organization_with_team/returns 500Table posthog.person does not exist—migrate-clickhousecrashed during boot. See the gotcha above.- In-page
fetch('/api/login/')returns 403 — you're calling it from outside the page context (e.g.page.request.postrather thanpage.evaluate). Useevaluate_script/browser_evaluateso the call originates in-page.