Skip to main content

add-resource

Add support for a new PostHog resource type (feature flag, cohort, action, survey, annotation, alert, …) to posthog-definitions. Walks through the eight files that must change, the tag-based identity convention, the load/diff/execute pipeline wiring, and the non-negotiable safety invariant. Use when the user wants to extend posthog-definitions beyond dashboards and insights.

Source facts

Repository
PostHog/posthog-definitions
Last source activity
May 14, 2026 at 13:27
Detected SKILL.md language
English
Stars
15
Forks
3

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
add-resource
description
Add support for a new PostHog resource type (feature flag, cohort, action, survey, annotation, alert, …) to posthog-definitions. Walks through the eight files that must change, the tag-based identity convention, the load/diff/execute pipeline wiring, and the non-negotiable safety invariant. Use when the user wants to extend posthog-definitions beyond dashboards and insights.
# Adding a new resource to posthog-definitions This skill is the playbook for extending posthog-definitions with a new PostHog resource type. It covers the _what to change and why_, not the line-by-line code — copy the existing dashboard/insight pair as a reference and adapt. ## When to use The user wants to manage a new PostHog resource as code, e.g.: - "Let's add feature flags." - "I want to define cohorts in TypeScript." - "Add survey support to apply." If the user only wants a one-off API call or a script, this is the wrong tool — this skill is for resources that should join the `load → validate → diff → execute` pipeline. ## Before you touch anything Re-read these three files. The whole architecture is in them: 1. `docs/implementation/architecture.md` — the pipeline shape. 2. `docs/implementation/identity.md` — the tag-based identity rule. 3. `docs/implementation/apply.md` — load / diff / execute in detail. Then read both existing implementations side by side. The insight implementation is the simpler one and is closer to what most new resources will look like: - `src/resources/insight/sdk.ts` — the `Insight` type and `insight()` factory. - `src/resources/insight/client.ts` — `ServerInsight` plus the HTTP wrappers, including `listManagedInsights`. - `src/resources/insight/pipeline.ts` — tag/key/hash/payload, validate, execute, prune, and display functions. - `src/resources/insight/index.ts` — public exports plus the `insightResource` registration object. If the new resource has _references to other resources_ (the way dashboards reference insights via tiles), study the dashboard pair as well — it shows the cross-resource id resolution pattern (`ctx.insightIdByKey`, populated by the insight module's executor and read by the dashboard module). ## The safety invariant (non-negotiable) **The CLI must never read, modify, or delete a resource it did not tag.** Hand-built resources in the same project must be byte-identical before and after `apply`. Two mechanisms enforce this — both must be replicated for the new resource: 1. **List filter.** `list<Resource>` only returns rows whose tags contain `iac:<resource>:<...>`. See `listManagedInsights` in `src/resources/insight/client.ts`. 2. **Pre-write assertion.** Before any `PATCH` or `DELETE`, refetch the row and confirm its `iac:<resource>:<key>` tag is still there. If it's been removed (e.g. an operator stripped it in the UI), abort with `SafetyViolationError`. See `assertManagedInsight` in `src/resources/insight/pipeline.ts`. If the resource's API has no `tags` field, the identity carrier changes but the invariant doesn't: - **Singleton resources** (one row per project, e.g. project settings) → use the sibling skill `add-singleton-resource`. No identity needed; field-level diff, PATCH-only. - **Collection resources without tags** (e.g. endpoints) → carry identity in the `description` (or another free-form text field the API round-trips). Append a trailing HTML comment marker, anchored to end-of-string: ``` <user description> <!-- iac:<resources>:<key> iac:hash:<hex> --> ``` List filter: `description.includes("<!-- iac:<resources>:")`. Pre-write assertion: refetch, confirm the marker is still the trailing content. Display functions must strip the marker before rendering so spec ↔ server diffs line up. See `src/resources/endpoint/pipeline.ts` for the reference. The safety invariant survives because rows without the marker (or with the marker no longer at end-of-string) are invisible to the CLI. ## Directory layout for a new resource Each new resource lives in its own self-contained directory under `src/resources/<resource>/`. The directory is the unit of contribution: everything a reviewer needs to understand the resource is co-located, and the generic pipeline (under `src/apply/`) is the only thing that depends on it. ``` src/resources/foo/ ├── sdk.ts user-facing factory + the Foo type ├── client.ts ServerFooSchema (Zod) + list/get/create/update wrappers ├── pipeline.ts serialize, validate, diff, execute, format — pipeline plumbing ├── pipeline.test.ts hash determinism, diff op selection, safety invariant ├── client.test.ts Zod schema parses real fixtures (only if non-trivial shape) └── index.ts public surface + registration object for the pipeline ``` For a hypothetical resource `Foo` (substitute the real noun), here is the per-file content. Order matters — earlier files' types feed later ones. | # | File | What it contains | | --- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `src/resources/foo/sdk.ts` | The `Foo` user-facing type and the `foo(spec): Foo` factory helper. | | 2 | `src/resources/foo/client.ts` | `ServerFooSchema` (Zod, with `ServerFoo` derived via `z.infer`) + `list/get/create/update` HTTP wrappers that `.parse()` every response. `listManagedFoos` filters by `iac:foos:` tag. | | 3 | `src/resources/foo/pipeline.ts` | `fooTag(key)`, `fooKeyFromTags(tags)`, `fooHashFromTags(tags)`, `looksLikeFoo(value)`, `fooHash(spec)`, `fooPayload(spec, hash)`, `validateFoos(specs, state)`, `assertManagedFoo` (private), `runFooOp(config, op, ctx, options)`, `pruneFoo(config, orphan, options)`, `displayFoo(spec)`, `displayFooFromServer(server)`. | | 4 | `src/resources/foo/index.ts` | Re-export `foo` and `Foo` for users; export a `fooResource` registration object that the generic pipeline picks up. | | 5 | `src/resources/index.ts` | Add `fooResource` to the `RESOURCES` array. | | 6 | `src/index.ts` | Re-export `foo` and the `Foo` type for SDK users. | Each resource is fully self-contained: SDK types live next to its `sdk.ts`, never in a shared `sdk/` directory. If two resources need to share a type, the dependent resource imports from the producer's `sdk.ts` directly (the way dashboard tiles import `Insight` from `src/resources/insight/sdk.ts`). Generic pipeline orchestration (hash, file load, the apply driver, plan formatter) stays in `src/apply/`. The CLI does not change — it iterates the registry. ### Resource-kind discriminator The loader routes each loaded default export to the resource whose `isSpec(value)` returns true. Several resources share a structural shape (insights, endpoints, and dashboards all have `{key, name, query | tiles}`), so `isSpec` cannot rely on shape alone. Every user-facing factory must install a non-enumerable kind marker, and every `looksLike<Resource>` must check it first: ```ts // sdk.ts import { markResourceKind } from "../types.js"; export function foo(spec: Foo): Foo { return markResourceKind(spec, "foo"); } // pipeline.ts import { getResourceKind } from "../types.js"; export function looksLikeFoo(value: unknown): value is Foo { return getResourceKind(value) === "foo"; } ``` If the resource may also appear inline inside another resource's spec (the way insights appear inside dashboard tiles) and could bypass the factory, fall back to a structural check when `getResourceKind` returns `undefined` — but the marker check must come first so factory-produced specs route deterministically. ### Server ids `ResourceOp.serverId` is `number | string`. Cast to the concrete type your API uses at the call site. For resources addressed by something other than a numeric id (e.g. endpoints, which are keyed by `name` in the URL), use `op.server.<field>` in `executeOp` and `prune` rather than `op.serverId`. ### Identity tag Pick a stable plural slug for the resource and use it everywhere: - List filter: `tag.startsWith("iac:foos:")` - Identity tag: `iac:foos:<key>` - Hash tag (shared, do not duplicate): `iac:hash:<hex>` Match the existing convention: lowercase, plural, colon-separated. The slug becomes part of every tagged record in the user's project, so it is effectively a public API — pick once, change never. ### API response validation with Zod New resources must validate every response from the PostHog API with a Zod schema. The PostHog API is the only external boundary in this codebase and is the right place to fail loudly when the shape we expect drifts. Casting `JSON.parse(text) as ServerFoo` (what the dashboard/insight client modules still do, via `request<T>` in `src/client/http.ts`) is the _legacy_ path — do not extend it. Those two will be migrated to Zod separately; new resources start on the validated path. If `zod` is not yet in `package.json`, add it: `pnpm add zod`. One-time cost for the first resource that adopts this. Pattern for `src/resources/foo/client.ts`: ```ts import { z } from "zod"; export const ServerFooSchema = z .object({ id: z.number(), name: z.string(), description: z.string().nullable().optional(), tags: z.array(z.string()).default([]), // … only the fields posthog-definitions actually reads }) .passthrough(); export type ServerFoo = z.infer<typeof ServerFooSchema>; const PaginatedFooSchema = z.object({ next: z.string().nullable(), previous: z.string().nullable(), results: z.array(ServerFooSchema), }); ``` Then in every wrapper, `.parse()` the response before returning. A failure surfaces as a `ZodError` with a precise path into the payload, which is what you want for "the API changed shape" debugging. Rules of thumb: - **Use `.passthrough()` on the root object.** The PostHog API returns dozens of fields per resource; only schema the ones posthog-definitions reads. `.passthrough()` keeps unknown fields without making them part of the type. - **Be strict on the fields you _do_ read.** No `z.unknown()` or `z.any()` for those — that defeats the whole point. - **Mirror server nullability faithfully.** PostHog often returns `null` rather than omitting a field. Use `.nullable()` (allows `null`) vs `.optional()` (allows missing) deliberately — they mean different things on the wire. - **No defaults that mask bugs.** Use `.default([])` only when the API genuinely may omit the field; otherwise let the parse fail. - **Parse, don't `safeParse` in the happy path.** A schema failure is a bug (ours or PostHog's), not a recoverable runtime condition. Let it throw; the CLI's existing error handling will surface it. - **Inline-validate the paginated wrapper too.** `paginate<T>` in `src/client/http.ts` currently casts — wrap it for new resources by parsing the page schema at the call site. What _not_ to schema: - Outgoing request bodies. They are constructed from typed `Foo` specs we already control; Zod adds nothing. - Response fields posthog-definitions never reads. Keeping them in `.passthrough()` is enough. ### Hashing Reuse `specHash` from `src/apply/hash.ts` on a canonical projection of the desired spec, exported from `pipeline.ts` as `fooPayloadHash`. The projection must: - Include every field the API round-trips. - Exclude server-only fields (`id`, `created_at`, …). - Exclude managed tags (the `iac:*` ones) — they are not user-authored. - Be stable under key reordering (the hash function canonicalises, but don't rely on it for arrays of objects — sort by a stable key if order is irrelevant). If hashing misses a field, that field will silently fail to sync on update. If it includes a server-generated field, every apply will be marked dirty. The canonical examples to copy from are `dashboardSpecForHash` in `src/resources/dashboard/pipeline.ts` and `insightSpecForHash` in `src/resources/insight/pipeline.ts`. ### Cross-resource references If `Foo` references another resource by key (e.g. a survey references a feature flag), do **not** include the referenced server id in the hash — it is environment-specific. Include the _key_ and resolve to the id at execute time, the way dashboards resolve insight ids via `insightIdByKey` in the legacy `src/apply/execute.ts`. Plan the execute ordering: dependencies must be created before dependents. The simplest model is two passes: create all `Foo`s first if other resources reference them, then move on. ## Validation In `pipeline.ts`, export `validateFoos(state): string[]` returning all issues. Cover at minimum: - `key` is required and unique. - `name` is required. - Any resource-specific invariants (e.g. survey must have at least one question). - Cross-resource references resolve. Push every error into the returned array — do not bail on the first. The generic pipeline driver aggregates issues across resources and throws `ValidationError` once at the end. The CLI shows all problems at once. ## Plan rendering In `pipeline.ts`, also export: - A `displayFoo` and `displayFooFromServer` pair returning a `DisplayValue` tree. - A `renderFooOp` matching the legacy `renderInsightOp` / `renderDashboardOp`. The generic plan formatter calls `renderFooOp` for each op and stacks them with the per-resource counts. The shape that comes out of `displayFooFromServer` must match `displayFoo` (same field order, same value coercions), otherwise the diff renderer will show spurious changes on `unchanged` rows. ## Registration (no CLI change) The CLI does not change. Each resource exports a registration object from its `index.ts` that the generic driver picks up: ```ts // src/resources/foo/index.ts import { foo, type Foo } from "./sdk.js"; import type { ApplyContext, ResourceModule } from "../types.js"; import { listManagedFoos, type ServerFoo } from "./client.js"; import { FOO_TAG_PREFIX, displayFoo, displayFooFromServer, fooHash, fooHashFromTags, fooKeyFromTags, looksLikeFoo, pruneFoo, runFooOp, validateFoos, } from "./pipeline.js"; export { foo } from "./sdk.js"; export type { Foo } from "./sdk.js"; export const fooResource: ResourceModule<Foo, ServerFoo> = { name: "foos", displayName: "foo", identityPrefix: FOO_TAG_PREFIX, isSpec: looksLikeFoo, specKey: (spec) => spec.key, list: listManagedFoos, keyFromServer: (server) => fooKeyFromTags(server.tags), hashFromServer: (server) => fooHashFromTags(server.tags), hash: fooHash, validate: (specs, state) => validateFoos(specs, state), executeOp: runFooOp, prune: pruneFoo,
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub