Writing the OpenAPI Spec
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.
Where things live
spec/openapi.yamlThe contract: plain OpenAPI 3.1, written and reviewed by people.
config/routes.oas.jsonGenerated 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.
config/policies.jsonToday: request validation against the contract.
lint/lint/ruleset.yaml and lint/rules/: the rules on these pages, as code.
config/routes.oas.json is generated from spec/openapi.yaml, and every
operation is a Zuplo route.
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.
Document rules
OpenAPI 3.1, one server (https://api.zuplo.com), the conventions and rate
limits explained in info.description, and the shared components present.
Code
Code
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:
Problem
The error responses: BadRequest, Unauthorized, Forbidden, NotFound,
Conflict, TooManyRequests, ServerError
Limit, Offset
Location, Retry-After, WWW-Authenticate
Operations
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.
Code
Code
summaryA short imperative phrase, at most 60 characters, no trailing period: "Create an app", "List a pool's apps".
descriptionAt 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.
tagsExactly 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.
x-tagGroupsNests 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".
requestBodyHas a description. Create operations should have at least one realistic request
example.
Operation IDs
operationIds become SDK method names and MCP tool names, so there is nothing to bikeshed:
/v4/buckets/{bucketId}/ai/pools/{poolId}/memberslist-ai-pool-members| Shape and method | operationId | Example |
|---|---|---|
| GET collection | list-{collection} | list-ai-pools, list-consumer-api-keys |
| POST collection | create-{resource} | create-ai-pool, create-ai-user-consumer |
| GET item or singleton | get-{resource} | get-ai-pool, get-ai-user-app-usage |
| PATCH | update-{resource} | update-ai-pool |
| POST association collection, PUT | upsert-{resource} | upsert-ai-pool-member, upsert-ai-user-budget |
| 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 names it right after
the verb: /v4/buckets/{bucketId}/ai/pools/{poolId} is get-ai-pool, never get-pool.
Bodies and schemas
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.
Code
Code
Names are what SDKs and tools show.
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.
The lint gate
npm run lint runs three checks, and all three must pass:
check:routesconfig/routes.oas.json matches spec/openapi.yaml.
lint:apivacuum with lint/ruleset.yaml, failing on any finding, warnings included.
test:lintEvery rule is tested against the real spec (must pass) and against deliberately broken copies (must fail with the expected message).
If a rule blocks something that is right, the rule is wrong. Change all three in the same pull request.
There are no ignore files. A spec with a single finding does not merge.
Adding an endpoint
- Pick the owner and the URL shape (URLs). New
collection names go into the table on that page, and new singletons into
lint/lib.js. - Add the path to
spec/openapi.yaml, referencing the shared parameters, headers and error responses. - Add the schemas:
{Resource},{Resource}Listand the request schemas. - Run the gate:
npm run build:routes && npm run lint. - Check the result in the docs (
npm run docs), the same way a customer would see it.
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.