Credited skills
Skill 40 of 42
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.
5 minutes · 1,147 words · 12 sections
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).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.
offset and limit — on service methods, handler
calls, 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 be
acceptable as a PUT body for the same resource (round-trip), aside from
server-managed/immutable fields.PUT
means keep; any other value replaces. Detail:
Updates and write-only secrets (opens in a new tab).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.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 |
@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: [...] } — never
a 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.@ApiResponse stripping to hide fields.500. Keep the schema loose enough for anything an existing
row may contain.Z.class(shape, { strict: true }).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 (opens in a new tab).
v1/controllers/<feature>.public.controller.ts + side-effect import in
v1/controllers/index.ts.@n8n/api-types + export from the barrel (src/dto/).@ApiKeyScope value exists in the permissions registry.x-required-scope for a controller
route — 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 (opens in a new tab). Match the nearest
existing tests.
Credited
This skill is installed in n8n-io/n8n — in use here rather than published from here — so there is no install command for it on this page.
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.
The verbatim description from this skill’s front matter — the string an agent matches on to decide whether to load it.
master, last pushed 22 September 2026.SKILL.md, not by matching a directory convention. 5 distinct layouts observed: .agents/skills/*/SKILL.md, .claude/plugins/n8n/skills/*/SKILL.md, .opencode/skills/*/SKILL.md, packages/@n8n/cli/skills/*/SKILL.md, packages/@n8n/instance-ai/skills/*/SKILL.md.h1 and no skipped levels:.claude/plugins/n8n/.claude-plugin/marketplace.json by n8n, declaring 1 plugin. It is read for editorial metadata only — never as the skill index, which is always the repository tree./n8n-io/n8n.md, and each skill at its own .md URL.1 file · 10 KB
Everything this skill ships beside its prose. All of it is set here, as a subchapter of skill 40.
Everything else published alongside the skill.