API development with AI: contract first, verified by machine
API development with AI agents works when the contract comes first: a person reviews a short OpenAPI, GraphQL or protobuf contract, the agent implements one endpoint slice at a time against it, and machine gates (contract lint, tests, contract fuzzing, breaking-change diffs and load thresholds) prove the handlers match. Reviewers read the contract and the gate output, not every handler.
Your GET /tasks endpoint loads each task’s assignee with a separate query. It passed review and the demo, because the demo had three tasks. In production the board renders 50 tasks, the handler fires 51 queries, and the connection pool is exhausted by mid-morning. The agent wrote what the prompt asked for. Nothing in the prompt, the contract or the gates said how the endpoint would be called.
This page is for the developer building the endpoints and the tech lead who approves them without reading 2,000 lines of handlers. It covers the method across tools and protocols. The tool-specific implementations are building APIs with Claude Code and contract-first APIs in Codex.
What you get from contract-first API work with agents
Section titled “What you get from contract-first API work with agents”- A six-step loop in which the contract, not the agent, decides the shape of the API.
- Four copy-paste prompts: draft the contract, implement a slice, add an auth-coverage test, and deprecate a version.
- A gate table and a CI script that check the implementation against the contract for REST, with the GraphQL and gRPC equivalents.
- The Claude Code, Codex and Cursor variant of each agent step.
- The failure modes of agent-built APIs, with a recovery for each.
Who reads what when an agent builds the API?
Section titled “Who reads what when an agent builds the API?”Contract-first moves human attention onto the smallest artifact that defines behaviour. A 150-line OpenAPI file for five endpoints is reviewable. The code behind it is checked by machines.
| Artifact | Checked by | How |
|---|---|---|
The contract (api/openapi.yaml, schema.graphql or .proto) | You, line by line; the API owner approves through CODEOWNERS | Pull request that changes only the contract |
| Contract style and validity | Machine | Spectral or Redocly lint, buf lint, GraphQL schema checks |
| Consumer compatibility | Machine | oasdiff breaking, buf breaking, graphql-inspector diff against main |
| Handlers, validation, queries | Machine | Type check, unit and integration tests |
| Handlers match the contract | Machine | Schemathesis against a running server |
| Latency and query count under load | Machine | k6 with thresholds that fail the run |
| Auth, money and deletion paths | Review agent, then a person on flagged files only | /code-review, codex review, Bugbot |
The developer who owns the branch signs off on the contract diff and the gate output. The tech lead owns the gate list: a gate added there covers every future endpoint, while a comment in review covers one.
How does the contract-first loop run?
Section titled “How does the contract-first loop run?”-
Draft the contract with the agent. Ask for the spec only, with pagination, auth, error format and access patterns written in. No server code yet.
-
Lint it and review it as a person. Run the contract linter, then read the diff. This is the one artifact you read in full.
-
Merge the contract on its own. A contract pull request changes only the contract file. Implementation branches treat it as read-only.
-
Implement one slice per task. One resource or a few operations per agent session, in its own branch or worktree, against the merged contract.
-
Run the gates. Tests, contract fuzzing, the auth-coverage test and, for list endpoints, a load run. The agent runs them before it reports done, and CI runs them again.
-
Check compatibility and regenerate clients. A breaking-change diff against
mainblocks unintended breaks. Client SDKs are regenerated from the contract, never edited by hand.
For how to write the behaviour down before any contract exists, see spec-driven development.
Draft the contract before any handler
Section titled “Draft the contract before any handler”Name the conventions in the prompt. An agent left to choose pagination, error bodies and status codes chooses differently on every endpoint.
The last line matters. The agent’s open questions are the decisions nobody made, and they belong in the contract review rather than in a handler.
Lint the result before you read it. Spectral needs a ruleset file:
# Terminal, repo rootecho 'extends: ["spectral:oas"]' > .spectral.yamlnpx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity warnThe same discipline applies to the other protocols. Only the tools change:
| Protocol | Contract | Lint | Breaking-change check |
|---|---|---|---|
| REST | api/openapi.yaml (OpenAPI 3.1) | npx @stoplight/spectral-cli lint or npx @redocly/cli lint | oasdiff breaking --fail-on ERR base.yaml api/openapi.yaml |
| GraphQL | schema.graphql with connection types for lists | npx @graphql-inspector/cli validate 'src/**/*.graphql' schema.graphql (checks every operation against the schema) | npx @graphql-inspector/cli diff old.graphql schema.graphql |
| gRPC | .proto files with field masks for partial updates | npx @bufbuild/buf lint | npx @bufbuild/buf breaking --against '.git#branch=main' |
Versions checked on 2026-09-26: @stoplight/spectral-cli 6.16.3, @redocly/cli 2.57.0, @graphql-inspector/cli 7.0.0, @bufbuild/buf 1.73.0 on npm. Install oasdiff with go install github.com/oasdiff/oasdiff@latest or brew install oasdiff.
Put the API conventions where every session reads them
Section titled “Put the API conventions where every session reads them”Conventions repeated in each prompt drift. Write them once in the file each tool loads at the start of a session:
## API conventions- The contract (api/openapi.yaml) is the source of truth. Implementation tasks never edit it. If the contract looks wrong, stop and say so.- Middleware order: requestId → requireAuth → validate (Zod, from the contract) → handler → errorHandler.- Handlers are thin: parse, call the service, map errors. Queries live in src/db/queries/.- Lists never query per row: batch with a join or an IN query (DataLoader for GraphQL resolvers).- Gates before "done": bash ci/api-gates.sh (Spectral, typecheck, tests, oasdiff, Schemathesis).Put the section in CLAUDE.md at the repository root. To make “done” mean the gates are green, add a Stop hook that runs them and blocks the stop on failure. The full hook script and its registration are on building APIs with Claude Code, and the hook events are explained in hooks automation.
Put the section in AGENTS.md at the repository root; Codex reads it before each task. A sandboxed session has no network access, so uvx schemathesis and oasdiff cannot download there. Either preinstall every gate tool (npm install -D @stoplight/spectral-cli, uv tool install schemathesis, and brew install oasdiff or go install github.com/oasdiff/oasdiff@latest), or have the agent run the offline subset (Spectral, typecheck, tests) and leave the full ci/api-gates.sh to CI. Run each slice in its own worktree with codex --worktree. The slice-by-slice loop is on contract-first APIs in Codex.
Save the section as a project rule in .cursor/rules/ (for example api-conventions.mdc) set to always apply. The format and a canary check that confirms the rule loads are on project rules in Cursor. Use Plan Mode for the first slice, so you approve the file list before the agent edits.
Which model should build the endpoints?
Section titled “Which model should build the endpoints?”For model choice, start on the tool’s default: as of 2026-09-26 that is Claude Opus 5.5 in Claude Code (from v2.1.280, the latest channel) and GPT-6 Astra in Codex. Raise the effort level before switching models, and switch only when your own results say so. Current options are on the models hub.
Implement one slice against the contract
Section titled “Implement one slice against the contract”A slice is the smallest set of operations that is useful on its own, such as list and create for one resource. Smaller slices keep each diff and each gate run readable.
Tests the agent writes in the same session agree with its code, not necessarily with the contract. That is why the contract fuzzing gate below exists: it generates its requests from the contract, so it does not share the agent’s assumptions.
Give the agent the real database schema
Section titled “Give the agent the real database schema”For database-backed endpoints, an agent that guesses column names produces a round of corrections. A database MCP server lets it read the real schema and query plans instead. Postgres MCP Pro (postgres-mcp; 0.3.0 is the latest PyPI release, from 2025-05-16, checked 2026-09-26) runs with uvx; point it at a staging database through a read-only role:
# Terminal, repo root: ${STAGING_DATABASE_URL} is expanded from your shell at launchclaude mcp add -s project postgres -e 'DATABASE_URI=${STAGING_DATABASE_URL}' \ -- uvx postgres-mcp --access-mode=restricted# ~/.codex/config.toml; export DATABASE_URI before you start codex[mcp_servers.postgres]command = "uvx"args = ["postgres-mcp", "--access-mode=restricted"]env_vars = ["DATABASE_URI"]// ~/.cursor/mcp.json; the value comes from your shell or secret manager, never the file{ "mcpServers": { "postgres": { "command": "uvx", "args": ["postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "${env:STAGING_DATABASE_URL}" } } }}The ${env:NAME} interpolation follows Cursor’s MCP documentation; confirm it in your Cursor version. If it does not resolve, remove the env block and start Cursor from a shell that exports DATABASE_URI.
With the server connected, add “run explain_query on every new query and report sequential scans” to the slice prompt. For Prisma projects, the prisma/skills pack (npx skills add prisma/skills) is the lighter option: it teaches the agent the Prisma CLI and client API without a live connection. Other servers and their traps are on database MCP servers.
How do you prove the API matches the contract without reading every handler?
Section titled “How do you prove the API matches the contract without reading every handler?”Each gate catches a class of bug that a reviewer skimming a large diff misses. The first five run in CI on every pull request.
| Gate | Catches | Command |
|---|---|---|
| Contract lint | Missing error responses, inconsistent names, undocumented operations | npx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity warn |
| Types and tests | Wrong status codes, broken validation, pagination that does not round-trip | npm run typecheck && npm test |
| Auth-coverage test | A route registered without requireAuth | tests/auth-coverage.test.ts (prompt below) |
| Contract fuzzing | Responses that differ from the schema, 500s on odd input, endpoints that succeed without credentials | uvx schemathesis run api/openapi.yaml --url "$BASE_URL" --checks all |
| Breaking-change diff | Removed fields, newly required parameters | oasdiff breaking --fail-on ERR base.yaml api/openapi.yaml |
| Load thresholds | N+1 queries, slow list endpoints | k6 run load/tasks.js (on list endpoints and before release) |
Schemathesis (4.28.0 on PyPI, 2026-09-26) reads the contract, generates valid and invalid requests, and checks status codes, response schemas and headers. Its ignored_auth check flags an operation that still succeeds when the credentials the contract requires are left out. Run it only against a local or disposable environment: it sends a large volume of generated requests, including writes.
The auth-coverage test is worth writing on the first day, because a route without auth passes every other gate when the tests were written by the same agent that forgot the middleware:
The last instruction is the point: a gate you have never seen fail is a gate you have not tested. For why the agent must not be able to edit these gates, see protect the oracle.
A compact CI job for the REST gates, with the contract taken from main as the compatibility baseline:
#!/usr/bin/env bash# ci/api-gates.sh: run in CI with full history (fetch-depth: 0) and the server started# against a disposable database. TEST_API_TOKEN comes from the CI secret store.set -euo pipefailBASE_URL="${BASE_URL:-http://localhost:3000}"
npx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity warnnpm run typechecknpm test
# Fail closed when main was not fetched; skip only when main has no contract yet.git rev-parse --verify -q origin/main > /dev/null || { echo "origin/main not fetched"; exit 1; }if git show origin/main:api/openapi.yaml > /tmp/openapi-base.yaml; then oasdiff breaking --fail-on ERR /tmp/openapi-base.yaml api/openapi.yamlelse echo "::warning::no base contract on main, breaking-change check skipped"fi
curl -sf "$BASE_URL/health" > /dev/nulluvx schemathesis run api/openapi.yaml --url "$BASE_URL" --checks all \ --header "Authorization: Bearer ${TEST_API_TOKEN:?}" \ --report junit --report-dir reports/schemathesisThe agent’s job is to get these green before it asks for review. How each tool does that differs:
A Stop hook runs the gates when Claude tries to finish and hands the failure back, so the session continues until they pass; have the hook check stop_hook_active so it cannot loop forever. Then run /code-review in the session, or a read-only reviewer subagent that compares the changed routes with the contract. Both are set up on building APIs with Claude Code.
List the gate commands in AGENTS.md under “Gates before done”, then review the branch with the base branch and the contract check written into the instructions: codex review "Review this branch's changes against main: compare every changed route with api/openapi.yaml and flag any missing requireAuth". --base and a custom prompt are mutually exclusive (checked on Codex CLI 0.157.1), so if you prefer codex review --base main, put the contract check in AGENTS.md instead. When Schemathesis fails, hand Codex the JUnit report rather than fixing it yourself; the prompt for that is on contract-first APIs in Codex.
Let the agent run the gate commands in the integrated terminal and read the output in the same session. On the pull request, Bugbot reviews for bugs and security issues. A project hook in .cursor/hooks.json can block commands you never want run, such as fuzzing a production URL.
A person still decides whether the contract is the right product. The gates only prove the code does what the contract says. The review routine for the pull request itself is on reviewing agent pull requests.
Catch N+1 queries and slow lists before production
Section titled “Catch N+1 queries and slow lists before production”The three-row demo never shows an N+1 query. A load test with thresholds does, because a slow or failing run exits non-zero instead of looking slow in a chart. k6 is a standalone binary, not an npm or PyPI package: install it with brew install k6 or winget install k6, or run it in Docker with docker run --rm -i grafana/k6 run - <load/tasks.js.
// load/tasks.js: run with k6 run -e BASE_URL=http://localhost:3000 -e TOKEN=... load/tasks.jsimport http from 'k6/http';import { check } from 'k6';
export const options = { stages: [ { duration: '1m', target: 50 }, { duration: '3m', target: 50 }, { duration: '1m', target: 0 }, ], thresholds: { http_req_duration: ['p(95)<300'], http_req_failed: ['rate<0.01'], },};
export default function () { const res = http.get(`${__ENV.BASE_URL}/tasks?limit=100`, { headers: { Authorization: `Bearer ${__ENV.TOKEN}` }, }); check(res, { 'status is 200': (r) => r.status === 200 });}Seed the database with realistic volume first: at least a few thousand rows and several related records per row. Set the thresholds from your service-level objectives, not from the numbers above. For a query-count check without load, assert the number of queries per request in an integration test. Both approaches are covered on performance testing.
Version and deprecate an API without breaking consumers
Section titled “Version and deprecate an API without breaking consumers”The breaking-change diff tells you when a change needs a new version. Merge api/v2/openapi.yaml in its own contract-only pull request first, like any contract change; the implementation pull request then touches only src/ and passes the contract guard under failure modes below. Deprecate the old one with standard headers, so clients and gateways can detect it: Deprecation (RFC 9745) takes a structured-field date, an @ followed by a Unix timestamp, and Sunset (RFC 8594) takes an HTTP date.
After any contract change, regenerate the clients from the contract so consumers move with the server:
npx @openapitools/openapi-generator-cli generate \ -i api/openapi.yaml -g typescript-axios -o ./sdk/typescript@openapitools/openapi-generator-cli was 2.41.0 on npm on 2026-09-26; it downloads a Java generator JAR, so the machine needs a Java runtime. Across service boundaries, add consumer-driven contract tests with Pact; that pattern is on microservices with AI.