{"owner":"PostHog","repo":"posthog","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md",".cursorrules"],"files":{"AGENTS.md":"# PostHog Development Guide\n\n## Codebase Structure\n\n- Key entry points: `posthog/api/__init__.py` (API URL routing skeleton; products register their own routes in `products/<name>/backend/routes.py` via `register_routes(routers)`), `posthog/settings/web.py` (Django settings, INSTALLED_APPS), `products/` (product apps)\n- [Monorepo layout](docs/internal/monorepo-layout.md) - high-level directory structure (products, services, common, tools)\n- [Products README](products/README.md) - how to create and structure products\n- [Products architecture](products/architecture.md) - DTOs, facades, isolated testing\n\n## Commands\n\n- Environment:\n  - This is a full dev environment, not a restricted patch-editing sandbox — it has `node`, `pnpm`, a package mirror, and `apt`, so tools and dependencies that aren't present yet can be installed, and tests, Storybook, and the app can actually be run. A missing `node_modules`, browser binary, or flox usually just means setup hasn't run yet (`pnpm install`, `npx playwright install --with-deps chromium`, or building the nested `@posthog/quill` workspace that `global.scss` imports), rather than that running things is impossible.\n  - So the absence of a tool isn't evidence that a task can't be done — installing it is the first step. The honest signal that something genuinely can't run is an attempt that fails for a specific, nameable reason (no network access, `apt` unavailable, out of memory), which is worth reporting alongside whatever fallback you take.\n  - This matters most for visual and UX work, where reading the code isn't the same as seeing the result. Rendering the affected surface (for example in Storybook via a headless browser) and comparing before and after is what actually confirms such a change, and is usually worth the setup cost.\n  - Use flox when available — prefer `flox activate -- bash -c \"<command>\"` if commands fail\n    - Never use `flox activate` in interactive sessions (it hangs if you try)\n- Tests:\n  - Universal: `hogli test <file_or_directory>` — auto-detects test type (Python, Jest, Playwright, Rust, Go)\n  - Single test: `hogli test path/to/test.py::TestClass::test_method`\n  - Watch mode: `hogli test path/to/test.py --watch`\n  - Changed files only: `hogli test --changed`\n- Lint:\n  - Python:\n    - `ruff check . --fix` and `ruff format .`\n  - Frontend: `pnpm --filter=@posthog/frontend fix` (safe Oxlint fixes + Oxfmt; suggestion fixes are not applied). `format` runs Oxfmt only; `lint` and `format:check` only verify.\n  - TypeScript check: `pnpm --filter=@posthog/frontend typescript:check`\n- Build:\n  - Frontend: `pnpm --filter=@posthog/frontend build`\n  - Start dev: `./bin/start` or `hogli start` (interactive TUI). Detached mode: `hogli up -d` paired with `hogli wait` / `hogli down`\n    - Cloud task VMs (prebaked dev-stack image): run `bootstrap-dev-stack` first (restores compose host aliases, starts dockerd), then `uv sync`, `source .venv/bin/activate`, `hogli start -y -d`, and `hogli wait` (the detached start returns while the stack is still booting; `hogli wait` blocks until every process is ready) — always detached: the sandbox has no TTY, and phrocs under a pseudo-TTY balloons in memory until OOM-killed\n    - Cloud task VMs, frontend work: `pnpm install --frozen-lockfile --prefer-offline` links from the prebaked pnpm store, and Playwright Chromium is preinstalled; product/Storybook builds still run from source\n- OpenAPI/types: `hogli build:openapi` (regenerate after changing serializers/viewsets)\n- New product: `bin/hogli product:bootstrap <name>`\n- LSP: Pyright is configured against the flox venv. Prefer LSP (`goToDefinition`, `findReferences`, `hover`) over grep when navigating or refactoring Python code.\n- Dev experience feedback: `hogli devex:feedback \"<message>\"` sends feedback about repo tooling — hogli, the dev stack, tests, CI, migrations, this setup — straight to the devex team as a `hogli_feedback` event (add `-c bug|idea|praise|question`).\n  **Local agents must use it too**: when a hogli command or local dev workflow is broken, slow, or confusing, run it — e.g. `hogli devex:feedback -c bug \"migrations:run failed with <error>\"`. Do not run it from cloud tasks or agent-server sandboxes; the command is a no-op there.\n\n## Commits and Pull Requests\n\n- Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) for all commit messages and PR titles.\n- When a change touches user-facing behavior, an API, a config/setting, or a documented workflow, update the matching doc under `docs/` **in the same PR** — treat a stale doc as part of the breakage, not a follow-up.\n\n### Commit types\n\n- `feat`: New feature or functionality (touches production code)\n- `fix`: Bug fix (touches production code)\n- `chore`: Non-production changes (docs, tests, config, CI, refactoring agents instructions, etc.)\n- Scope convention: use `aio` for AI observability changes (for example, `feat(aio): ...`)\n\n### Format\n\n```text\n<type>(<scope>): <description>\n```\n\nExamples:\n\n- `feat(insights): add retention graph export`\n- `fix(cohorts): handle empty cohort in query builder`\n- `chore(ci): update GitHub Actions workflow`\n- `chore: update AGENTS.md instructions`\n\n### PR descriptions\n\n**Required:** Before creating any PR, read `.github/pull_request_template.md` and use its exact section structure.\nDo not invent a different format.\n**Shape:** invoke `/writing-pr-descriptions` before writing the body. Lead with the effect a person sees rather than the code path behind it, make the body stand alone for a reader who opens no files, and let its size track the change. Then one fact per bullet, sentences under 25 words, active voice, no idioms. A description that got longer as bullets was not cut.\nAlways fill the `## 🤖 Agent context` section when creating PRs.\nNEVER share sensitive information in a PR description. Users may share sensitive data in an agent session, but those should never surface to a PR description, or comments.\n\n**Screenshots:** Upload frontend/visual changes with `hogli pr:upload-image <file>` and embed the printed markdown. The first run only warns and uploads nothing; re-run with `--yes` to confirm. Only PostHog employees can upload, but the public can permanently view these assets, so only upload the image if you're certain it doesn't contain customer data (including customer names), secrets, or sensitive internal info.\n\n### Rules\n\n- Scope is optional but encouraged when the change is specific to a feature area\n- Description should be lowercase and not end with a period\n- Keep the first line under 72 characters\n\n### Pushing to remote\n\nOnce a branch already has an open PR, push incremental changes and fixes to it without waiting for human guidance — keeping the PR current is part of the work.\nPushes still trigger CI, which burns runner credits, so batch related commits and push once the increment is ready rather than after every change.\n\nA push to this repository cannot be taken back.\nForks, clones, mirrors, and notification emails carry it within seconds, and a later fix commit does not retract what is already in the branch's history — recovering means abandoning the branch and respinning the PR.\nThat makes the first push of a branch the decision point, not a step you correct afterwards.\nSo if any part of the work drew on something from your session rather than from this repository — a customer conversation, a support ticket, a log, an internal thread — check what is actually in the diff before that first push.\nSee [Public open source repo guidance](#public-open-source-repo-guidance) for what has to clear the bar.\n\n#### Forcing the full CI matrix on a draft\n\nDraft PRs run a narrowed matrix.\nThe `run-ci-backend` and `run-ci-frontend` labels force the full one, but a label alone starts nothing: it takes effect on the next push, or when the PR is marked ready for review.\nAn empty commit is enough.\n\n```bash\ngit commit --allow-empty -m \"chore(ci): run the full matrix\" && git push\n```\n\nDo not add `labeled`/`unlabeled` back to a merge gate's `on.pull_request.types` to avoid that push.\nGitHub cannot filter a label trigger by name, so every unrelated label re-runs the full matrices against a commit CI has already covered.\nGuarding it inside the workflow is worse: skipping the gate job cascades to the `if: always()` aggregator, which counts a skipped dependency as success and posts a green required check with no tests behind it.\n\n#### Stacked PRs\n\nGitHub's native stacked PRs are enabled on this repo — use the `gh stack` CLI and the `/stacking-prs` skill instead of hand-managing branch chains.\nA stack lands bottom-first: merge the layer based on `master` the usual way (see \"Merging PRs\" below), then `gh stack sync --prune` and repeat.\nNever `gh stack merge` — it merges the whole chain straight through GitHub's API, so the bottom layer reaches `master` outside that path.\n\nRestacking force-pushes every branch, and each push triggers a full CI fan-out.\nNever restack while any branch in the stack is sitting in the merge queue — the force-push removes it from the queue.\nPushing a deep stack at once can exceed GitHub's per-repo dispatch cap (500 workflow runs / 10s).\nThe overflow fails as `startup_failure` and takes unrelated runs in the same window down too.\nDraft status doesn't help, since runs are dispatched before draft/skip logic applies.\n\n- Keep stacks shallow; merge the base before extending.\n- Restack only when you need to, rather than rebasing the whole stack on master repeatedly.\n- When a restack must push many branches, stagger them instead of force-pushing all at once.\n\n#### Pre-push checks — ci:preflight\n\nA pre-push hook runs `hogli ci:preflight --strict`, failing the push on deterministic CI breakage reachable from your diff (lint, lockfiles, migration conflicts). Never bypass it (`--no-verify`).\nIf it blocks the push, run `hogli ci:preflight --fix`, resolve the remaining `✗ fail` lines, act on the `→ advisory` ones (regenerate OpenAPI types, merge master in), and push again.\nIn environments without hooks (no `node_modules`), run `hogli ci:preflight --fix` yourself before pushing or reporting a task done. If the command reports it is disabled, that's intentional — proceed.\n\n### Merging PRs\n\nAll merges into `master` go through the Trunk merge queue.\nNever run `gh pr merge` or click the GitHub merge button — both are blocked by branch ruleset.\n\n- Enqueue: `gh pr comment <number> --body \"/trunk merge\"`. Cancel: `gh pr comment <number> --body \"/trunk cancel\"`.\n- After enqueueing, babysit the PR until it merges or fails — follow [`.agents/skills/merging-prs/SKILL.md`](./.agents/skills/merging-prs/SKILL.md) for the preflight, watch, and failure-handling loop.\n- Queue progress is the `Trunk Merge Queue (master)` check run on the PR's head commit. The PR's own checks don't reflect the queue's testing — it runs CI on a `trunk-merge/**` branch.\n- On failure the Trunk bot comments with links to the failing workflows; fix, push, and re-enqueue.\n- Never force-push a branch while it is in the queue — it removes the PR from the queue.\n\n### Public open source repo guidance\n\nThis repository is public, and everything you push is public with it: source, tests, fixtures and sample data, comments and docstrings, branch names, commit messages, PR titles, descriptions, and comments, and uploaded screenshots.\nAnything you were given as context that is not already in this repository — a customer conversation, a support ticket, a log, an internal thread — has to clear that bar before any of it reaches a file, a message, or a description.\n\n- Never mention internal-only systems, private incidents, customer data, Slack thread contents, unreleased roadmap details, or security-sensitive implementation details. Slack thread links and channel references are fine to include — they sit behind PostHog auth and are useful as origin context — but do not quote or paraphrase what was said in the thread.\n- **Derived is not synthetic.** Swapping out names, domains, and identifiers does not make real customer material publishable. The prose, the typos, the error IDs, and the order of events are still theirs, and still disclose what they told us. If you started from real material and edited it, it is derived, however much you changed.\n- **Sample data that has to read like the real thing gets invented, not transcribed.** List the properties a case must exercise, then write the case from that list with the real material closed. Use reserved domains (`example.com`), invented identifiers, and obviously fake tokens — customers paste credentials and cookies into support chats, and those must not survive the trip even in fragments.\n- **Do not claim a provenance you have not checked.** \"Written fresh\" in a commit message is a factual claim a reviewer will rely on. If you are unsure, compare your text against the source: any shared run of ~40 characters or more means derived, not fresh.\n- Use product-facing and code-facing context that a public OSS contributor could understand from this repository alone.\n- If context is sensitive, summarize it at a high level without naming internal tools, accounts, or people.\n- Avoid citing private operational scale or incident metrics (for example, exact affected team counts, internal row-volume anecdotes, or customer-specific performance numbers) unless that data is already public and linkable.\n\nExamples:\n\n- ✅ `fix(insights): handle missing series color in trend export`\n- ✅ A PR description that links to the originating Slack thread for context\n- ✅ A test fixture written from a list of the properties it has to exercise, with the real conversation closed\n- ❌ `fix: patch issue found in acme-co prod workspace after sales escalation` — references internal customer\n- ❌ `fix: will run fine on our 12 million rows there now` — leaks private operational scale\n- ❌ A PR description that quotes verbatim what a coworker said in a Slack thread\n- ❌ A test fixture adapted from a real support conversation with the names and domains replaced\n\n## CI / GitHub Actions\n\n- `.nvmrc` controls the Node.js version for all CI workflows (via `actions/setup-node`) — changing it affects every CI job that runs Node\n- Every job in `.github/workflows/` must declare `timeout-minutes` — prevents stuck runners from burning credits indefinitely\n- **CI workflow changes must stay backwards compatible with open PRs that haven't rebased.** A workflow edit hits every in-flight PR immediately (it runs against the PR merged with master), but companion changes — a new dependency, file, or config — only reach a branch once it rebases. If the workflow starts requiring something an unrebased branch lacks, every such PR fails before its tests run. Make the new behavior degrade gracefully when the prerequisite is absent, or gate it so unrebased branches are unaffected. This has broken CI repeatedly.\n\n## Security\n\nSee [.agents/security.md](.agents/security.md) for security guidelines — least privilege, secrets & service-to-service auth (don't add new `INTERNAL_API_SECRET` callers), SQL, HogQL, and semgrep.\n\n## Architecture guidelines\n\n- API views should declare request/response schemas — prefer `@validated_request` from `posthog.api.mixins` or `@extend_schema` from drf-spectacular. Plain `ViewSet` methods that validate manually need `@extend_schema(request=YourSerializer)` — without it, drf-spectacular can't discover the request body and generated code gets empty schemas\n- Django serializers are the source of truth for frontend API types — `hogli build:openapi` generates TypeScript via drf-spectacular + Orval. Generated files (`api.schemas.ts`, `api.ts`, `api.zod.ts`) live in `frontend/src/generated/core/` and `products/{product}/frontend/generated/` — don't edit them manually, change serializers and rerun. See [type system guide](docs/published/handbook/engineering/type-system.md) for the full pipeline\n- MCP tools are generated from the same OpenAPI spec — see [implementing MCP tools](docs/published/handbook/engineering/ai/implementing-mcp-tools.md) for the YAML config and codegen workflow\n- MCP UI apps (interactive visualizations for tool results) are defined in `products/*/mcp/tools.yaml` under `ui_apps` and auto-generated — see [services/mcp/CONTRIBUTING.md](services/mcp/CONTRIBUTING.md) or use the `implementing-mcp-ui-apps` skill\n- When touching a viewset or serializer, ensure schema annotations are present (`@extend_schema` or `@validated_request` on viewset methods, `help_text` on serializer fields) — these flow into generated frontend types and MCP tool schemas\n- New features should live in `products/` — read [products/README.md](products/README.md) for layout and setup. When _creating a new_ product, follow [products/architecture.md](products/architecture.md) (DTOs, facades, isolation). Code a single product owns — not just backend/frontend, but scripts, CLIs, services, packages, MCP tools, skills — belongs under `products/<product>/`; reserve top-level `tools/`/`services/`/`packages/`/`cli/` for cross-product things\n- **Every tenant-data model must have `team_id`** — either as a FK (`models.ForeignKey(\"posthog.Team\", ...)`) or a plain `BigIntegerField` (for multi-DB products). This is the primary tenant isolation boundary. Models without `team_id` must be org-scoped, user-scoped, or instance-global — never silently unscoped. New models should inherit from `TeamScopedRootMixin` (main DB) or `ProductTeamModel` (separate DB) so they start fail-closed — see `posthog/models/scoping/README.md`. CI enforces this via `posthog/models/scoping/baseline_unmigrated.txt`: any new team-scoped model not on a fail-closed manager fails the IDOR coverage check. In serializers, access the team via `self.context[\"get_team\"]()`. When querying a fail-closed model for one team outside request context (Temporal activities, Celery tasks, management commands), use `Model.objects.for_team(team_id)` — not `Model.all_teams.filter(team_id=...)` or `objects.unscoped().filter(...)`; reserve `all_teams`/`unscoped()` for genuinely cross-team access and Django framework internals. Caveat: `for_team(...).get_or_create(...)`/`.create(...)` still need `team_id` passed explicitly — queryset filters don't propagate into row creation\n- **Do not add domain-specific fields to the `Team` model.** Use a Team Extension model instead — see `posthog/models/team/README.md` for the pattern and helpers\n- **PostHog event capture in Celery tasks:** Do not use `posthoganalytics.capture()` in Celery tasks — events are silently lost. Use `ph_scoped_capture` from `posthog.ph_client` instead (see its docstring for why and usage).\n- **Django admin `ForeignKey` fields need explicit widget config.** When adding a `ForeignKey`/`OneToOneField` to a model that's exposed in Django admin (including via inlines attached to a _related_ admin), list the new field in `autocomplete_fields`, `raw_id_fields`, or `readonly_fields` on **every** admin class that renders the model — otherwise the default `<select>` widget loads the entire target table per row on each change-page render. Prefer declaring the config on a shared base inline so per-parent variants (e.g., subclasses differentiated by `fk_name`) inherit it automatically.\n- **Use personhog client for all person/group data access — do not query persons DB tables via the Django ORM or raw SQL.** The `posthog/personhog_client/` gRPC client is the required interface for reading and writing person-related data. This applies to the following tables: `posthog_person`, `posthog_persondistinctid`, `posthog_cohortpeople`, `posthog_group`, `posthog_grouptypemapping`, and related override tables (`posthog_personoverride`, `posthog_pendingpersonoverride`, `posthog_flatpersonoverride`, `posthog_featureflaghashkeyoverride`, `posthog_personlessdistinctid`, `posthog_personoverridemapping`). Use the helpers in `posthog/models/person/util.py` (e.g. `get_person_by_uuid`, `get_persons_by_distinct_ids`, `get_person_by_distinct_id`) and `posthog/models/group_type_mapping.py` (`get_group_types_for_project`) — these already route through personhog with ORM fallback via `_personhog_routed()`. When adding new person/group data access, follow the same `_personhog_routed()` pattern: provide a `personhog_fn` using `get_personhog_client()` and an `orm_fn` fallback. Never add new direct ORM queries like `Person.objects.filter(...)` or `PersonDistinctId.objects.filter(...)` — use the existing routed helpers or create new ones following the established pattern. See `posthog/personhog_client/README.md` for client details and `posthog/personhog_client/client.py` for the full RPC interface.\n- **PostHog does not enable `ATOMIC_REQUESTS` — there is no implicit per-request transaction.** Each database operation runs in autocommit mode unless explicitly wrapped. Use `with transaction.atomic():` around the specific writes that must succeed or fail together. Do not wrap an entire view method atomically — keep the block as narrow as possible around the related writes. Avoid performing irreversible side effects (sending emails, calling external APIs, enqueuing Celery tasks) inside an atomic block: if the transaction rolls back, those side effects have already happened. Schedule such side effects after the commit, or use `transaction.on_commit()` for Celery task dispatch.\n- **Object storage is SeaweedFS — do not add new MinIO dependencies.** Both S3-compatible stores in the dev/CI stack are SeaweedFS: the `objectstorage` service (S3 API on `:19000`) backs general object storage (`OBJECT_STORAGE_*` settings — exports, media uploads, error-tracking source maps, query cache, tasks), and the `seaweedfs` service (S3 API on `:8333`) backs session replay v2 (`SESSION_RECORDING_V2_S3_*` settings). MinIO now survives only as migration tooling: `docker-compose.hobby.yml` keeps it as a source for `bin/migrate-storage-hobby`, and `bin/upgrade-objectstorage` starts a throwaway MinIO to salvage objects off the pre-swap volume. Outside that, don't add docker-compose services, scripts, tests, or docs that stand up a `minio/minio` container. Code that talks to object storage should go through the existing `OBJECT_STORAGE_*` / `SESSION_RECORDING_V2_S3_*` config and a standard S3 client rather than hardcoding an endpoint — that keeps backends swappable. Note the `objectstorage` service registers its credentials at runtime via a bootstrap loop and returns `InvalidAccessKeyId` until that completes, so anything depending on it must wait for its readiness sentinel rather than just for the container to start.\n- **Temporal activity payloads have a ~2 MiB hard limit — pass large data by reference, not by value.** Activity inputs and outputs are serialized across a gRPC boundary that Temporal caps at ~2 MiB per payload (the server rejects larger payloads via `blobSizeLimitError`). As a conservative field-level rule, if a field could exceed ~256 KB once serialized (serialized query results, exported file contents, LLM context, rendered HTML, image bytes, unbounded `list[dict[str, Any]]`), write it to Postgres / S3 / object storage from _inside_ the activity and return only the reference (row ID, S3 key). The workflow already has access to any row ID created earlier in the same run; it does not need the content to flow back through. Shuttling large data through the workflow on the way to persistence is a foreseeable failure mode that produces `PayloadSizeError` (`TMPRL1103`) the moment the underlying data crosses the limit.\n- **Outbound calls to a third-party API that need rate-limiting or egress telemetry belong in `posthog/egress/` — add a `<domain>/` incarnation (GitHub is the reference) and route callers through its gated, recorded transport, never hand-rolled `requests`. See `posthog/egress/README.md`.**\n- **`services/llm-gateway` is under an unofficial code freeze while callers move to [`PostHog/ai-gateway`](https://github.com/PostHog/ai-gateway).** New callers and features belong on the Go gateway by default. A Python gateway change needs a documented parity blocker for an active caller and must stay limited to that blocker. Read [`services/llm-gateway/PARITY.md`](services/llm-gateway/PARITY.md). Invoke `/auditing-llm-gateway-parity` for gateway contract changes and parity refreshes, `/finding-llm-gateway-migration-candidates` when deciding what to migrate next, and `/migrating-llm-gateway-callers` when moving a selected caller. The gateway's Postgres role reads only an allowlisted set of tables, with grants maintained in posthog-cloud-infra: a new table read needs the SELECT grant landed there for every environment first, then a declaration in `services/llm-gateway/src/llm_gateway/db/required_tables.py`, which a test binds to the package's SQL. The readiness probe verifies the connected role holds every declared grant on every probe, so a missing grant holds a rollout, and a revoked grant unreadies the running fleet, instead of serving 500s.\n\n## Code Style\n\n- Python: Write as if mypy `--strict` is enabled — annotate all function signatures (arguments + return types), avoid `Any`, use `TYPE_CHECKING` imports for type-only references. When a change is type-risky, run mypy the way CI does — `uv run mypy --cache-fine-grained .`, repo-wide, never a file subset (it follows imports, so a subset misses reverse-dependency breakage); `hogli ci:preflight` reminds you, and CI blocks on the same command. The config isn't fully strict yet, but new code should be\n- Python imports: keep imports at module level — not inside functions, methods, or conditionals. Inline imports hide dependencies from static analysis, slow hot paths with repeated lookups, and mask circular-import problems instead of fixing them; ruff's `PLC0415` enforces this. Defer an import only to (1) break a true unavoidable circular import (fix the structure first if you can), (2) reference types under `TYPE_CHECKING`, or (3) keep a heavy/optional dependency off the import path so it loads only when its code runs. For (3), add a justified `# noqa: PLC0415` on the import line (e.g. `# noqa: PLC0415 — keeps the heavy dep off the import path`) — never blanket-suppress the rule\n- Python: prefer a small dataclass over a tuple when returning or passing multiple values: always when two or more elements share a type, so callers can silently swap them (e.g. `(start, end)`, `(width, height)`), and when the tuple is big (roughly 3+ elements), where positional access hurts readability. Small tuples with unambiguous, differently typed elements are fine as-is. Use `@frozen` from `posthog.dataclasses` (frozen, `kw_only`, `slots` by default; every flag overridable, e.g. `@frozen(slots=False)` for `functools.cached_property`). A bare `@dataclass` without an explicit `frozen=` choice fails the ratchet in `posthog/test/test_dataclass_defaults.py` and is flagged by the `prefer-frozen-dataclasses` semgrep rule\n- Frontend: for any frontend work — the main app (`frontend/src/`) **or** a product frontend (`products/*/frontend/`) — follow [frontend/src/AGENTS.md](frontend/src/AGENTS.md): reuse existing Lemon/quill components instead of hand-rolling tables/badges/labels, import generated `*Api` types instead of handwriting them, and run typecheck/typegen at the right moments. Product frontends share the same components and generated types, so the same rules apply there\n- Frontend: TypeScript required, explicit return types\n- Frontend: If there is a kea logic file, write all business logic there, avoid React hooks at all costs.\n- Frontend (quill design system): before writing UI that imports `@posthog/quill` / `lib/ui/quill`, read [packages/quill/packages/primitives/AGENTS.md](packages/quill/packages/primitives/AGENTS.md) — component choice (dropdown vs select vs combobox, accordion vs collapsible, etc.), composition, and spacing rules. Charts: [packages/quill/packages/charts/AGENTS.md](packages/quill/packages/charts/AGENTS.md); DataTable/DateTimePicker: [packages/quill/packages/components/AGENTS.md](packages/quill/packages/components/AGENTS.md)\n- Frontend (quill vs LemonUI): quill is for MCP apps and the desktop app. It is deliberately more compact than LemonUI, so its components look out of place in the main app, and there is no active migration of the main app onto it. In `frontend/src/` and `products/*/frontend/`, use LemonUI, including for menus — `LemonMenu` with a `LemonButton` trigger is the default there. `lib/ui/DropdownMenu` (Radix) is legacy; don't add new ones. Where quill is the right library, don't mix quill and Lemon components within one component's internals, and note that quill uses Base UI's `render` prop rather than Radix's `asChild`, so don't carry `asChild` over when converting\n- Frontend: Any button or form submit that triggers a network request must guard against double-submission — disable the button and show a loading state (`loading` / `disabledReason` on `LemonButton`, or equivalent) while the request is in flight. Never leave a submit button clickable during an active mutation; reset the state in both success and error paths. This applies to `<form onSubmit>` handlers, `onClick` handlers that call `api.*`, and any kea `listener` that issues a request — wire the in-flight state (loader `*Loading` selectors, local `useState`, or a reducer) into the trigger's disabled/loading props.\n- Imports: Use oxfmt import sorting (automatically runs on format), avoid direct dayjs imports (use lib/dayjs)\n- CSS: Use tailwind utility classes instead of inline styles\n- Error handling: Prefer explicit error handling with typed errors\n- Naming: Use descriptive names, camelCase for JS/TS, snake_case for Python\n- Comments: default to short or 1-line comments. Explain _why_, not _what_, and only when a future reader (with no access to this PR or chat) would otherwise be confused\n- Comments: never log change history or chat context in code — no \"previously did X, now does Y\", \"per <task/PR>\", \"changed because…\", or \"AI:\"/\"agent:\" notes. That goes in the commit message and PR description\n- Comments: when refactoring or moving code, preserve existing comments unless they are explicitly made obsolete by the change\n- Python tests: do not add doc comments\n- Python: do not create empty `__init__.py` files\n- Python: consume dataclass results with dot notation (`result.field`), never by unpacking into positional locals (`a, b = result.a, result.b`), which reintroduces the swap hazard the dataclass exists to prevent. Mark secret fields with `field(repr=False)`\n- Python: name dataclasses after the domain concept, not the plumbing: `ClickHouseCredentials`, `BillingPeriod`, not `GetCredsResult` or `CredsTuple`. A `*Result` suffix only when the function's outcome genuinely is the concept; never `*Info`/`*Data`/`*Tuple`. Underscore-prefix classes private to one module\n- jest tests: when writing jest tests, prefer a single top-level describe block in a file\n- Tests: prefer parameterized tests (use the `parameterized` library in Python) — if you're writing multiple assertions for variations of the same logic, it should be parameterized\n- Tests must earn their place: every new test has to catch a realistic regression no existing test already catches (if you can't name it, don't add it), assert observable behavior through the public interface rather than implementation details, and stay cheap — deterministic, isolated, and at the lowest level that catches the bug (see `/writing-tests`)\n- Reduce nesting: Use early returns, guard clauses, and helper methods to avoid deeply nested code\n- Markdown: prefer semantic line breaks; no hard wrapping\n- Use American English spelling\n\n## User-facing copy\n\nFor any text a person reads (UI labels, tooltips, empty/error states, notifications, docs, support replies). Invoke `/writing-user-facing-copy` before writing or editing it — that skill carries the full voice, em-dash, and feature-naming rules. When unsure whether copy reads well, ask a human.\n\n- Sentence case, not Title Case: capitalize only the first word and proper nouns ('Product analytics', 'Save as view').\n- Avoid the tells of AI-generated text: em dashes (—), \"not just X, but Y\", rule-of-three padding, hedging preambles. Write like a person typed it; if you can't tell, ask a human.\n- Plain language, no jargon. Use the labels users see, not internal names (`surveyPopupDelaySeconds` becomes \"Delay the survey popup\").\n- Be direct and friendly: short sentences, consistent tone across surfaces.\n- Errors and empty states guide, don't dead-end: say what happened and the next action.\n\n## Agent automation\n\nWhen automating a convention, try these in order — only fall back to the next if the previous isn't suitable:\n\n1. **Linters** (ruff, oxlint, semgrep) — code pattern enforcement, always paired with CI\n2. **lint-staged / husky** — file-level validation or warnings at commit time\n3. **Skills** (`.agents/skills/`) — scaffold with `hogli init:skill`\n4. **AGENTS.md / CLAUDE.md instructions** — when automated enforcement isn't suitable\n\nClaude Code hooks are reserved for environment bootstrapping (`SessionStart` only) — do not add `PreToolUse`, `PostToolUse`, or `Notification` hooks as they add latency and are fragile. Changes to `.claude/hooks/` trigger a lint-staged warning; changes to `.claude/settings.json` are blocked outright.\n\n### Mandatory skill invocation\n\nALWAYS invoke the matching skill **before** writing or reviewing code in these areas — do not skip, do not attempt the work without loading the skill first.\n\n**Always invoke:**\n\n- `/improving-drf-endpoints` — any DRF viewset or serializer change\n- `/django-migrations` — any Django migration, including deleting a model, table, column, or whole product/app (even when no migration file is written, e.g. removing a product folder)\n- `/clickhouse-migrations` — any ClickHouse migration\n- `/adopting-generated-api-types` — any frontend file using `lib/api`, `api.get<`, `api.create<`, or handwritten API types\n- `/writing-ui-components` — creating, moving, splitting, or restructuring any component or file under `frontend/src/` or `products/*/frontend/`, extracting or promoting a shared component, or renaming frontend symbols or feature vocabulary\n- `/writing-tests` — adding or substantially changing any test (pytest, Jest, or Playwright)\n- `/writing-user-facing-copy` — writing or editing any text a user reads (UI labels, tooltips, empty/error states, notifications, docs, support replies), or any code change that adds or changes a visible string\n- `/writing-code-comments` — writing or editing a code comment in any language, or reviewing a diff that adds comments\n- `/writing-pr-descriptions` — writing or editing any PR body, before `gh pr create` or `gh pr edit --body`\n\n**Invoke when in the area:**\n\n- `/merging-prs` — merging a PR, or babysitting one through the Trunk merge queue\n- `/stacking-prs` — creating, restacking, adopting, or landing a stack of PRs (`gh stack`)\n- `/implementing-mcp-tools` — adding/modifying endpoints or `tools.yaml`\n- `/modifying-taxonomic-filter` — any TaxonomicFilter change\n- `/sending-notifications` — adding notification support\n- `/writing-skills` — creating or updating skills in `.agents/skills/`\n- `/writing-evals` — adding or changing eval suites, cases, scorers, or seeders under `products/posthog_ai/evals/` or `products/*/evals/`, touching the harness in `products/posthog_ai/eval_harness/`, or running those evals\n- [`ee/hogai/eval/AGENTS.md`](ee/hogai/eval/AGENTS.md) — writing eval cases or fixture data by hand anywhere (not a skill, and not covered by `/writing-evals`): where that data may come from, and why anonymizing a real conversation does not make it publishable\n- `/authoring-ci-workflows` — adding or editing any `.github/workflows` workflow, composite action, or reusable workflow\n- `/reviewing-personhog-protocol` — any personhog coordination-protocol change (leases, fencing, handoffs, supervisors, budgets, warming, changelog semantics), and any request for an exhaustive review of personhog code\n- `/gating-production-deploys` — any workflow that builds and pushes a production image or dispatches a deploy\n- `/auditing-llm-gateway-parity` — changing either gateway's auth, attribution, billing, endpoints, providers, models, routing, or metadata contract; reviewing a `services/llm-gateway` change; or refreshing `services/llm-gateway/PARITY.md`\n- `/finding-llm-gateway-migration-candidates` — finding, auditing, or ranking callers that could move from `services/llm-gateway` to `PostHog/ai-gateway`, including requests for the next or lowest-risk migration candidate\n- `/migrating-llm-gateway-callers` — adding an LLM gateway caller or migrating an existing caller from `services/llm-gateway` to `PostHog/ai-gateway`, including shared client and gateway setting changes made for that migration\n",".cursorrules":"DO NOT GIVE ME HIGH LEVEL SHIT, IF I ASK FOR FIX OR EXPLANATION, I WANT ACTUAL CODE OR EXPLANATION! I DON'T WANT \"Here's how you can blablabla\"\n- Be casual unless otherwise specified\n- Be terse\n- Suggest solutions that I didn't think about-anticipate my needs\n- Treat me as an expert\n- Be accurate and thorough\n- Give the answer immediately. Provide detailed explanations and restate my query in your own words if necessary after giving the answer\n- Value good arguments over authorities, the source is irrelevant\n- Consider new technologies and contrarian ideas, not just the conventional wisdom\n- You may use high levels of speculation or prediction, just flag it for me\n- No moral lectures\n- Discuss safety only when it's crucial and non-obvious\n- If your content policy is an issue, provide the closest acceptable response and explain the content policy issue afterward\n- Cite sources whenever possible at the end, not inline\n- No need to mention your knowledge cutoff\n- No need to disclose you're an AI\n- Please respect my formatting preferences when you provide code.\n- Please respect all code comments, they're usually there for a reason. Remove them ONLY if they're completely irrelevant after a code change. if unsure, do not remove the comment.\n- Split into multiple responses if one response isn't enough to answer the question.\n- If I ask for adjustments to code I have provided you, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. Multiple code blocks are ok.\n- Auto-detect flox environment before running terminal commands\n- If flox is available: ALWAYS use `flox activate -- bash -c \"<command>\"` pattern\n- Never use `flox activate` in interactive sessions (it hangs)\n\n"}}