# Writing the OpenAPI Spec

<Lead>

The OpenAPI document **is** the v4 contract. The portal, the CLI, the MCP server,
SDKs, Terraform and these docs are all generated from it, so its quality is the
API's quality.

</Lead>

## <N n="1" />Where things live

<Tiles>
<Tile tone="do" label="Edit this" code="spec/openapi.yaml">

The contract: plain OpenAPI 3.1, written and reviewed by people.

</Tile>
<Tile tone="dont" label="Generated: never edit" code="config/routes.oas.json">

Generated by `npm run build:routes`, which adds the Zuplo route configuration
(`x-zuplo-route`) to every operation. Zuplo serves it and Zudoku documents it. CI
fails if it is stale.

</Tile>
<Tile label="Gateway policies" code="config/policies.json">

Today: request validation against the contract.

</Tile>
<Tile label="The rules as code" code="lint/">

`lint/ruleset.yaml` and `lint/rules/`: the rules on these pages, as code.

</Tile>
</Tiles>

<Rule id="V4-OAS-5" lint="v4-zuplo-route" shared="SPEC-1">

`config/routes.oas.json` is generated from `spec/openapi.yaml`, and every
operation is a Zuplo route.

</Rule>

v4 is **design-first**. The contract is written and reviewed before services
implement it, and services are tested against it. That is a deliberate difference
from SPEC-1 ("specs are generated from code"), which suits an existing fleet.

## <N n="2" />Document rules

<Rule id="V4-OAS-4" lint="v4-document">

OpenAPI 3.1, one server (`https://api.zuplo.com`), the conventions and rate
limits explained in `info.description`, and the shared components present.

</Rule>

<DoDont>
<Do title="OpenAPI 3.1 nullable values">

```yaml
description:
  type: [string, "null"]
```

</Do>
<Dont title="OpenAPI 3.0 style">

```yaml
description:
  type: string
  nullable: true
```

</Dont>
</DoDont>

The version is part of every path, so the server URL has none. The shared
building blocks exist once, in `components`, and are referenced everywhere:

<Fields title="Shared components">
<Field name="schemas">

`Problem`

</Field>
<Field name="responses">

The error responses: `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`,
`Conflict`, `TooManyRequests`, `ServerError`

</Field>
<Field name="parameters">

`Limit`, `Offset`

</Field>
<Field name="headers">

`Location`, `Retry-After`, `WWW-Authenticate`

</Field>
</Fields>

## <N n="3" />Operations

<Rule id="V4-OAS-1" lint="v4-operation-docs v4-document">

A summary of at most 60 characters, a real description, and exactly one declared
tag. Every declared tag is in exactly one `x-tagGroups` group.

</Rule>

<DoDont>
<Do title="Short summary, full description">

```yaml
summary: Create an app
description: |
  Creates an app in the pool given by `poolId`. Without
  `policies`, it starts with a copy of the pool's policy
  template. Creating an app does not issue a key.
tags: [Apps]
```

</Do>
<Dont title="Summary and description swapped">

```yaml
summary: This endpoint creates a new app.
description: Creates an app.
tags: [apps, ai-gateway]
```

</Dont>
</DoDont>

<Tiles>
<Tile code="summary">

A short imperative phrase, at most 60 characters, no trailing period: "Create an
app", "List a pool's apps".

</Tile>
<Tile code="description">

At least a full sentence. Say what the operation does, its side effects, when
changes take effect, and which errors a client should expect for domain reasons
("Fails with `409` while the pool still has apps"). Agents read this text to
choose a tool, so write it for them as much as for people.

</Tile>
<Tile code="tags">

**Exactly one** per operation, declared in the top-level `tags` with a
description of the resource. Tags are Title Case plural nouns ("Apps") and
become the sections of the API reference.

</Tile>
<Tile code="x-tagGroups">

Nests the tags in the sidebar (the "AI Gateway" group holds the AI Gateway
resources). Every declared tag is in exactly one group, and a group named like its
only tag shows as that tag. A group named like one of several tags lists that
tag's operations above the others. For a section named like its group, name the
tag differently and show the group's name with `x-displayName`: the "API Keys"
group's keys are the tag `Keys`, shown as "API Keys".

</Tile>
<Tile code="requestBody">

Has a `description`. Create operations should have at least one realistic request
example.

</Tile>
</Tiles>

## <N n="4" />Operation IDs

<Rule id="V4-OAS-2" lint="v4-operation-id" shared="NAME-5">

operationIds are derived mechanically from the method and the URL shape.

</Rule>

operationIds become SDK method names and MCP tool names, so there is nothing to
bikeshed:

<Flow>
<FlowStep label="Method and URL" title={<Path method="GET" p="/v4/buckets/{bucketId}/ai/pools/{poolId}/members" />} />
<FlowStep label="operationId" code="list-ai-pool-members" />
</Flow>

| Shape and method | operationId | Example |
| --- | --- | --- |
| <Method m="GET" /> collection | `list-{collection}` | `list-ai-pools`, `list-consumer-api-keys` |
| <Method m="POST" /> collection | `create-{resource}` | `create-ai-pool`, `create-ai-user-consumer` |
| <Method m="GET" /> item or singleton | `get-{resource}` | `get-ai-pool`, `get-ai-user-app-usage` |
| <Method m="PATCH" /> | `update-{resource}` | `update-ai-pool` |
| <Method m="POST" /> association collection, <Method m="PUT" /> | `upsert-{resource}` | `upsert-ai-pool-member`, `upsert-ai-user-budget` |
| <Method m="DELETE" /> | `delete-{resource}` | `delete-api-key` |

Words between the verb and the resource say whose it is, for an association or a
credential under its owner (`list-ai-pool-members`, `upsert-ai-app-consumer`,
`list-ai-user-consumers`).
SDK methods and MCP tools have no namespaces, so a path in a
[namespace](/guidelines/urls#collection-names-and-namespaces) names it right after
the verb: <Path p="/v4/buckets/{bucketId}/ai/pools/{poolId}" /> is `get-ai-pool`, never `get-pool`.

## <N n="5" />Bodies and schemas

<Rule id="V4-OAS-3" lint="v4-request-bodies v4-success-responses">

Request bodies only on `POST`, `PUT` and `PATCH`, named
`{Verb}{Resource}Request`, where the verb is `Create`, `Update` or `Upsert`
(`PUT`, and the `POST` that upserts an association).
Request and success bodies are always
`application/json` and always reference a named schema in `components.schemas`.

</Rule>

<DoDont>
<Do title="Reference a named schema">

```yaml
requestBody:
  description: The new app.
  content:
    application/json:
      schema:
        $ref: "#/components/schemas/CreateAiAppRequest"
```

</Do>
<Dont title="An inline schema">

```yaml
requestBody:
  content:
    application/json:
      schema:
        type: object
        properties: …
```

Names are what SDKs and tools show.

</Dont>
</DoDont>

<Specimens cols={2}>
<Specimen label="Create" sample="CreateAiAppRequest" casing="Create{Resource}Request" />
<Specimen label="Update" sample="UpdateAiAppRequest" casing="Update{Resource}Request" />
<Specimen label="Upsert" sample="UpsertAiPoolMemberRequest" casing="Upsert{Resource}Request" />
<Specimen label="Nested request object" sample="BudgetRuleInput" casing="…Input" />
<Specimen label="Resource" sample="AiApp" casing="{Resource}" />
<Specimen label="List response" sample="AiAppList" casing="{Resource}List" />
<Specimen label="Secret-revealing response" sample="ApiKeyWithSecret" casing="{Resource}WithSecret" />
</Specimens>

Every schema, property and parameter has a `description`. Use `examples` on
properties (IDs, names, model references) so the generated docs and tools show
realistic values.

## <N n="6" />The lint gate

`npm run lint` runs three checks, and all three must pass:

<Flow>
<FlowStep label="1" code="check:routes">

`config/routes.oas.json` matches `spec/openapi.yaml`.

</FlowStep>
<FlowStep label="2" code="lint:api">

vacuum with `lint/ruleset.yaml`, failing on **any** finding, warnings included.

</FlowStep>
<FlowStep label="3" code="test:lint">

Every rule is tested against the real spec (must pass) and against deliberately
broken copies (must fail with the expected message).

</FlowStep>
</Flow>

<DoDont>
<Do title="Fix rule, test and page">

If a rule blocks something that is right, the rule is wrong. Change all three in
the same pull request.

</Do>
<Dont title="Ignore, disable or weaken a rule">

**There are no ignore files.** A spec with a single finding does not merge.

</Dont>
</DoDont>

## <N n="7" />Adding an endpoint

<Stepper toc={false}>

1. **Pick the owner and the URL shape** ([URLs](/guidelines/urls)). New
   collection names go into the table on that page, and new singletons into
   `lint/lib.js`.
1. **Add the path to `spec/openapi.yaml`**, referencing the shared parameters,
   headers and error responses.
1. **Add the schemas**: `{Resource}`, `{Resource}List` and the request schemas.
1. **Run the gate**: `npm run build:routes && npm run lint`.
1. **Check the result in the docs** (`npm run docs`), the same way a customer
   would see it.

</Stepper>

<EnforcedBy
  lint="v4-zuplo-route v4-document v4-operation-docs v4-operation-id v4-request-bodies"
  checks="Every operation is a Zuplo route (CI also checks that the routes file is current); the document rules and x-tagGroups; operation docs and tags; operationIds; named request bodies."
>

That descriptions are written for agents as much as for people, and that create
operations have a realistic request example.

</EnforcedBy>
