# How to stop a coding agent inventing endpoints not in the spec

> Make an invented path a compile error, refuse the rest at a validation proxy, and review casts as well as tests, because an instruction on its own leaks.

Source: https://voxgig.com/howto/stop-a-coding-agent-inventing-endpoints

- Audience: api-consumer
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## 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`](https://code.claude.com/docs/en/memory). 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](https://openapi-ts.dev/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](https://code.claude.com/docs/en/memory) | 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`](https://docs.stoplight.io/docs/prism/72d69fb629de0-validation-proxy) | 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`](https://code.claude.com/docs/en/memory) the same way, so one
file serves several agents.

```markdown title="AGENTS.md"
# 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](https://spec.openapis.org/oas/v3.1.0.html#operation-object) 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.

```bash
npx openapi-typescript meters.json -o api.d.ts
```

[openapi-typescript](https://openapi-ts.dev/introduction) writes one `paths` interface keyed by the
literal path strings in the document. [openapi-fetch](https://openapi-ts.dev/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.

```ts title="client.ts"
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:

```ts title="off-spec.ts"
// 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
}
```

```bash
npx tsc --noEmit -p tsconfig.json
```

```text output
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`](https://www.typescriptlang.org/tsconfig/#noUncheckedIndexedAccess),
which the openapi-fetch documentation recommends.

## Refuse the rest at the proxy

[Prism](https://docs.stoplight.io/docs/prism/72d69fb629de0-validation-proxy) 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.

```js title="prism.mjs"
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'] })
```

```bash
node --experimental-strip-types proxy.mjs
```

```text output
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&region=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&region=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](https://docs.stoplight.io/docs/prism/cfa6f7191e544-errors) 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.

```js title="audit.mjs"
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
}
```

```bash
node audit.mjs
```

```text output
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.

```bash
node --experimental-strip-types --test audit.test.mjs proxy.test.mjs
```

```text output
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

- [Measure drift between two AI-written clients](/howto/measure-drift-between-two-ai-written-clients)

- [Let a coding agent probe a live API through an MCP server](/howto/probe-live-api-responses-through-an-mcp-server)

- [Validate responses against the OpenAPI document at runtime](/howto/validate-responses-against-the-openapi-document)

- [Let a coding agent customize a generated SDK safely](/howto/customise-a-generated-sdk-with-an-agent-safely)

## 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.

[^1]: The manifest of openapi-typescript names TypeScript as a peer at
[`^5.x`](https://raw.githubusercontent.com/openapi-ts/openapi-typescript/main/packages/openapi-typescript/package.json),
and the `latest` tag on [npm](https://www.npmjs.com/package/typescript) is 7.0.2, so a fresh
`npm install` of the pair fails to resolve. The
gap is a native port. The [announcement](https://devblogs.microsoft.com/typescript/typescript-native-port/)
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.

[^2]: The [operation object](https://spec.openapis.org/oas/v3.1.0.html#operation-object) gives
`operationId` four 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.

[^3]: Prism's error bodies cite [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807), Problem
Details for HTTP APIs, from March 2016. The RFC Editor lists it as obsoleted by
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) 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 `type` URI in each body still points at Prism's own error catalogue, which is what
both documents ask of it.