# How to organize operations with tags that docs and SDKs respect

> Give every operation one declared, described tag in reading order, audit the list in CI, and measure what a tag rename moves in a tag-driven generator and in sdkgen.

Source: https://voxgig.com/howto/organise-operations-with-tags

- Audience: devrel
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Give every operation exactly one tag, and declare each tag once in the top-level `tags` list with a description. Order that list the way readers should meet the API, because documentation tools follow it. Audit the list in CI with the Spectral tag rules and a check for unused tags. Treat a tag rename as an SDK breaking change for tag-driven generators such as OpenAPI Generator and Speakeasy, and expect sdkgen to name methods from schemas first and tags second.

---
## You will need

Node 22 or later and an OpenAPI 3.1 document with an `operationId` on every operation. Verified
2026-10-21 against Node 22.22.0, `@stoplight/spectral-cli` 6.17.0, `orval` 8.43.0,
`@voxgig/apidef` 8.25.3, and `yaml` 2.9.1. The
[OpenAPI Object](https://spec.openapis.org/oas/v3.1.0.html#openapi-object) says the order of the
top-level `tags` list "can be used to reflect on their order by the parsing tools" and leaves
the rest to the tool. Tags used but not declared "MAY be organized randomly or based on the
tools' logic."[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [OpenAPI 3.2 tag `parent` and `kind`](https://spec.openapis.org/oas/v3.2.0.html#tag-object) | A hierarchy and an audience marker inside the standard, for tools that read 3.2 | A 3.2 document, which few generators accept, so the hierarchy is inert in every tool that stops at 3.1 | Your toolchain stops at 3.1, or your renderer already groups with `x-tagGroups` |
| [OpenAPI Generator API classes](https://openapi-generator.tech/docs/templating/) | One client class per tag in dozens of languages, with `default` for untagged operations | An operation with two tags is generated twice, and a tag rename renames a class every caller imports | Your tags were chosen for the sidebar, not for a class list |
| [Redocly `x-tagGroups`](https://redocly.com/docs/realm/content/api-docs/openapi-extensions/x-tag-groups) | A second navigation level in Redoc and Redocly, over tags you keep flat | A vendor extension, a tag in no group vanishes from the output, and SDK generators ignore it | Your renderer is neither Redoc nor Scalar, or 3.2 `parent` is open to you |
| [sdkgen entity namespaces](https://voxgig.com/sdk) | Entity-shaped clients named from your component schemas, so tags stay a documentation device | A schema rename moves methods instead, and an operation with an inline schema takes its first tag as its entity | Your SDK should mirror the sidebar, tag for tag |
| [Speakeasy tag namespaces](https://www.speakeasy.com/docs/sdks/customize/structure/namespaces) | Tag-driven namespaces, with `x-speakeasy-group` to override them per operation | A method appears in every class its tags name, and an untagged one lands on the root client | You cannot add vendor extensions to the published document |

Voxgig maintains sdkgen. It is one of five options here, not the recommendation.

OpenAPI Generator and Speakeasy read tags as the shape of the SDK, so every tag decision is an
API decision for them. An operation with two tags becomes a method with two homes. Redoc reads
tags as the shape of the sidebar and `x-tagGroups` as a layer over it. sdkgen reads tags last: the
component schema an operation references names its entity, and the tag steps in only where no
schema does. One document serves all three only when tags, schemas, and paths agree.

## Audit the tags before a renderer does

The sample is a billing API with eight operations and the four mistakes a tag list collects over
time. A declared tag goes unused, an operation carries two tags, a tag is used but never
declared, and the health check is untagged. `tags.mjs` reads a document and reports all four.

```js title="tags.mjs"
/** The problems with a document's tags: per declared tag, then per operation. */
export function audit(doc) {
  const declared = doc.tags ?? []
  const ops = operations(doc)
  const uses = new Map(declared.map((t) => [t.name, 0]))
  const names = new Set(declared.map((t) => t.name))
  const operationProblems = ops.map((op) => {
    const problems = []
    if (op.tags.length === 0) problems.push('untagged')
    if (op.tags.length > 1) problems.push(`${op.tags.length} tags`)
    for (const tag of op.tags) {
      if (names.has(tag)) uses.set(tag, uses.get(tag) + 1)
      else problems.push(`${tag} is not declared`)
    }
    return { ...op, problems }
  })
  const tagProblems = declared.map((tag) => {
    const problems = []
    if (!tag.description) problems.push('no description')
    if (uses.get(tag.name) === 0) problems.push('unused')
    return { name: tag.name, uses: uses.get(tag.name), problems }
  })
  return { tags: tagProblems, operations: operationProblems }
}
```

```bash
node tags.mjs openapi.yaml
```

```text output
openapi.yaml: 3 tags declared, 8 operations, 4 with a problem
  tag    Invoices     used by 4 ok
  tag    Payments     used by 3 ok
  tag    Refunds      used by 0 unused
  GET    /invoices                Invoices             ok
  POST   /invoices                Invoices             ok
  GET    /invoices/{invoice_id}   Invoices             ok
  GET    /payments                Payments             ok
  POST   /payments                Invoices, Payments   2 tags
  GET    /payments/{payment_id}   Payments             ok
  GET    /customers               Customers            Customers is not declared
  GET    /health                  (none)               untagged
```

The script exits 1 here, so the same command can gate a pull request. The Spectral
[OpenAPI ruleset](https://docs.stoplight.io/docs/spectral/4dec24461f3af-open-api-rules) covers
three of the four with built-in rules. The ruleset beside the document turns on those three and
the two rules for the top-level list, and leaves the rest of `spectral:oas` off:

```yaml title=".spectral.yaml"
extends: [[spectral:oas, off]]
rules:
  openapi-tags: error
  openapi-tags-uniqueness: error
  operation-tags: error
  operation-tag-defined: error
  operation-singular-tag: error
```

```bash
npx spectral lint openapi.yaml
```

```text output
  89:12  error  operation-singular-tag  Operation must not have more than a single tag.  paths./payments.post.tags
 126:14  error  operation-tag-defined   Operation tags must be defined in global tags.   paths./customers.get.tags[0]
  137:9  error  operation-tags          Operation must have non-empty "tags" array.      paths./health.get

✖ 3 problems (3 errors, 0 warnings, 0 infos, 0 hints)
```

`operation-singular-tag` is off in the default ruleset, so a team that wants one tag per
operation has to ask for it. The unused `Refunds` tag has no rule in Spectral at all, which is
why the script exists beside it.

## Declare every tag once, described, in reading order

The fixed document declares four tags, each with a description. It orders them the way a reader
meets the API: the resource people pay for, the money against it, who owes it, and the service
itself last. The health check gets a tag of its own, `Operations`, instead of a seat in `Invoices`.

```yaml title="openapi.fixed.yaml"
tags:
  - name: Invoices
    description: Invoices raised against a customer.
  - name: Payments
    description: Payments recorded against an invoice.
  - name: Customers
    description: The customers invoices are raised against.
  - name: Operations
    description: Health and readiness of the service itself.
```

```bash
node tags.mjs openapi.fixed.yaml
```

```text output
openapi.fixed.yaml: 4 tags declared, 8 operations, 0 with a problem
  tag    Invoices     used by 3 ok
  tag    Payments     used by 3 ok
  tag    Customers    used by 1 ok
  tag    Operations   used by 1 ok
```

Tag by resource unless two audiences read two different documents. A resource tag names a
noun your schemas already name, so the docs sidebar and an SDK class list come out the same. An
audience tag such as `Partners` reads well in a sidebar and becomes a class called `Partners` in a
tag-driven SDK, holding whichever operations partners happen to call. OpenAPI 3.2 lets a tag say
which kind it is, and nest under another, so an audience tag can be declared as one:

```yaml title="tags-3-2.yaml"
tags:
  - name: Billing
    summary: Billing
    description: Everything a finance team touches.
    kind: nav
  - name: Invoices
    description: Invoices raised against a customer.
    parent: Billing
    kind: nav
  - name: Payments
    description: Payments recorded against an invoice.
    parent: Billing
    kind: nav
  - name: Partners
    description: Operations open to partner integrations, whichever resource they touch.
    kind: audience
```

The [3.2 Tag Object](https://spec.openapis.org/oas/v3.2.0.html#tag-object) defines `parent` as
the name of a tag this one nests under. It defines `kind` as "a machine-readable string to
categorize what sort of tag it is" and names `nav`, `badge`, and `audience` as common
values.[^2] Until your renderer reads 3.2,
[Redocly's `x-tagGroups`](https://redocly.com/docs/realm/content/api-docs/openapi-extensions/x-tag-groups)
puts the same hierarchy in an extension. The page on
[organizing a large OpenAPI reference by tags and tag groups](/howto/organise-a-large-openapi-reference)
covers what Redoc and Scalar do with it.

## See where each generator files an operation

`namespaces.mjs` runs two generators on the fixed document and on a copy with every tag renamed,
and prints where each operation landed. [orval](https://orval.dev/docs/reference/configuration/output/)
in `tags` mode writes one file per tag, which makes it the runnable stand-in here for every
generator that reads tags as namespaces. `@voxgig/apidef` is the model step inside sdkgen.

```js title="namespaces.mjs"
/**
 * orval in tags mode writes one file per tag and names each function after the operationId.
 * Returns the file each operation landed in, keyed by method and path.
 */
export function byTag(file) {
  const out = mkdtempSync(join(tmpdir(), 'orval-'))
  try {
    // orval reads the nearest tsconfig.json up the tree, so point it at the sample's own.
    const tsconfig = fileURLToPath(new URL('./tsconfig.json', import.meta.url))
    const args = [ORVAL, '-i', resolve(file), '-o', join(out, 'api.ts'), '-m', 'tags', '--client', 'fetch', '--tsconfig', tsconfig, '--quiet']
```

```bash
node namespaces.mjs openapi.fixed.yaml openapi.renamed.yaml
```

```text output
orval 8.43.0, mode tags, openapi.fixed.yaml -> openapi.renamed.yaml
  GET    /invoices                invoices       -> billing
  POST   /invoices                invoices       -> billing
  GET    /invoices/{invoice_id}   invoices       -> billing
  GET    /payments                payments       -> money
  POST   /payments                payments       -> money
  GET    /payments/{payment_id}   payments       -> money
  GET    /customers               customers      -> accounts
  GET    /health                  operations     -> platform
  8 of 8 operations moved

@voxgig/apidef 8.25.3, the sdkgen model step, openapi.fixed.yaml -> openapi.renamed.yaml
  GET    /invoices                invoice.list   -> invoice.list
  POST   /invoices                invoice.create -> invoice.create
  GET    /invoices/{invoice_id}   invoice.load   -> invoice.load
  GET    /payments                payment.list   -> payment.list
  POST   /payments                payment.create -> payment.create
  GET    /payments/{payment_id}   payment.load   -> payment.load
  GET    /customers               customer.list  -> customer.list
  GET    /health                  operation.load -> platform.load
  1 of 8 operations moved
```

Every orval file moved, because the file is the tag. Speakeasy documents the same relation for
its namespaces, and OpenAPI Generator's
[`DefaultGenerator`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java)
files an operation under a group per tag, with a tag named `default` for an operation that has
none.[^3]

apidef moved one method. Seven operations reference a component schema, `Invoice`, `Payment`,
or `Customer`, and that schema names the entity, so the tag rename did not reach them. The health
check returns an inline object, so apidef fell back to its tag, and `Operations` became
`operation.load`. The [apidef](https://github.com/voxgig/apidef) heuristic reads a referenced
schema first, the operation's first tag second, and the path last, which is a narrower claim
than "sdkgen ignores tags". Name your schemas and the tags stay a documentation device.

## Check it worked

The test file covers the audit and the Spectral run, then both generators before and after the
rename.

```bash
node --test tags.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 7343.578503
```

The third test asserts that orval files the draft's two-tag operation under `invoices`, the first
of its tags, and the untagged health check under `default`. The fourth pins the rename result
from the previous step: eight orval files moved, one apidef method moved, and that one is the
health check.

## When it goes wrong

A section appears at the bottom of the sidebar with no description. The tag is used on an
operation and missing from the top-level list, which the specification allows and then declines
to order. Declare it, and keep `operation-tag-defined` at `error` so the next one fails the build.

A method appears twice in the generated SDK. The operation carries two tags, and
[Speakeasy](https://www.speakeasy.com/docs/sdks/customize/structure/namespaces) says that such an
operation "will appear as a method in multiple classes" and OpenAPI Generator's group loop does
the same. orval takes the first tag only, so three generators give the operation two, two, or one
homes.
Keep one tag per operation, and group for the sidebar with `x-tagGroups` or `parent`.

A tag rename breaks callers of the SDK. The rename run in the previous step moved eight of eight
orval files, and
the tag-driven generators move their classes the same way. Rename in a major version, or change
only the label: Redoc reads `x-displayName` and Speakeasy reads `x-speakeasy-group`, so the
sidebar can change while the namespace stays.

## When not to do this

Do not tag by audience when an SDK is generated from tags. `Partners` and `Internal` are useful
sidebar sections and poor class names, and a tag-driven generator will mint the classes anyway.
Keep audience as a 3.2 `kind` or an `x-tagGroups` entry, and keep the tags themselves on
resources.

Do not expect tags to shape an sdkgen client. apidef names an entity from the component schema an
operation references, and reaches for the tag only when there is none. A tag rename therefore
moves the operations with inline schemas and leaves the rest. If a method lands in the wrong entity in
sdkgen, the fix is a named schema or a clearer path, and a tag change will not do it.

Do not give an operation two tags to make it findable in two sidebar sections. Redoc shows it
twice, Speakeasy and OpenAPI Generator generate it twice, and orval files it once under the first
tag, so the document means three different things to three readers. Cross-reference in the
description instead.

Do not treat `x-tagGroups` as portable. It is one vendor's extension that another vendor also
reads, and a tag outside every group disappears from Redoc's output.

## Related how-tos

- [Write operationIds that generate clean SDK method names](/howto/operationids-that-generate-clean-sdk-method-names)

- [Organize a large OpenAPI reference by tags and tag groups](/howto/organise-a-large-openapi-reference)

- [Tell an entity-shaped SDK from an endpoint-shaped one](/howto/measure-entity-versus-endpoint-shaped-output)

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@stoplight/spectral-cli` 6.17.0, `orval` 8.43.0,
`@voxgig/apidef` 8.25.3, and `yaml` 2.9.1. Every output block is what the command preceding it
printed, run in the page's code directory after `npm ci`. OpenAPI Generator, Speakeasy, Redoc, and
Scalar were not run; their behavior is taken from the documentation and source linked on this
page. The sdkgen names come from `@voxgig/apidef` run on its own, the step of an sdkgen generate
that reads the document into entities. The 3.2 tags file was written for the page and fed to no
tool.

[^1]: The sentence permitting random order reads the same in
[3.1](https://spec.openapis.org/oas/v3.1.0.html#openapi-object) and in
[3.2](https://spec.openapis.org/oas/v3.2.0.html#openapi-object), word for word. The revision
between them gave the Tag Object three fields and a registry and left that sentence alone. A
specification that tells you how to nest a tag still declines to say where an undeclared one goes,
which leaves that question to whichever renderer opens the file.

[^2]: The [3.2 Tag Object](https://spec.openapis.org/oas/v3.2.0.html#tag-object) points to a
registry of common `kind` values and offers three: `nav` for navigation, `badge` for visible
badges, and `audience` for the groups an API serves. `x-tagGroups` arrived in
[Redoc 1.7.0](https://github.com/Redocly/redoc/blob/main/CHANGELOG.md#170-2017-01-06) in January
2017 and expressed the first of the three, navigation, for years before the standard named any of
them.

[^3]: The three tools give an operation with no tag three different homes. OpenAPI Generator's
[`DefaultGenerator`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultGenerator.java)
adds a tag named `default` and files the operation under it. orval writes a `default.ts`.
[Speakeasy](https://www.speakeasy.com/docs/sdks/customize/structure/namespaces) puts the method
on the root SDK class, which has no name of its own to lose. The word `default` is a tag the
document never declared, so it would fail the audit on this page if it were written down.