Use when editing, reviewing, or auditing DRF viewsets and serializers in PostHog. Triggers on files in posthog/api/, products/*/backend/api/, products/*/backend/presentation/, or any file importing rest_framework. Covers field typing, schema annotations, enum collision fixes, and OpenAPI spec quality — everything that flows downstream into generated TypeScript types and MCP tools.
80
100%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Serializer fields are the source of truth for PostHog's entire type pipeline:
Django serializer → drf-spectacular → OpenAPI JSON → Orval → Zod schemas → MCP toolsEvery help_text, every field type, every @extend_schema annotation flows downstream.
A missing help_text means an agent guessing at parameters.
A bare ListField() means z.unknown() in the generated Zod schema.
Getting the serializer right means every consumer — frontend types, MCP tools, API docs — gets correct types and descriptions automatically.
Serializer or ViewSetBefore diving into Python, look at the committed generated types to see what's broken. Find the generated files for the endpoint's product:
frontend/src/generated/core/products/<product>/frontend/generated/Each has two files:
api.schemas.ts — TypeScript interfaces derived from serializers. Search for the serializer name and look for unknown types (bare ListField/JSONField), missing JSDoc descriptions (missing help_text), or overly generic Record<string, unknown> shapes.api.ts — API client functions. Check if the endpoint's operation exists at all — if missing, the viewset method likely lacks @extend_schema.This tells you exactly which fields and endpoints to prioritize.
Work through this list for every serializer and viewset you touch.
help_text — describes purpose, format, constraints, valid valuesListField() or DictField() — always specify child= with a typed serializer or fieldJSONField() — create a custom field class with @extend_schema_field(TypedSchema)SerializerMethodField has @extend_schema_field on its get_* methodChoiceField has explicit choices= with all valid values listedformat, type, status, kind, level, mode, state, platform, provider clash with existing choices and fail CI under --fail-on-warn; pick a specific name or add an ENUM_NAME_OVERRIDES entry up front (see serializer-fields.md)See serializer-fields.md for patterns and examples.
@action has @extend_schema or @validated_request — without it, drf-spectacular discovers zero parametersViewSet methods have schema annotations — ModelViewSet with serializer_class is auto-discovered; plain ViewSet is not@extend_schema is on the actual method (get, post, create, list), not on a helper or the class itselfOpenApiResponse(response=ErrorSerializer), not OpenApiTypes.OBJECTpagination_class=None on custom actions that don't paginate@validated_request over manual serializer.is_valid() + @extend_schema — it handles both in one decoratorproducts/ need @extend_schema(extensions={"x-product": "<product>"}) — ViewSets in products/<name>/backend/ are auto-attributed via module path; ViewSets in posthog/api/ or ee/ aren't and must declare attribution explicitly via the x-product extension. Accepts a plain string ("product_analytics") or ProductKey.X enum (kebab values are normalized). Don't use tags=["<product>"] to influence codegen routing — tags is for Swagger UI display only. Without x-product, the MCP scaffold and frontend type generator can't route the endpoint to the right productpartial_update request= override must be a superset of runtime write fields — extend_schema(request=CustomSerializer) replaces drf-spectacular's inference from serializer_class; omitted fields disappear from OpenAPI, frontend types, and MCP tool schemas even when the runtime serializer still accepts them. After changing the override, run hogli build:openapi and verify generated MCP tool schemas still expose every OpenAPI body fieldStreaming endpoints: For SSE or streaming responses, use @extend_schema(request=InputSerializer, responses={(200, "text/event-stream"): OpenApiTypes.STR}) to document the request schema even though the response can't be fully typed.
See viewset-annotations.md for patterns and examples.
PostHog briefly split projects and environments as separate concepts then rolled
the split back. /api/projects/:team_id/... is the canonical path for any
team-nested endpoint. /api/environments/:team_id/... is a backward-compat alias
preserved only for clients that integrated against it during the split.
For a new team-nested endpoint, register it under routers.projects. Routes
live in each product's own products/<name>/backend/routes.py, in a
register_routes(routers) function:
# products/<name>/backend/routes.py
from posthog.api.routing import RouterRegistry
def register_routes(routers: RouterRegistry) -> None:
routers.projects.register(r"my_thing", MyThingViewSet, "project_my_thing", ["team_id"])Product routes are auto-discovered — posthog/api/__init__.py iterates
INSTALLED_APPS and calls register_routes(routers) on every products.* app
that has a routes.py. Adding a product needs no edit to core: create
products/<name>/backend/routes.py and make sure the product is in
PRODUCTS_APPS (posthog/settings/web.py). Only core, non-product viewsets still
register directly in __init__.py.
Why core discovers and calls the product (not the product calling core). Core
registers the four parents (root + projects/environments/organizations)
first, then runs the discovery loop. Products only nest onto those parents and
never onto each other, so discovery order is irrelevant. The registration is kept
eager (it runs when posthog.api is first imported, i.e. on the first request) and
deliberately not moved into AppConfig.ready(): ready() runs inside
django.setup() in every process, and registering a route imports its viewset, so
that would pull the whole API into setup() everywhere — regressing the laziness
that keeps the API out of Celery workers and management commands. See the
RouterRegistry docstring and the discovery loop in posthog/api/__init__.py for
the full reasoning.
Register team-nested endpoints under routers.projects with a project_<name>
basename. There is no environments_router and no dual-route helper: the legacy
/api/environments/* surface has been retired as a set of registered routes.
Existing clients that still call /api/environments/... are served transparently by
EnvironmentsRewriteMiddleware, which rewrites the path onto the equivalent
/api/projects/* viewset in-process (no 307). You never register an env route for
this — just register under routers.projects and the middleware handles the alias.
For products using the facade pattern (e.g., visual_review) with DataclassSerializer wrapping frozen dataclasses from contracts.py:
help_text (dataclass fields don't carry it; add it on the serializer field overrides)@validated_request is already the standard pattern — verify response serializers are declared@extend_schema tags and descriptions still need to be set on viewset methodsdigraph audit {
rankdir=TB
node [shape=diamond fontsize=10]
edge [fontsize=9]
start [label="Serializer or\nViewSet file?" shape=box]
is_model [label="ModelViewSet with\nserializer_class?"]
is_plain [label="Plain ViewSet or\ncustom @action?"]
is_facade [label="DataclassSerializer\n(facade product)?"]
check_fields [label="Check fields:\nhelp_text, ListField,\nJSONField, ChoiceField" shape=box]
add_schema [label="Add @validated_request\nor @extend_schema to\nevery method" shape=box]
check_help [label="Focus on help_text\nand response declarations" shape=box]
check_responses [label="Check response types,\npagination, error schemas" shape=box]
start -> is_model
is_model -> check_fields [label="yes"]
is_model -> is_plain [label="no"]
is_plain -> add_schema [label="yes"]
is_plain -> is_facade [label="no"]
is_facade -> check_help [label="yes"]
check_fields -> check_responses
add_schema -> check_fields
check_help -> check_responses
}See quick-reference-table.md for a scannable "I see X, do Y" lookup.
See common-anti-patterns.md for before/after code pairs.
posthog/api/alert.pyproducts/tasks/backend/api.pyproducts/llm_analytics/backend/api/evaluation_summary.pyproducts/visual_review/backend/presentation/views.pyimplementing-mcp-tools skill to scaffold MCP toolsdocs/published/handbook/engineering/type-system.mdposthog/api/mixins.py (@validated_request source)posthog/settings/web.py (SPECTACULAR_SETTINGS)python manage.py find_enum_collisions — finds unresolved collisions and suggests overridesd1dd198
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.