Skip to content

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.

ArtifactChecked byHow
The contract (api/openapi.yaml, schema.graphql or .proto)You, line by line; the API owner approves through CODEOWNERSPull request that changes only the contract
Contract style and validityMachineSpectral or Redocly lint, buf lint, GraphQL schema checks
Consumer compatibilityMachineoasdiff breaking, buf breaking, graphql-inspector diff against main
Handlers, validation, queriesMachineType check, unit and integration tests
Handlers match the contractMachineSchemathesis against a running server
Latency and query count under loadMachinek6 with thresholds that fail the run
Auth, money and deletion pathsReview 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.

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

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

  3. Merge the contract on its own. A contract pull request changes only the contract file. Implementation branches treat it as read-only.

  4. Implement one slice per task. One resource or a few operations per agent session, in its own branch or worktree, against the merged contract.

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

  6. Check compatibility and regenerate clients. A breaking-change diff against main blocks 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.

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 window
# Terminal, repo root
echo 'extends: ["spectral:oas"]' > .spectral.yaml
npx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity warn

The same discipline applies to the other protocols. Only the tools change:

ProtocolContractLintBreaking-change check
RESTapi/openapi.yaml (OpenAPI 3.1)npx @stoplight/spectral-cli lint or npx @redocly/cli lintoasdiff breaking --fail-on ERR base.yaml api/openapi.yaml
GraphQLschema.graphql with connection types for listsnpx @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 updatesnpx @bufbuild/buf lintnpx @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.

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.

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.

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 window
# Terminal, repo root: ${STAGING_DATABASE_URL} is expanded from your shell at launch
claude mcp add -s project postgres -e 'DATABASE_URI=${STAGING_DATABASE_URL}' \
-- uvx postgres-mcp --access-mode=restricted

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.

GateCatchesCommand
Contract lintMissing error responses, inconsistent names, undocumented operationsnpx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity warn
Types and testsWrong status codes, broken validation, pagination that does not round-tripnpm run typecheck && npm test
Auth-coverage testA route registered without requireAuthtests/auth-coverage.test.ts (prompt below)
Contract fuzzingResponses that differ from the schema, 500s on odd input, endpoints that succeed without credentialsuvx schemathesis run api/openapi.yaml --url "$BASE_URL" --checks all
Breaking-change diffRemoved fields, newly required parametersoasdiff breaking --fail-on ERR base.yaml api/openapi.yaml
Load thresholdsN+1 queries, slow list endpointsk6 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 pipefail
BASE_URL="${BASE_URL:-http://localhost:3000}"
npx @stoplight/spectral-cli lint api/openapi.yaml --fail-severity warn
npm run typecheck
npm 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.yaml
else
echo "::warning::no base contract on main, breaking-change check skipped"
fi
curl -sf "$BASE_URL/health" > /dev/null
uvx schemathesis run api/openapi.yaml --url "$BASE_URL" --checks all \
--header "Authorization: Bearer ${TEST_API_TOKEN:?}" \
--report junit --report-dir reports/schemathesis

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

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.js
import 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:

Terminal window
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.