posthog/add-singleton-resource
Add support for a PostHog resource that exists exactly once per project (project settings, organization billing config, plugin enablement flags, …). Singletons do not fit the tag-based identity model — they have no collection to filter, no key to disambiguate, and no orphans to track. This skill is the playbook for the *other* shape: one declarative block, field-level diff, PATCH-only execution. Use when the user wants to manage a resource where the project itself is the only "row".
npx skills add https://github.com/PostHog/posthog-definitions --skill add-singleton-resource
This skill is the playbook for resources that exist exactly once per project — there's a single row and no collection, so the identity, safety, and lifecycle story is fundamentally different from add-resource. Examples: project settings (/api/projects/<id>/), billing config, default-event property allowlists, anything keyed by the project itself.
add-resource| Signal | This skill | add-resource |
| --------------------------- | ----------------------------------------------- | ------------------------------------- |
| API endpoint | PATCH /api/projects/<id>/ (no sub-collection) | POST /api/projects/<id>/<resource>/ |
| Number of rows per project | exactly 1 | 0..N |
| User declares | one block (projectSettings({...})) | many blocks across many files |
| Has a writable tags field | usually no | yes (required) |
| Can be deleted | no — always exists | yes |
If you're unsure: list the API. If you GET a single object (not a paginated list of objects), it's a singleton. If you'd need a key field on the spec to tell two of them apart in code, it's not.
Re-read the same three architecture docs as add-resource:
docs/implementation/architecture.mddocs/implementation/identity.md — note the gaps: this skill replaces the tag identity model.docs/implementation/apply.md — note the gaps: this skill replaces the create/update/orphan/prune lifecycle with PATCH-only.Then re-read the legacy insight pair (src/sdk/insight.ts, src/client/insights.ts, the insight-relevant blocks in src/apply/{diff,execute,serialize,validate}.ts) — not because singletons follow that shape, but because the pipeline plumbing is the same shape you'll register against.
Collection resources protect hand-built rows with a tag invariant: _if it doesn't have my tag, I don't touch it._ That mechanism is unavailable here — the project always exists, and we always have to write to it.
Singletons instead enforce a field-scoping invariant:
> A singleton apply only ever writes the fields the user explicitly declared.
The PATCH body must contain only the keys present in the user's spec. Fields the user did not mention must not appear in the request payload, even with undefined or null values — the request shape itself is the safety contract. PostHog's PATCH is a true partial update, so an absent key means "leave alone."
Concretely, this means:
Partial<Settings> rather than a Settings.State this contract in the user-facing JSDoc on the factory. Users who want to reset a field must explicitly set it to the desired value — there is no "remove" verb.
Singletons use the same per-resource directory pattern as collections, with fewer files:
src/resources/<singleton>/
├── sdk.ts user-facing factory + the spec type
├── client.ts ServerXSchema (Zod) + get/patch wrappers
├── pipeline.ts validate, diff (field-level), execute (PATCH), format
├── pipeline.test.ts field-diff selection, unchanged-when-equal, untouched-fields invariant
├── client.test.ts Zod schema parses a real fixture (only if schema is non-trivial)
└── index.ts public surface + registration object
Note: there is no serialize step in the same sense — payload construction is "spread the declared keys" — but it's tiny, so it goes in pipeline.ts rather than its own file.
| # | File | What it contains |
| --- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | src/resources/<singleton>/sdk.ts | The XSettings (or similar) user-facing type and the xSettings(spec): XSettings factory. The spec type is a Partial over the full configurable surface. |
| 2 | src/resources/<singleton>/client.ts | ServerXSchema (Zod) + getX(config) and patchX(config, partial) wrappers. No list* — there's nothing to list. |
| 3 | src/resources/<singleton>/pipeline.ts | validateX(spec), XDiff shape (per-field), diffX(spec, server), runXOp(config, op), renderXOp(op), xPayload(spec). |
| 4 | src/resources/<singleton>/index.ts | Re-export xSettings and XSettings; export an xResource registration object. |
| 5 | src/resources/index.ts | Add xResource to the RESOURCES array. |
| 6 | src/index.ts | Re-export xSettings and XSettings. |
A singleton has no key, no slug, no tag. Its identity is the project itself, addressed by config.projectId. There is no list filter, no iac:* namespace, and no orphan concept.
Plural slug picking, hash-in-tag, iac:hash:<hex> — none of these apply.
Same rules as add-resource — every response goes through a Zod schema with .passthrough() on the root and strict typing on the fields posthog-definitions actually reads. Singletons typically have many configurable fields (project settings can have ~50). Schema only the ones the user can declare via the factory; let .passthrough() carry the rest.
import { z } from "zod";
export const ServerXSchema = z
.object({
id: z.number(),
name: z.string(),
timezone: z.string(),
week_start_day: z.number().int().min(0).max(6),
autocapture_opt_out: z.boolean().nullable(),
// … only the declarable fields
})
.passthrough();
export type ServerX = z.infer<typeof ServerXSchema>;
If a declarable field is null-on-the-wire, mirror that with .nullable(). The factory type may still expose it as boolean | undefined (user-side undeclared is different from server-side null) — see "Nullability mapping" below.
Three states need to stay distinct:
| Where | State | Meaning |
| --------------- | -------------------------------------------- | ----------------------------------------------------------------------- |
| User spec | field absent (undefined) | "I do not manage this." Field is excluded from PATCH and from the diff. |
| User spec | field present with a value, including null | "Set the server field to this value." Included in PATCH and diff. |
| Server response | null | The field is unset on the server. |
The factory accepts field?: T \| null. The diff treats undefined as "skip this field entirely" and null as "set to null." Document this in the factory JSDoc — it's the trickiest bit of the surface.
diffX returns a list of per-field operations:
type XFieldChange = {
field: keyof XSettings;
before: unknown;
after: unknown;
};
type XDiff = { kind: "unchanged" } | { kind: "patch"; changes: XFieldChange[] };
The diff routine:
Object.keys(spec) — only declared keys, never the server keys).k, compare spec[k] to server[k].specHash's canonicalization — sorted keys for objects, exact array order. Reuse specHash to compute per-value hashes if the value is a non-primitive; for primitives, === is fine.{ kind: "patch", changes }. Otherwise { kind: "unchanged" }.There is no iac:hash:* tag. The server's current value is the source of truth and we always GET it before diffing. Hashing is a per-field comparison aid, not a stored marker.
In pipeline.ts, export validateX(spec): string[]. The validator runs purely on the declared fields. Cover at minimum:
Push every error into the returned array — the generic pipeline driver aggregates and throws ValidationError once.
renderXOp(op) formats the singleton like this:
Project settings
timezone: "UTC" → "Europe/London"
week_start_day: 0 → 1
(2 fields changed)
When op.kind === "unchanged", render a single line:
Project settings (unchanged)
There is no per-row "create / update / orphan" breakdown to surface in the summary line. Treat the singleton as a single update in the summary counts.
Singletons register the same way as collections, but the registration object's hooks reflect the different lifecycle:
// src/resources/<singleton>/index.ts
import { xSettings, type XSettings } from "./sdk.js";
import { getX, patchX } from "./client.js";
import { validateX, diffX, runXOp, renderXOp } from "./pipeline.js";
export { xSettings, type XSettings };
export const xResource = {
name: "<singleton>",
kind: "singleton", // ← distinguishes from "collection"
fetch: getX, // ← single GET, not a list
diff: diffX, // ← takes (spec, server), returns XDiff
execute: runXOp, // ← PATCH with declared fields only
validate: validateX,
render: renderXOp,
} as const;
The generic driver branches on kind: collections iterate, singletons fetch once. The CLI does not change.
If the generic driver in this codebase does not yet support kind: "singleton", you need to add that branch — the existing pipeline assumes collection lifecycle. Look at src/apply/{load,diff,execute,format-plan}.ts and add a parallel path that:
diff(spec, server) → XDiff.execute if kind === "patch".Loader: a singleton's user-facing factory should be detectable by a kind: "singleton" discriminator on the returned object (mirror looksLikeDashboard / looksLikeInsight in src/apply/load.ts). If multiple files default-export the same singleton type, that's a user error — reject with a clear message, do not merge.
The pipeline's pure functions are testable with no mocking. Cover at minimum, in src/resources/<singleton>/pipeline.test.ts:
{ timezone: "UTC" }, server returns { timezone: "UTC", week_start_day: 0, autocapture_opt_out: null, … } → kind: "unchanged".{ timezone: "UTC" }, server returns { timezone: "Europe/London", … } → kind: "patch" with [{ field: "timezone", before: "Europe/London", after: "UTC" }].{ timezone: "UTC" }, server returns { timezone: "UTC", week_start_day: 6 } → kind: "unchanged". The fact that week_start_day is 6 (and the server's default is 0) does not produce a change.null and undefined are distinct. Spec declares { autocapture_opt_out: null }, server returns { autocapture_opt_out: true } → kind: "patch". Spec omits autocapture_opt_out entirely, server returns { autocapture_opt_out: true } → no change for that field.Reuse the 500ms test budget. Build the server fixture with a serverX(overrides) factory.
If the Zod schema is non-trivial, add src/resources/<singleton>/client.test.ts with the same two cases as the collection skill: real fixture parses, fixture with required-field missing throws. Fixtures should come from a real curl against PostHog, not be invented.
Same shape as the collection flow, adapted:
pnpm typecheck — must pass.examples/ exercising the singleton.npx posthog-definitions apply --dry-run against the dev project. Inspect the plan: declared fields appear in the diff, undeclared fields do not.npx posthog-definitions apply. Confirm the targeted fields changed in the UI; confirm no other settings drifted.(unchanged). If anything is listed as a field change on a no-op apply, the equality check is wrong — fix it before moving on.--verbose to inspect the request body).(unchanged) — the server value persists. This is the abandoned-field smoke test.Steps 7 and 8 are the singleton equivalent of the safety invariant. Do not skip them.
Flip the singleton's row from ❌ to ✅ in docs/resources.md. Note in the entry that it is a singleton — users coming from feature flags or cohorts will expect a key.
The top-level README.md lists supported resources in its intro sentence and the required API scopes in the "Authenticate" step. Add the singleton to the intro list and append its read/write scope(s) to the bullet list.
Singletons cannot be deleted. --prune is a no-op for them. The registration object has no prune hook.
Same as the collection skill — multi-environment, state files, dev command — plus one singleton-specific item:
Take posthog/add-singleton-resource from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.