LLMs love inventing endpoints. Spec-first development flips the workflow: OpenAPI is law; models only fill implementation gaps inside schema-defined operations. Handlers, clients, and contract tests are generated mechanically — the agent never gets a blank check to mint routes.
⚡ TL;DR: Treat
openapi.yamlas the only source of truth. Codegen types + stubs; constrain LLM patches to// agent:implregions; reject PRs whose routes drift from the spec. Contract tests assert status codes and schemas on every merge. Pair with Node SDK Generation and Structured Outputs for Codegen.
Generate the skeleton, not the product fantasy
# ✅ Mechanical codegen from OpenAPI
npx openapi-typescript openapi/openapi.yaml -o src/gen/api.ts
npx @hey-api/openapi-ts -i openapi/openapi.yaml -o src/gen/client
node scripts/gen-handler-stubs.mjs # writes src/handlers/*.stub.ts
// src/handlers/createInvoice.stub.ts — GENERATED; do not edit signature
import type { paths } from "../gen/api";
export type CreateInvoice =
paths["/v1/invoices"]["post"];
/** agent:impl — fill body only; signature is owned by OpenAPI */
export async function createInvoice(
req: CreateInvoice["requestBody"]["content"]["application/json"],
ctx: RequestContext
): Promise<CreateInvoice["responses"]["201"]["content"]["application/json"]> {
// LLM may edit inside this function only
throw new Error("not implemented");
}
❌ Letting the agent add /v1/invoice-v2 because “it felt cleaner.”
Constrain agent edits to impl regions
// scripts/agent-impl-gate.ts
import { Project } from "ts-morph";
export function assertOnlyImplRegionsTouched(diffPaths: string[]) {
const proj = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const p of diffPaths) {
if (p.startsWith("src/gen/") || p.endsWith(".stub.ts") || p.includes("openapi/")) {
throw new Error(`forbidden_touch:${p}`);
}
}
}
Combine with Cursor rules / Claude hooks so shell cannot rewrite the spec without a labeled spec-change PR. See Claude Code Hooks.
Contract tests lock the wire
// tests/contract/createInvoice.test.ts
import { openapi } from "../helpers/loadSpec";
import { inject } from "../helpers/app";
test("POST /v1/invoices matches 201 schema", async () => {
const res = await inject("POST", "/v1/invoices", {
customerId: "cus_1",
idempotencyKey: "k1",
lines: [{ sku: "pro", qty: 1 }],
});
expect(res.statusCode).toBe(201);
openapi.assertResponse("post", "/v1/invoices", 201, res.json());
});
If the agent “improves” a field name, CI fails — not production.
PR policy
| Change type | Allowed path | Reviewers |
|---|---|---|
| Spec evolution | openapi/** + regen |
API guild |
| Impl fill | src/handlers/** impl body |
CODEOWNERS |
| Client bump | generated only | automerge after contract green |
| Invented route | none | blocked by gate |
Closing checklist
✅ Dos
– ✅ OpenAPI → types → stubs → agent impl
– ✅ Forbid agent edits under src/gen/ and openapi/
– ✅ Contract-test every operation status + schema
– ✅ Label true spec changes separately from impl PRs
– ✅ Regenerate clients in CI on spec hash change
❌ Don’ts
– ❌ Don’t let models invent paths, verbs, or fields
– ❌ Don’t hand-edit generated clients
– ❌ Don’t skip idempotency fields defined in the spec
– ❌ Don’t merge green unit tests that bypass contract suite
Related reading
- Node SDK Generation: OpenAPI-Driven Clients LLMs Cannot Hallucinate
- Structured Outputs for Codegen: JSON Schemas That Actually Compile
- Claude Code Hooks: Gate Risky Shell Commands Before CI Runs
- LLM Output Validators: Parse Trees Before Accepting Any Patches
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
