Spec-First AI Development: Keep OpenAPI the Only Source of Truth for Coding Agents

Spec-First AI Development: OpenAPI Remains the Only Source of Truth
34 views

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.yaml in 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.

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

Figure 1. Spec-first flow for coding agents. The spec feeds generated types; agents implement inside marked regions or propose JSON Patch changes; CI rejects drift and breaking changes before a human reviews. Diagram by CheatCoders.
Figure 1. Spec-first flow for coding agents. The spec feeds generated types; agents implement inside marked regions or propose JSON Patch changes; CI rejects drift and breaking changes before a human reviews. Diagram by CheatCoders.

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.

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

typescript
// 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.

python
"""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.

json
{
  "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"}}
      }
    }}
  ]
}
python
"""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.

python
"""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:

bash
git show origin/main:openapi/openapi.yaml > /tmp/base-openapi.yaml
oasdiff breaking --fail-on ERR /tmp/base-openapi.yaml openapi/openapi.yaml

We 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

  1. A ticket asks for refunds. The agent’s first step is a patch_openapi proposal adding POST /v1/invoices/{invoiceId}/refund with 202 and 409 responses.
  2. spec_patch.py validates it; the PR is labeled as a spec change; oasdiff finds nothing breaking; an API owner approves the contract diff.
  3. Codegen updates src/gen/api.ts. The drift test now fails: operations in openapi.yaml with no handler: [('POST', '/v1/invoices/{invoiceId}/refund')].
  4. A second, ordinary PR adds the handler. Its signature comes from the generated refundInvoice types, the agent fills the impl region, and the gate, tsc and drift test all pass.
  5. 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

SymptomLikely causeFix
Drift test reports a route that is in the specPath parameter names differ ({id} vs {invoiceId}) or a trailing slashUse the spec’s parameter names in routes; normalize slashes in one place
Generated types are stale in CISpec changed without rerunning codegenRun codegen in CI and fail on git diff --exit-code src/gen/
Agent keeps editing generated filesThe editor doesn’t know they’re generatedAdd a “GENERATED, do not edit” header and an editor rule; the CI gate still enforces it
Spec patch rejected as invalidUnescaped / in the JSON Pointer, or a missing responses objectEscape / as ~1 and ~ as ~0; every operation needs responses
oasdiff flags a change you meantA real breaking changeVersion 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; strict TypeScript
  • ☐ 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

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.

Open the playlist on YouTube · CheatCoders on YouTube

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.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.