The agent’s client compiles, its tests pass against a mock the agent also wrote, and the first
call in staging answers 404. The task mentioned a summary, so the agent wrote
GET /meters/summary, an endpoint the API does not have. Nothing in the loop held a copy of
the document, so the invented path travelled from prompt to pull request without meeting an
objection.
What you get
You will end up with three layers that refuse a path the document does not describe: an instruction, a typed client, and a validation proxy. An audit reads the client back against the document. This is for you if a coding agent writes API calls in your codebase.
Short answer
Give the agent the OpenAPI document and a rule to cite an operationId on every call. Then make
the rule unnecessary. Generate paths types with openapi-typescript and call through
openapi-fetch, so an unknown path fails tsc. Run the tests through prism proxy --errors, so an
off-spec request is refused at run time. Review casts as well as tests, because as any makes
the type error disappear without making the endpoint exist.
You will need
Node 22 or later, an OpenAPI 3 document for the API, and a coding agent that reads an
instruction file such as AGENTS.md. Verified
2026-09-25 against Node 22.22.2, openapi-typescript 7.13.0, openapi-fetch 0.17.0, TypeScript
5.9.3,1 and @stoplight/prism-cli 5.14.2. The Prism pin matters on a Node 22 runner: 5.15.7
and later declare Node 24 in engines, and 5.14.2 is the last release that does not.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| openapi-typescript and openapi-fetch | TypeScript, and the agent calls the API through one client module | A generated types file to refresh on every document change, and a build that runs tsc or the types check nothing | The client is not TypeScript, or paths are assembled at run time |
| operationId citation in the prompt | Any language, any agent, the first afternoon | Nothing enforces it, so a confident agent cites an operation that does not exist and carries on | You need a guarantee rather than a habit, which is what the other two rows are |
Prism proxy with --errors | Tests run against an upstream you can put a proxy in front of | A process to start in every test run, and a template such as /meters/{meterId} that swallows an invented sub-path | There is no upstream to proxy, or the document carries no schemas to validate against |
The instruction costs nothing and enforces nothing. The types refuse, at edit time, whatever shape the document carries: a path, a path parameter, a body field, a parameter’s type. The proxy refuses, at run time, the values the types cannot see, such as a maximum, and it forwards an invented path that happens to fit a template. Use all three. Each catches what the one before it lets through.
Write the rule down, then stop relying on it
The instruction goes where the agent reads it at the start of every session. Claude Code loads
CLAUDE.md from the working directory and every directory above it, and
reads a repository’s AGENTS.md the same way, so one
file serves several agents.
# Meters integration
The API is described by `meters.json`, an OpenAPI 3.1 document. It is the
only source of truth for paths, parameters, and fields.
- Every request you write must cite the `operationId` it implements, in a
comment on the line before the call: `// operationId: listMeters`.
- If the document has no operation for what the task needs, stop and say
so. Do not guess a path, a parameter, or a field.
- Call the API through the typed client in `client.ts`. Do not build URLs
by hand and do not call `fetch` directly.
- Never widen a type or cast to `any` to make a call compile. A type error
on a path means the path is not in the document.
- Run `npm run typecheck` and `node audit.mjs` before you report done.
The rule leans on the one identifier the document guarantees. The
OpenAPI specification requires an
operationId to be unique among all operations described in the API, and says tools may use it
to identify an operation.2 A citation is therefore checkable: either the id is in the document
or it is not.
Checkable is not enforced. An agent that has decided a summary endpoint exists cites
getMeterSummary with the same confidence it cites listMeters, and the comment reads as
evidence to a reviewer who does not look it up. Keep the instruction, and put the enforcement in
the next two layers.
Make an unknown path a compile error
Generate the types from the document, and regenerate them whenever the document changes.
npx openapi-typescript meters.json -o api.d.ts
openapi-typescript writes one paths interface keyed by the
literal path strings in the document. openapi-fetch
takes that interface as a type parameter, and its GET and POST accept only those keys, with
the parameters and body each path declares.
export function makeClient(baseUrl: string) {
const client = createClient<paths>({ baseUrl })
return {
// operationId: listMeters
async listMeters(limit = 20) {
const { data, error, response } = await client.GET('/meters', {
params: { query: { limit } },
})
if (error) throw new Error(`listMeters failed with ${response.status}`)
return data.items
},
// operationId: getMeter
async getMeter(meterId: string) {
const { data, error, response } = await client.GET('/meters/{meterId}', {
params: { path: { meterId } },
})
if (error) throw new Error(`getMeter failed with ${response.status}`)
return data
},
That client compiles. The agent’s next file is the test of the layer. It holds the invented endpoint, and an invented query parameter on a real operation:
// operationId: getMeterSummary
// The task asked for a summary. The document has no such operation, and the
// agent wrote the path it expected to exist.
export async function meterSummary() {
const { data } = await client.GET('/meters/summary', {})
return data
}
// operationId: listMeters
// A real operation, with a query parameter the document does not define.
export async function listMetersInRegion(region: string) {
const { data } = await client.GET('/meters', {
params: { query: { limit: 20, region } },
})
return data?.items
}
npx tsc --noEmit -p tsconfig.json
off-spec.ts(10,37): error TS2345: Argument of type '"/meters/summary"' is not assignable to parameter of type 'PathsWithMethod<paths, "get">'.
One error, for the path. region produced none: the generated types accept a query key the
document does not declare, so an invented parameter compiles where an invented path does not.
limit: 500 compiles as well, because the document’s maximum of 100 is a constraint the types
do not carry. Both are what the proxy is for.
tsc has to run for any of this to count. Put tsc --noEmit in the script the agent runs
before it reports done, and turn on
noUncheckedIndexedAccess,
which the openapi-fetch documentation recommends.
Refuse the rest at the proxy
Prism sits between the
client and an upstream, checks each request and response against the document, and with
--errors answers a violation itself instead of forwarding it. The demo starts a stand-in
upstream on a loopback port, then two proxies: one with the flag, one without.
export async function startProxy(upstreamUrl, { errors = true } = {}) {
const port = await freePort()
const pkg = require.resolve('@stoplight/prism-cli/package.json')
const bin = join(dirname(pkg), require('@stoplight/prism-cli/package.json').bin.prism)
const args = ['proxy', join(here, 'meters.json'), upstreamUrl, '-p', String(port), '-h', '127.0.0.1']
if (errors) args.push('--errors')
const child = spawn(process.execPath, [bin, ...args], { cwd: here, stdio: ['ignore', 'pipe', 'pipe'] })
node --experimental-strip-types proxy.mjs
prism 5.14.2, --errors, upstream at http://127.0.0.1:35559
listMeters through the typed client 1 item
GET /summary 404 NO_PATH_MATCHED_ERROR
The route /summary hasn't been found in the specification file
GET /meters/summary 200
{"id":"m-1","reading":12.5,"unit":"kWh"}
GET /meters?limit=500 422 UNPROCESSABLE_ENTITY
Request query parameter limit must be <= 100
POST /meters {"unit":"kWh","name":"x"} 422 UNPROCESSABLE_ENTITY
Request body must NOT have additional properties; found 'name'
GET /meters?limit=20®ion=eu 200
{"items":[{"id":"m-1","reading":12.5,"unit":"kWh"}]}
prism 5.14.2, no --errors, upstream at http://127.0.0.1:35559
listMeters through the typed client 1 item
GET /summary 200
{"answered":"/summary","documented":false}
sl-violations: [{"location":["request"],"severity":"Warning","message":"Selected route not found"}]
GET /meters/summary 200
{"id":"m-1","reading":12.5,"unit":"kWh"}
GET /meters?limit=500 200
{"items":[{"id":"m-1","reading":12.5,"unit":"kWh"}]}
sl-violations: [{"location":["request","query","limit"],"severity":"Error","code":"maximum","message":"Request query parameter limit must be <= 100"}]
POST /meters {"unit":"kWh","name":"x"} 201
{"id":"m-2","reading":0,"unit":"kWh"}
sl-violations: [{"location":["request","body"],"severity":"Error","code":"additionalProperties","message":"Request body must NOT have additional properties; found 'name'"}]
GET /meters?limit=20®ion=eu 200
{"items":[{"id":"m-1","reading":12.5,"unit":"kWh"}]}
Read the first block line by line. /summary is refused with 404 and a
problem details body naming the
rule.3 /meters/summary is forwarded and answered with meter m-1: it matches the template
/meters/{meterId} with meterId set to summary, and the proxy cannot know the agent meant an
endpoint that does not exist. The typed client refused that path at compile time, which is why
the layers are stacked rather than chosen between. limit=500 and the extra body field are
refused with 422, the two cases the types let through. region=eu passes both layers, because
an undeclared query parameter is not a violation of the document.
The second block is the same proxy without --errors. Every request is forwarded, the upstream
creates the meter with the extra field, and the violations arrive in an sl-violations header
that nothing in a test suite reads unless told to. Run the tests with the flag on.
Read the client back against the document
The last check is a script rather than a type, because the pitfall is an agent that fixes the
type error instead of the path. It reads every TypeScript file in the directory, and
tsconfig.json includes the same set, so the first file an agent adds is checked by both
without anyone listing it.
const CASTS = [
[/\bas\s+any\b/, 'as any'],
[/\bas\s+unknown\s+as\b/, 'as unknown as'],
[/\bas\s+never\b/, 'as never'],
[/@ts-ignore\b/, '@ts-ignore'],
[/@ts-expect-error\b/, '@ts-expect-error'],
[/\bas\s+keyof\s+paths\b/, 'as keyof paths'],
[/@ts-nocheck\b/, '@ts-nocheck'],
[/:\s*any\b/, ': any'],
]
export function auditSource(name, source, index) {
const findings = []
const lines = source.split('\n')
lines.forEach((line, i) => {
const at = `${name}:${i + 1}`
for (const m of line.matchAll(PATH_LITERAL)) {
if (!index.paths.has(m[1])) findings.push({ kind: 'path', at, detail: `${m[1]} is not in the document` })
}
for (const m of line.matchAll(CITATION)) {
if (!index.operationIds.has(m[1])) findings.push({ kind: 'operationId', at, detail: `${m[1]} is not in the document` })
}
for (const [re, label] of CASTS) {
if (re.test(line)) findings.push({ kind: 'cast', at, detail: `${label} bypasses the generated types` })
}
})
return findings
}
node audit.mjs
openapi-fetch 0.17.0, 2 paths and 3 operations in meters.json
operationId cast.ts:6 getMeterSummary is not in the document
path cast.ts:10 /meters/summary is not in the document
cast cast.ts:10 as any bypasses the generated types
cast cast.ts:15 @ts-expect-error bypasses the generated types
path cast.ts:16 /meters/summary is not in the document
operationId off-spec.ts:6 getMeterSummary is not in the document
path off-spec.ts:10 /meters/summary is not in the document
7 findings
cast.ts is off-spec.ts after the fix. client as any and @ts-expect-error both compile, but
the endpoint still does not exist. Review the casts in an agent’s diff with the attention you
give the tests, and treat a cast that appears in a client module as a finding until someone
explains it. The audit reports @ts-nocheck and an any annotation the same way, because each
turns the types off without a cast.
Check it worked
Run the two suites. The proxy suite starts Prism once and asserts the status and the problem type of each refusal.
node --experimental-strip-types --test audit.test.mjs proxy.test.mjs
1..17
# tests 17
# suites 0
# pass 17
# fail 0
# cancelled 0
# skipped 0
# todo 0
# pass 17, and the two tests worth reading are the ones that pin the limits: an invented
sub-path is forwarded as its template, and an undeclared query parameter is forwarded as well.
When it goes wrong
tsc says Expected 2 arguments, but got 1 on the invented call. That is the path error in a
poor disguise. When the path is not a key of paths, the second argument stops being optional,
and the arity complaint arrives before the type complaint. Add {} as the second argument and
the message names the path.
error has type never and the client does not compile. The document declares no error
responses for the operation, so openapi-fetch types error as impossible and the branch that
reads response.status becomes unreachable. Declare a default response with a problem schema,
as meters.json does, and regenerate the types.
Prism exits before it listens, with Invalid upstream URL provided. The upstream argument was
given without a scheme. Prism needs http:// or https:// in front of the upstream, and it does
not read the document’s servers entry to find it. A documented request that answers 500 with a
FetchError naming ECONNREFUSED means the port is wrong, or the upstream is not running.
The agent adds the endpoint to meters.json. The compile error goes, the audit goes green, and
the document now describes an endpoint the API does not serve. Review a change to the document
with more suspicion than a change to the client, and keep the document owned by the API’s team
rather than by the integration.
When not to do this
Do not put the validation proxy on the path of production traffic. It is a test fixture, and Prism’s own documentation says to keep it out of the critical path. Its place is in front of a staging upstream or a mock, in the test run the agent has to pass.
Do not read the generated types as a validator of values. They carry a path, a parameter’s name and type, and a body’s fields. They do not carry a maximum, a pattern, or an enumerated query value, and they accept a query key the document never declared. Anything a value can get wrong is the proxy’s job or the API’s.
Do not apply the typed client to code that builds paths at run time. openapi-fetch infers the
type from the literal string, so [...parts].join('/') gets no check at all, and an agent that
finds the literal refused will reach for the join. Refuse that in review as you would a cast.
Do not run the proxy without --errors and call the result a gate. A violation that arrives in
a response header beside a green test is a violation nobody read.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, openapi-typescript 7.13.0, openapi-fetch 0.17.0,
TypeScript 5.9.3, and @stoplight/prism-cli 5.14.2. Every output block is what the command
preceding it printed. The upstream behind the proxy is a stand-in on a loopback port, not a real
service.
Footnotes
-
The manifest of openapi-typescript names TypeScript as a peer at
^5.x, and thelatesttag on npm is 7.0.2, so a freshnpm installof the pair fails to resolve. The gap is a native port. The announcement of March 2025 promised a compiler written in Go that would cut most build times by ten times. It said the JavaScript codebase would continue as the 6.x series, and reserved the number 7 for the port. A peer range written for one implementation was overtaken by a second, and the pin on this page is the answer until the range moves. ↩︎ Back to text -
The operation object gives
operationIdfour sentences, and two of them are addressed to other people. The id MUST be unique among all operations described in the API, and it is case-sensitive. Then: tools and libraries MAY use it to uniquely identify an operation, so it is RECOMMENDED to follow common programming naming conventions. It is a field defined for the convenience of whoever comes along later, which on this page is the agent. ↩︎ Back to text -
Prism’s error bodies cite RFC 7807, Problem Details for HTTP APIs, from March 2016. The RFC Editor lists it as obsoleted by RFC 9457 in July 2023. The replacement has the same title, the same two authors plus one, and an abstract that says the format exists to avoid the need to define new error response formats. Its first act was to give the existing one a new number. The
typeURI in each body still points at Prism’s own error catalogue, which is what both documents ask of it. ↩︎ Back to text