How-to › Expose your API to agents

How to publish an OpenAPI document at a stable URL for agents#

Serve the OpenAPI document as JSON and YAML at URLs that do not move, with the headers a browser needs, and a check that fails when the copies drift.

Audience
API producer
Level
beginner
Topic
Make a docs site agent-ready
Verified

A code generator fetches your OpenAPI document and gets a login page. A browser-based client fetches it and the console says the request was blocked by CORS. The URL in your docs is /api/v3/spec?format=json, it has moved once already, and the document it serves is two releases behind the one in the repository.

What you get

You will end up with your OpenAPI document at four URLs that do not move, served with the headers a browser needs, and a check that fails when the published copy drifts. This is for you if anyone fetches your description by URL.

Short answer

Build /openapi.json and /openapi.yaml from the one document in your repository, and publish the same files again under /v1/. Serve them with Content-Type, Access-Control-Allow-Origin: *, and Cache-Control: max-age=300, and keep them out of any bot challenge. Then run a check on a schedule that fetches the URL, validates the document, and compares it with the repository copy, failing on the first difference.

You will need

Node 22 or later, and an OpenAPI 3 document. Verified 2026-09-24 against Node 22.22.2, yaml 2.9.1, and @seriousme/openapi-schema-validator 2.10.0. The OpenAPI Specification allows the document in JSON or YAML and recommends openapi.json and openapi.yaml as the file names, which is where the URL shape comes from. It registers no media type for either.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A runtime route from the frameworkThe framework derives the document from the code, as FastAPI does at /openapi.jsonThe document changes when a dependency changes, and a request-time render is a request-time failureThe description is written by hand, or the served file must equal a committed one
A static file in the build outputAny site with a build step, which is where the docs already liveA build on every change, and headers set in the host’s configuration rather than in the codeThe document is generated from code the docs build cannot see
Redocly hostingReference docs rendered from the same upload, with no server of your ownA vendor between you and the URL, and the raw file lives wherever the vendor puts itYour own domain has to serve the raw document
Scalar hostingThe same, with the Scalar reference and a registry of your versionsThe same vendor dependency, and a canonical URL that is theirs unless you proxy itThe generator or agent needs a URL under your domain

The static file and the runtime route disagree about what the document is. A static file is an artifact you committed, so it cannot drift from the repository, and it cannot know anything the build did not. A runtime route is derived from the running code, so it is always current and never checked in, and a bug in the derivation ships to every client at once. The hosted platforms remove the server from your hands and put the URL in theirs.

Whichever you pick, the check at the end of this page is the same: fetch what is published, validate it, and compare it with what you meant to publish.

Decide the URL shape first

Two URLs per format. /openapi.json tracks the current release, and /v1/openapi.json is the same document pinned under its major version, so a client that generated code against v1 can keep fetching v1 after v2 ships.

export function build({ source = 'openapi.json', out = 'dist' } = {}) {
  const doc = JSON.parse(readFileSync(source, 'utf8'))
  const major = `v${doc.info.version.split('.')[0]}`
  const json = JSON.stringify(doc, null, 2) + '\n'
  const yaml = stringify(doc)
  const written = []
  for (const dir of [out, join(out, major)]) {
    mkdirSync(dir, { recursive: true })
    for (const [name, text] of [['openapi.json', json], ['openapi.yaml', yaml]]) {
      writeFileSync(join(dir, name), text)
      written.push(join(dir, name))
    }
  }
  return { version: doc.info.version, major, written }
}

Both formats come from one parse, so they cannot disagree. The YAML is written by the yaml package, because a hand-rolled emitter is the kind of code that works until a description contains a colon.

The major comes from info.version, which means the per-major URL is derived from the document and not from a second place that has to be kept in step. When info.version becomes 2.0.0, /v2/openapi.json appears and /v1/openapi.json keeps serving the last v1 build, as long as the deploy does not delete it.

Set the three headers

A URL that answers curl and fails in a browser is missing one header. A URL that gets fetched by every agent on every call is missing another.

export function headersFor(path, body) {
  const ext = path.slice(path.lastIndexOf('.'))
  return {
    'content-type': `${TYPES[ext]}; charset=utf-8`,
    // The document is public, and a browser-based client cannot read it without this.
    'access-control-allow-origin': '*',
    'access-control-allow-methods': 'GET, HEAD, OPTIONS',
    'access-control-allow-headers': '*',
    // Five minutes in any cache, then a conditional request against the ETag.
    'cache-control': 'public, max-age=300',
    etag: `"${createHash('sha256').update(body).digest('hex').slice(0, 16)}"`,
  }
}

application/yaml is the type RFC 9512 registered for YAML. The JSON copy is application/json.

Access-Control-Allow-Origin: * is what the CORS protocol requires before a browser hands a cross-origin response to the page that asked for it. A wildcard is right here because the document is public and the request carries no credentials. The OPTIONS handling exists for the preflight a browser sends when the client adds a header of its own.

max-age=300 with an ETag means a generator that fetches on every run costs you one request per five minutes per cache, and a conditional request after that. RFC 9111 defines the directive. Five minutes is also the longest a fixed document will lag a deploy, which is a trade you can explain.

Check it worked

Build, serve, fetch all four URLs plus one that should not exist, then run the check twice: once against a faithful copy and once after the served copy has fallen behind.

node demo.mjs
built 4 files for version 1.4.0, major v1
/openapi.json      200  application/json; charset=utf-8  public, max-age=300  cors *
/openapi.yaml      200  application/yaml; charset=utf-8  public, max-age=300  cors *
/v1/openapi.json   200  application/json; charset=utf-8  public, max-age=300  cors *
/v1/openapi.yaml   200  application/yaml; charset=utf-8  public, max-age=300  cors *
/v2/openapi.json   404  application/json                 -                    cors -
check: ok, version 1.4.0 matches openapi.json
check: FAIL
  published document differs from openapi.json at $.info.version

The last two lines are the reason to have the check. The served copy was edited to say 1.3.0 and to drop a response, and the check names the first path at which the two documents differ. Nothing about the URL, the headers, or the schema changed, so nothing else would have noticed.

The test suite covers the round trip from JSON to YAML and back, the headers, the 304 on a matching ETag, the check’s four refusals, and the echoed Origin.

node --test check.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 933.893296

Fail the build when the copies disagree

The check fetches the published URL the way a browser would, with an Origin header, and refuses on any of four counts: the headers, the JSON, the schema, and the diff. A server that echoes the Origin back, with Vary: Origin, passes the header check, because a browser reads that as readily as *.

  const type = res.headers.get('content-type') ?? ''
  if (!type.startsWith('application/json')) problems.push(`content-type is ${type || 'missing'}, expected application/json`)
  const allow = res.headers.get('access-control-allow-origin')
  if (allow !== '*' && allow !== ORIGIN) problems.push(`no Access-Control-Allow-Origin header for ${ORIGIN}, so a browser cannot read it`)
  if (!res.headers.get('cache-control')) problems.push('no Cache-Control header, so every fetch reaches the origin')

  let published
  try {
    published = JSON.parse(await res.text())
  } catch (err) {
    return { ok: false, problems: [...problems, `body is not JSON: ${err.message}`] }
  }
  if (published === null || typeof published !== 'object' || Array.isArray(published)) {
    return { ok: false, problems: [...problems, 'body is not a JSON object'] }
  }

  const result = await new Validator().validate(published)
  if (!result.valid) {
    // A document with no usable `openapi` version comes back as one string, not a list.
    const [first] = Array.isArray(result.errors) ? result.errors : []
    problems.push(`not a valid OpenAPI document: ${first ? `${first.instancePath || '$'} ${first.message}` : result.errors}`)
  }

  const local = JSON.parse(readFileSync(source, 'utf8'))
  const diff = firstDifference(local, published)
  if (diff) problems.push(`published document differs from ${source} at ${diff}`)

The validator checks the document against the OpenAPI JSON Schema for its declared version, which catches a truncated upload or a wrong file at the URL. The diff catches the case the schema cannot: a valid document that is not the one in the repository.

Run it on a schedule rather than only on deploy, because the failure it catches is the deploy that did not happen.

on:
  schedule:
    - cron: "17 6 * * *"
  workflow_dispatch:

When it goes wrong

The document works from curl and every browser-based client fails. Two causes, and they look alike from the terminal. Three servers, one file, two kinds of client:

node pitfall.mjs
server               curl   browser
no CORS header       200    blocked, no Access-Control-Allow-Origin
bot challenge        403    200
CORS, no challenge   200    200

The first row is the missing header. The server answered 200, the bytes arrived, and the browser refused to hand them to the page, which is what Access-Control-Allow-Origin governs. The second row is the opposite failure: a bot challenge in front of the site answered the browser and blocked everything else, which is every generator and every agent. Exempt the document’s path from the challenge; Cloudflare’s bot products and their equivalents all take a path rule.

The YAML and the JSON disagree. Two build steps, or a hand-edited copy. Write both from one parse, as build.mjs does, and let the test round-trip the YAML back to the source object.

The check fails the morning after a release. The deploy shipped the site and not the document, or a CDN is still serving the old copy past its max-age. Purge the path, and put the check after the deploy as well as on the schedule.

/v1/openapi.json vanished when v2 shipped. The build writes only the current major, and the deploy cleared the directory. Keep the previous major’s file as a committed artifact, or serve it from the last v1 build’s output.

When not to do this

Do not publish a document that describes endpoints you have not shipped. A generator will build a client for them, an agent will call them, and the 404 will be filed as your bug. Publish what is live, and keep the draft on a branch.

Do not put the document behind the same login as the API. A description is not a credential, and the client that needs it most is the one that has not authenticated yet. If the API is private, put the document behind the same network boundary and nothing more.

Do not serve the runtime-generated route as the stable URL when the description is hand written. The two will differ, and the route wins because it is the one at the URL. Pick one source and publish that.

Do not skip the check because the file is static. Static is how the copy that was uploaded once in March is still there in September, valid and wrong.2

Last verified

Verified 2026-09-24 against Node 22.22.2, yaml 2.9.1, and @seriousme/openapi-schema-validator 2.10.0. Every output block is what the command preceding it printed. The servers are local stand-ins started by the demo; the bot challenge is a user agent check, not a vendor’s product.

  1. Twenty-two years is a long time for a format to travel as text/plain.

Footnotes

  1. There is no registered media type for an OpenAPI document. An Internet-Draft, draft-polli-rest-api-mediatypes, proposed application/openapi+json and application/openapi+yaml, and the Datatracker files its first revision as expired. YAML itself waited longer: RFC 9512 registered application/yaml in February 2024, and the YAML specification history begins in May ↩︎ Back to text

  2. APIs.guru keeps a directory of public OpenAPI documents, and its machine-readable listing records, for every entry, the URL the document was harvested from. On the day this page was written it held 2529 APIs. A directory that size is an argument for stable URLs made by other people, several thousand times, without meaning to. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.