Ask a coding agent to “add refunds to the billing API” and it will happily write a handler for POST /v1/invoice-refunds, rename quantity to qty because it looks tidier, and update the client to match. Unit tests pass, because the agent wrote those too. The first sign of trouble is a mobile app that can’t parse the response.
Spec-first development prevents this by making the API contract a file that code is generated from and checked against, not documentation written afterwards. For AI-assisted teams the rule is simple: the agent may implement what the spec says, and may propose changes to the spec, but it can never change the contract by editing code. This guide shows how to enforce that with ordinary tools: generated TypeScript types, a diff gate, a drift test and a breaking-change check. It merges our earlier bootcamp lesson on spec-first workflows for coding agents into this guide.
⚡ TL;DR
- Keep
openapi.yamlin the repo as the contract. Generate types and clients from it; never hand-edit generated files.- Derive handler signatures from generated types, so a renamed field or a wrong enum value fails
tsc.- Gate agent PRs: no edits to the spec or generated code unless the PR is a labeled spec change, and handler edits only inside marked implementation regions.
- Agents propose contract changes as JSON Patch operations on the spec, validated before anyone reviews them.
- In CI, fail on routes missing from the spec, operations with no handler, and breaking changes (for example with
oasdiff breaking --fail-on ERR).
Who this is for: teams letting coding agents (in the IDE or as PR bots) work on HTTP APIs. Examples use a small billing API with TypeScript types, a Python FastAPI app for the drift test, and shell for CI. Every snippet below was run against the same example spec.
Why agents and APIs need a contract file
An OpenAPI document describes each operation: its path, method, parameters, request body and responses, with schemas for the data. OpenAPI 3.1 schemas use JSON Schema (draft 2020-12), and every operation can carry an operationId that must be unique across the document. That makes the spec machine-checkable in a way prose never is.
Language models are good at producing plausible code and bad at remembering which plausible variant your clients depend on. Without a contract, every agent edit is a possible API change. With one, you can ask a precise question in CI: does this PR change the contract, and if so, was that the intent?
Step 1: Write the contract
A trimmed version of the example billing spec. Note the things an agent is most likely to “improve”: field names, required fields, the status enum and the idempotency header.
openapi: 3.1.0
info:
title: Billing API
version: 1.4.0
paths:
/v1/invoices:
post:
operationId: createInvoice
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, maxLength: 64 }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateInvoiceRequest" }
responses:
"201":
description: Invoice created
content:
application/json:
schema: { $ref: "#/components/schemas/Invoice" }
"422":
description: Validation failed
/v1/invoices/{invoiceId}:
get:
operationId: getInvoice
parameters:
- name: invoiceId
in: path
required: true
schema: { type: string }
responses:
"200":
description: The invoice
content:
application/json:
schema: { $ref: "#/components/schemas/Invoice" }
"404":
description: Not found
components:
schemas:
CreateInvoiceRequest:
type: object
additionalProperties: false
required: [customerId, lines]
properties:
customerId: { type: string }
lines:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required: [sku, quantity]
properties:
sku: { type: string }
quantity: { type: integer, minimum: 1 }
Invoice:
type: object
required: [id, customerId, status, totalCents]
properties:
id: { type: string }
customerId: { type: string }
status: { type: string, enum: [draft, open, paid] }
totalCents: { type: integer }Two details pay off later. additionalProperties: false on request schemas means a client (or an agent) can’t slip in fields the server ignores. And the Idempotency-Key header is part of the contract, so it can’t be dropped by a refactor.

Step 2: Generate types, derive handler signatures
openapi-typescript turns the spec into TypeScript types with no runtime code. If you also want a typed client, a generator such as @hey-api/openapi-ts produces one from the same file.
# Regenerate on every spec change (and check in CI that the output is up to date)
npx openapi-typescript openapi/openapi.yaml -o src/gen/api.ts
git diff --exit-code src/gen/ || { echo "generated types are stale: run codegen"; exit 1; }The handler takes its parameter and return types from the generated operations type, keyed by operationId. The agent fills in the body between the markers; the signature belongs to the spec.
// Signature derived from the generated OpenAPI types. Only the marked body is hand-written.
import type { operations } from "../gen/api";
type Op = operations["createInvoice"];
type Body = Op["requestBody"]["content"]["application/json"];
type Created = Op["responses"]["201"]["content"]["application/json"];
const PRICES: Record<string, number> = { pro: 4900, seat: 1200 };
export async function createInvoice(body: Body, idempotencyKey: string): Promise<Created> {
// agent:impl-start createInvoice
const totalCents = body.lines.reduce((sum, l) => sum + (PRICES[l.sku] ?? 0) * l.quantity, 0);
return { id: `inv_${idempotencyKey.slice(0, 8)}`, customerId: body.customerId, status: "draft", totalCents };
// agent:impl-end createInvoice
}With strict on, this compiles. Change status: "draft" to "pending" and tsc fails with “Type ‘”pending”‘ is not assignable to type ‘”open” | “draft” | “paid”‘”. Read l.qty instead of l.quantity and it fails too. The type checker becomes a contract check that runs in milliseconds, before any test.
Step 3: Gate what agents may touch
Types catch mismatches inside a file. A diff gate catches the agent changing the rules themselves: editing the spec, regenerating types to match its own code, or widening a handler’s signature. The gate below fails a PR that edits openapi/ or src/gen/ without a spec-change label, or that changes handler lines outside the agent:impl markers.
"""CI gate for agent PRs: generated code and the spec are off-limits, and handler edits
must stay inside // agent:impl-start ... // agent:impl-end regions."""
import re
import subprocess
import sys
from pathlib import Path
FORBIDDEN = ("src/gen/", "openapi/")
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.M)
def impl_ranges(text: str) -> list:
ranges, start = [], None
for n, line in enumerate(text.splitlines(), 1):
if "agent:impl-start" in line:
start = n
elif "agent:impl-end" in line and start is not None:
ranges.append((start + 1, n - 1))
start = None
return ranges
def check(base: str, spec_change_label: bool) -> list:
errors = []
files = subprocess.run(["git", "diff", "--name-only", base, "--"], capture_output=True,
text=True, check=True).stdout.split()
for f in files:
if f.startswith(FORBIDDEN):
if not spec_change_label:
errors.append(f"{f}: spec and generated code change only in a labeled spec-change PR")
continue
if not f.startswith("src/handlers/") or not Path(f).exists():
continue
diff = subprocess.run(["git", "diff", "-U0", base, "--", f], capture_output=True,
text=True, check=True).stdout
allowed = impl_ranges(Path(f).read_text())
for m in HUNK.finditer(diff):
first, count = int(m.group(1)), int(m.group(2) or 1)
lines = range(first, first + max(count, 1))
if not all(any(a <= n <= b for a, b in allowed) for n in lines):
errors.append(f"{f}:{first}: change outside agent:impl region")
return errors
if __name__ == "__main__":
problems = check(sys.argv[1], spec_change_label="--spec-change" in sys.argv)
print("\n".join(problems) or "impl gate: ok")
sys.exit(1 if problems else 0)Tested on the example: changing the pricing fallback inside the region passes; adding a parameter to the signature fails with src/handlers/createInvoice.ts:10: change outside agent:impl region; touching the spec fails unless --spec-change is passed. Run it as a required check on PRs opened by agents, and have the PR labeler set --spec-change only for PRs created from a spec proposal. Most AI editors also support project rules or hooks that can stop an agent from editing these paths locally, which saves a CI round trip, but the CI gate is the one that can’t be skipped.
Step 4: Let agents propose spec changes as JSON Patch
Sometimes the agent is right that the API needs to change. Make that an explicit, reviewable proposal instead of a side effect. The agent emits RFC 6902 JSON Patch operations; a script checks they only touch paths and schemas, refuses removals (those are breaking and need a human author), applies them and validates the result as an OpenAPI document.
{
"kind": "patch_openapi",
"reason": "Support refunds requested in ticket BILL-412",
"ops": [
{"op": "add", "path": "/paths/~1v1~1invoices~1{invoiceId}~1refund", "value": {
"post": {
"operationId": "refundInvoice",
"parameters": [{"name": "invoiceId", "in": "path", "required": true, "schema": {"type": "string"}}],
"responses": {"202": {"description": "Refund accepted"}, "409": {"description": "Invoice not paid"}}
}
}}
]
}"""Apply an agent's proposed spec change (RFC 6902 JSON Patch) and validate the result."""
import copy
import json
import sys
from pathlib import Path
import jsonpatch
import yaml
from openapi_spec_validator import validate
ALLOWED_PREFIXES = ("/paths/", "/components/schemas/")
def apply_spec_patch(spec: dict, ops: list) -> dict:
for op in ops:
if not op["path"].startswith(ALLOWED_PREFIXES):
raise ValueError(f"agent may not patch {op['path']}")
if op["op"] == "remove":
raise ValueError("removals are breaking changes: needs a human-authored PR")
new = jsonpatch.apply_patch(copy.deepcopy(spec), ops)
validate(new) # raises if the result is not a valid OpenAPI document
return new
if __name__ == "__main__":
spec = yaml.safe_load(Path("openapi/openapi.yaml").read_text())
proposal = json.loads(Path(sys.argv[1]).read_text())
new = apply_spec_patch(spec, proposal["ops"])
Path("openapi/openapi.yaml").write_text(yaml.safe_dump(new, sort_keys=False))
print(f"applied {len(proposal['ops'])} ops; regenerate types and open a spec-change PR")Note the JSON Pointer escaping: / inside a path key is written ~1, so /v1/invoices/{invoiceId}/refund becomes ~1v1~1invoices~1{invoiceId}~1refund. After this patch is applied, the drift test in the next step fails until someone implements refundInvoice, which is exactly the order you want: contract first, then code.
Step 5: Fail CI on drift and on breaking changes
The drift test compares the running app’s routes with the spec in both directions: no route the spec doesn’t declare, no declared operation without a handler, and matching operation IDs. This example uses FastAPI, but any framework that can list its routes works the same way.
"""Fail CI when the running app and the OpenAPI spec disagree, in either direction."""
import re
from pathlib import Path
import yaml
from fastapi.routing import APIRoute
from app import app # your FastAPI app; any framework that can list routes works the same way
SPEC = yaml.safe_load(Path("openapi/openapi.yaml").read_text())
METHODS = {"get", "put", "post", "delete", "patch", "head", "options"}
def spec_ops() -> set:
return {(m.upper(), p) for p, item in SPEC["paths"].items() for m in item if m in METHODS}
def app_ops() -> set:
ops = set()
for r in app.routes:
if isinstance(r, APIRoute) and r.include_in_schema:
ops |= {(m, r.path) for m in r.methods if m != "HEAD"}
return ops
def test_no_phantom_routes():
extra = app_ops() - spec_ops()
assert not extra, f"routes not in openapi.yaml (spec first, then code): {sorted(extra)}"
def test_no_unimplemented_operations():
missing = spec_ops() - app_ops()
assert not missing, f"operations in openapi.yaml with no handler: {sorted(missing)}"
def test_operation_ids_match():
spec_ids = {op["operationId"] for item in SPEC["paths"].values()
for m, op in item.items() if m in METHODS}
app_ids = {r.name for r in app.routes if isinstance(r, APIRoute) and r.include_in_schema}
assert app_ids == spec_ids, f"operationId drift: {sorted(app_ids ^ spec_ids)}"For spec-change PRs, add a breaking-change check against the main branch’s spec. oasdiff’s breaking command reports changes that break existing clients (removed operations, new required fields, narrowed types) and exits with code 1 when you pass --fail-on ERR:
git show origin/main:openapi/openapi.yaml > /tmp/base-openapi.yaml
oasdiff breaking --fail-on ERR /tmp/base-openapi.yaml openapi/openapi.yamlWe ran oasdiff 1.33 against the example. Adding refundInvoice passes (exit 0, “No breaking changes to report”). Removing totalCents from the Invoice response fails with response-required-property-removed, and adding a required currency request field fails with new-required-request-property. Both exit 1 and route the PR to API owners.
Worked example: the refund feature end to end
- A ticket asks for refunds. The agent’s first step is a
patch_openapiproposal addingPOST /v1/invoices/{invoiceId}/refundwith202and409responses. spec_patch.pyvalidates it; the PR is labeled as a spec change; oasdiff finds nothing breaking; an API owner approves the contract diff.- Codegen updates
src/gen/api.ts. The drift test now fails:operations in openapi.yaml with no handler: [('POST', '/v1/invoices/{invoiceId}/refund')]. - A second, ordinary PR adds the handler. Its signature comes from the generated
refundInvoicetypes, the agent fills the impl region, and the gate,tscand drift test all pass. - At no point could the agent add a route the spec didn’t declare, or change a field name in a way clients wouldn’t see in review.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Drift test reports a route that is in the spec | Path parameter names differ ({id} vs {invoiceId}) or a trailing slash | Use the spec’s parameter names in routes; normalize slashes in one place |
| Generated types are stale in CI | Spec changed without rerunning codegen | Run codegen in CI and fail on git diff --exit-code src/gen/ |
| Agent keeps editing generated files | The editor doesn’t know they’re generated | Add a “GENERATED, do not edit” header and an editor rule; the CI gate still enforces it |
| Spec patch rejected as invalid | Unescaped / in the JSON Pointer, or a missing responses object | Escape / as ~1 and ~ as ~0; every operation needs responses |
| oasdiff flags a change you meant | A real breaking change | Version the operation or the API, or coordinate the client release; don’t silence the check |
Production checklist
- ☐ Spec in the repo, owned by API reviewers (CODEOWNERS on
openapi/) - ☐ Codegen in CI with a stale-output check; generated files never hand-edited
- ☐ Handler signatures derived from generated types;
strictTypeScript - ☐ Impl-region gate required on agent PRs; spec-change label set only by the proposal workflow
- ☐ Agent spec changes arrive as validated JSON Patch, never as handler-only code
- ☐ Drift test both ways, plus operation ID match
- ☐ Breaking-change check on every spec-change PR
FAQ
Does this work with gRPC or GraphQL?
Yes. The principle is the same with .proto files or a GraphQL schema: generate types from the contract, gate edits to it, and use the ecosystem’s breaking-change checker.
Why not let the agent update the spec and the code in one PR?
Because reviewers then see a large diff where the contract change is easy to miss. Separate PRs make the contract change the whole point of one review.
Can’t I generate the spec from the code instead?
You can, but then an agent’s code change silently becomes a contract change. Code-first can work for humans with strong review habits; with agents, spec-first gives you a checkpoint the agent can’t bypass.
Which OpenAPI version should I use?
OpenAPI 3.1 if your tools support it, because its schemas are standard JSON Schema. The tools used here (openapi-typescript, the Python validator and oasdiff) all read 3.1 documents.
Official documentation
- OpenAPI Specification (latest)
- openapi-typescript documentation
- RFC 6902: JSON Patch
- oasdiff: breaking changes
- FastAPI documentation
Related on CheatCoders: Strict JSON tool contracts for coding agents · Idempotent tool calls with DynamoDB
Last updated on October 10, 2026
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- Css Animation Performance: Performance-Optimized Motion for Modern UIs
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
