Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Adds, migrates, or updates n8n Public API v1 endpoints with @PublicApiController — public DTOs, API-key and RBAC scopes, cursor pagination, OpenAPI + coverage wiring, and tests. Use when working under packages/cli/src/public-api/v1/ or when exposing an existing service through /api/v1.
.claude/skills/n8n-io-n8n-public-api/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 102% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 56% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 24% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 79% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 83% | 0% |
Public API v1 lives in packages/cli/src/public-api/v1/, mounted at /api/v1 with API-key auth and public error formatting via PublicApiControllerRegistry (packages/cli/src/public-api/public-api-controller.registry.ts).
Two rule tiers: invariants (never break) and team defaults (follow unless an existing public contract forces otherwise). When this skill and the code disagree on a detail, the code wins — so open the files below. That is a reason to check the code, not license to drop a team default.
@PublicApiController classes under v1/controllers/, one*.public.controller.ts per feature. A controller is a class — never export = (the legacy tuple style; require-public-api-controller flags it).
never calls an internal controller/endpoint; both reuse the same service.
Container.get(…Repository) (no-repository-in-public-api-handler).
@n8n/api-types; every JSON route declares@ApiResponse(Dto).
v1/controllers/index.ts(public-api-controllers.test.ts fails otherwise).
express-openapi-validator (EOV) handlers.These are n8n-local-rules ESLint rules (see packages/cli/eslint.config.mjs) and can't be silenced inline (no-public-api-guardrail-disable). The off allowlist there covers pre-existing legacy files only — it's shrink-only, don't add to it.
page-based — don't copy an internal endpoint's model).
offset and limit — on service methods, handlercalls, and repository methods you add. Never skip/take (TypeORM names). Translate to skip/take only inside a repository, at the TypeORM find call. The public query string is still cursor + limit; offset is the decoded cursor field passed into the service, never a client-facing param.
PUT, not PATCH. A successful GET body should beacceptable as a PUT body for the same resource (round-trip), aside from server-managed/immutable fields.
resource's sentinel/placeholder (or omit). Echoing that sentinel on PUT means keep; any other value replaces. Detail: Updates and write-only secrets.
Public and internal are sibling routes over one shared, HTTP-agnostic service; neither calls the other.
GET /rest/tags → TagsController ┐ JWT auth, internal shape
├─→ TagService
GET /api/v1/tags → TagsPublicController ┘ API-key auth, public DTOReuse the service behavior. Reuse a DTO only when public and internal contracts are intentionally identical; otherwise make a public-specific DTO that doesn't depend on a UI-oriented internal shape.
Open these — they are the source of truth, not this skill:
v1/controllers/ — copy structure from tags.public.controller.ts (list +cursor) or workflows.public.controller.ts (@Param + @ProjectScope), and index.ts for the barrel.
packages/@n8n/decorators/src/controller/:public-api-controller.ts, api-key-scope.ts, api-response.ts, api-error-response.ts, api-summary.ts, api-description.ts, api-tags.ts, route.ts, scoped.ts, args.ts, licensed.ts.
needed for a controller route): v1/openapi-gen/generate.ts, v1/openapi-gen/decorator-routes.ts.
v1/shared/services/pagination.service.ts(decodeCursor, encodeNextCursor).
packages/@n8n/api-types/src/dto/.v1/__tests__/public-api-controllers.test.ts,v1/__tests__/scope-parity.test.ts, v1/openapi-gen/__tests__/generated-spec-drift.test.ts.
A controller is a class marked @PublicApiController('/base') that injects the shared service via its constructor and delegates to it. Copy the shape from an existing controller in v1/controllers/ with the same operation type and auth model; reuse only what applies. Decorators, all from @n8n/decorators:
| Decorator | Use | |---|---| | @PublicApiController('/base') | Class marker; mounts routes at /api/v1/base. | | @Get/@Post/@Put/@Patch/@Delete('/path') | Route method. | | @ApiKeyScope('res:action') | API-key grant check. | | @ProjectScope/@GlobalScope('res:action') | User RBAC check. | | @ApiResponse(status) / @ApiResponse(status, Dto) | Success status + (optional) output DTO; registry .parse()s + strips the return value. Exactly one per route — a second @ApiResponse throws. 204 can't carry a DTO — throws. | | @ApiErrorResponse(status) | Declares an additional documented non-2xx status (e.g. 404, 409). Stack multiple for more than one. 400/401/403 are added automatically (body/query present, always, and @ApiKeyScope present, respectively) — don't declare those yourself. | | @ApiSummary(text) / @ApiDescription(text) / @ApiTags([...]) | OpenAPI summary/description/tags. @ApiTags sorts alphabetically regardless of the order you pass. All optional but expected on every real route. | | @Query / @Body / @Param('name') | Bind + validate via a Z.class DTO / path param. | | @Licensed('feat') | Gates the route on a single BooleanLicenseFeature; PublicApiControllerRegistry runs its own license middleware (after auth/@ApiKeyScope/@ProjectScope|@GlobalScope, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. LicenseState.isProvisioningLicensed(), which is feat:saml OR feat:oidc), @Licensed can't express that; check manually in the handler instead, same as the internal provisioning.controller.ee.ts/role-mapping-rule.controller.ee.ts do today (throwing ForbiddenError on failure). |
@ApiKeyScope (what the API key is granted) and @ProjectScope/@GlobalScope(what the user may do) are independent. Use both when the model needs both.
{resource}Id (e.g. workflowId, credentialId,projectId, …) — never a generic :id / {id}. This is the Public API's naming convention: it keeps the API self-documenting and gives typed SDK codegen a real argument name instead of id. @ProjectScope also reads req.params as-is and does not remap id — it resolves authorization by exact key name (workflowId, credentialId, projectId, dataTableId, …), so a generic id on a @ProjectScope route often fails outright; a @GlobalScope or unscoped route won't fail the same way, but still follow the convention.
@ApiKeyScope takes a string, { anyOf: [...] }, or { allOf: [...] } — nevera bare array. The scope must exist in the permissions registry (API_KEY_RESOURCES in @n8n/permissions); scope-parity.test.ts fails on an orphan scope.
on @ApiResponse stripping to hide fields.
fields, tokens, and encrypted values.
The registry parses the handler's return value against it, so a value the schema rejects becomes a 500. Keep the schema loose enough for anything an existing row may contain.
TypeORM relations are opt-in, so two routes over the same entity can return different shapes.
Z.class(shape, { strict: true }).
(or omit). See Updates and write-only secrets.
Copy the cursor flow from tags.public.controller.ts. The input DTO takes limit: publicApiPaginationSchema.limit plus cursor: z.string().optional() — pick limit off the schema, never spread the whole publicApiPaginationSchema (it also exports offset, which must never be a Public API query param). Use decodeCursor / encodeNextCursor from the shared pagination service; the cursor is opaque; return { data, nextCursor } (never a bare array) with nextCursor: null on the last page; an invalid cursor is a 400. Preserve an existing endpoint's cursor semantics as-is — but an offset param is a defect to remove, not a contract to preserve. Detail: List endpoints and cursor pagination.
v1/controllers/<feature>.public.controller.ts + side-effect import inv1/controllers/index.ts.
@n8n/api-types + export from the barrel (src/dto/).@ApiKeyScope value exists in the permissions registry.x-required-scope for a controllerroute — the generator (v1/openapi-gen/generate.ts) builds it from your decorators (@ApiSummary/@ApiDescription/@ApiTags/@ApiKeyScope/ @ApiResponse/@ApiErrorResponse). Run the full pnpm build and commit the regenerated handlers/<feature>/spec/paths/*.generated.yml fragment(s) and openapi.decorator-routes.generated.yml — generated-spec-drift.test.ts fails CI if they're stale. pnpm run build:data alone is not enough after touching a controller: it runs the generator against the already-compiled dist/, so a new/changed controller silently doesn't show up unless tsc ran first.
packages/nodes-base/nodes/N8n/n8n-api-coverage.json.Always cover: happy path, input-validation failure, missing API-key scope, RBAC denial. Prefer covering the business path in packages/cli/test/integration/public-api/ (real HTTP + DB); mocked-service unit tests don't replace that. Add the cases that apply (cursor pages, not-found/conflict, no sensitive fields, credential keep/replace, migration contract) — see Testing matrix. Match the nearest existing tests.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 20,444 | 27,346 | +34% | 1 | 1 | 0% | 4,253 | 8,579 | +102% | 0 | 0 | — |
case-02 | fail→pass | 22,899 | 14,628 | -36% | 1 | 1 | 0% | 3,886 | 6,072 | +56% | 0 | 0 | — |
case-03 | fail→pass | 31,762 | 22,337 | -30% | 1 | 1 | 0% | 6,015 | 7,475 | +24% | 0 | 0 | — |
case-04 | fail→pass | 15,023 | 7,413 | -51% | 1 | 1 | 0% | 2,454 | 4,389 | +79% | 0 | 0 | — |
case-05 | pass→pass | 16,978 | 7,840 | -54% | 1 | 1 | 0% | 2,626 | 4,410 | +68% | 0 | 0 | — |
case-06 | fail→pass | 14,517 | 8,266 | -43% | 1 | 1 | 0% | 2,566 | 4,687 | +83% | 0 | 0 | — |
case-07 | fail→fail | 10,176 | 6,970 | -32% | 1 | 1 | 0% | 1,785 | 4,370 | +145% | 0 | 0 | — |
case-08 | pass→pass | 15,052 | 8,472 | -44% | 1 | 1 | 0% | 2,340 | 4,642 | +98% | 0 | 0 | — |
case-09 | fail→pass | 10,192 | 5,494 | -46% | 1 | 1 | 0% | 1,298 | 4,080 | +214% | 0 | 0 | — |
case-10 | fail→fail | 12,236 | 10,836 | -11% | 1 | 1 | 0% | 1,942 | 4,995 | +157% | 0 | 0 | — |
case-11 | fail→pass | 12,310 | 4,752 | -61% | 1 | 1 | 0% | 1,887 | 3,846 | +104% | 0 | 0 | — |
case-12 | fail→pass | 11,312 | 5,892 | -48% | 1 | 1 | 0% | 1,911 | 3,844 | +101% | 0 | 0 | — |
case-13 | fail→pass | 14,444 | 5,365 | -63% | 1 | 1 | 0% | 2,150 | 4,091 | +90% | 0 | 0 | — |
case-14 | fail→pass | 15,365 | 3,947 | -74% | 1 | 1 | 0% | 2,428 | 3,782 | +56% | 0 | 0 | — |
case-15 | pass→fail | 10,014 | 5,524 | -45% | 1 | 1 | 0% | 1,510 | 3,938 | +161% | 0 | 0 | — |
case-16 | pass→pass | 14,310 | 9,573 | -33% | 1 | 1 | 0% | 2,142 | 4,554 | +113% | 0 | 0 | — |
case-17 | pass→pass | 14,254 | 7,227 | -49% | 1 | 1 | 0% | 2,197 | 4,202 | +91% | 0 | 0 | — |
case-18 | pass→pass | 13,525 | 6,115 | -55% | 1 | 1 | 0% | 1,886 | 4,254 | +126% | 0 | 0 | — |
case-19 | pass→pass | 9,455 | 5,423 | -43% | 1 | 1 | 0% | 1,396 | 4,032 | +189% | 0 | 0 | — |
case-20 | pass→pass | 19,412 | 10,117 | -48% | 1 | 1 | 0% | 2,986 | 4,790 | +60% | 0 | 0 | — |
case-21 | pass→pass | 13,431 | 5,438 | -60% | 1 | 1 | 0% | 1,936 | 4,162 | +115% | 0 | 0 | — |
case-22 | pass→pass | 18,730 | 10,100 | -46% | 1 | 1 | 0% | 3,211 | 5,168 | +61% | 0 | 0 | — |
case-23 | pass→pass | 16,324 | 19,248 | +18% | 1 | 1 | 0% | 3,237 | 6,354 | +96% | 0 | 0 | — |
case-24 | pass→pass | 18,535 | 13,942 | -25% | 1 | 1 | 0% | 2,978 | 5,650 | +90% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +38 percentage points is the difference between those two pass rates over the 24 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.