Development Playbook

Write the OpenAPI file first, then let the compiler and a short test catch every place the agent drifts from it

A contract-first loop for a C# API and a TypeScript client: generated types for the client, a schema check on every response, and a failing test as the agent's brief. About 30 minutes, works with any agent.

Two blueprint-style square frames side by side, one gold and one teal, joined by dimension ticks, with a corner of the teal frame slightly out of line

The ticket says the invoice response has a totalMinor field. The backend developer’s agent returns total, because that read more naturally to it. The frontend developer’s agent calls invoice.totalMinor, divides by 100 and renders NaN. Both pull requests are green. Each side’s tests were written by the side that made the mistake, so each side agreed with itself.

Fixing that doesn’t take a framework. It takes a single file both sides must obey, and two cheap mechanical checks that fail loudly when either side wanders. The file is an OpenAPI document. One check is the TypeScript compiler, running against types generated from it. The other is a 20-line Vitest helper that validates every real response against it.

This Playbook builds the loop around a small invoices API: a C# minimal API on .NET 10, a typed TypeScript client, and a test suite that doubles as the brief for whichever agent writes the server. The technique, the prompts and the checks need nothing but a shell, so they work with any coding agent. Allow about 30 minutes to try it, most of it waiting on package installs.

The contract comes first, and a human owns it

Start with the document, before any C# exists. This one is deliberately small: create an invoice, fetch an invoice, one shared error shape.

openapi: 3.1.0
info:
  title: Invoices API
  version: 1.0.0
paths:
  /invoices:
    post:
      operationId: createInvoice
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewInvoice'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Invoice'
        '400':
          description: Validation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Problem'
  /invoices/{id}:
    get:
      operationId: getInvoice
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The invoice
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Invoice'
        '404':
          description: No such invoice
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Problem'

The components section carries the shapes. The ones that matter for what follows:

components:
  schemas:
    Line:
      type: object
      additionalProperties: false
      required: [description, quantity, unitPriceMinor]
      properties:
        description: { type: string, minLength: 1 }
        quantity: { type: integer, minimum: 1 }
        unitPriceMinor:
          type: integer
          minimum: 0
          description: Price per unit in minor currency units (pence, cents).
    Invoice:
      type: object
      additionalProperties: false
      required: [id, customer, lines, totalMinor, status]
      properties:
        id: { type: string, format: uuid }
        customer: { type: string }
        lines:
          type: array
          items: { $ref: '#/components/schemas/Line' }
        totalMinor: { type: integer, minimum: 0 }
        status: { type: string, enum: [draft, sent, paid] }
    Problem:
      type: object
      required: [title, status]
      properties:
        title: { type: string }
        status: { type: integer }
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }

The full file also defines NewInvoice (a customer and a non-empty lines array). Three choices in there are worth making deliberately, because they are the ones an agent gets wrong when left to infer them. Money is an integer in minor units, so nobody has to argue about floating-point totals. additionalProperties: false on the request and response objects means an invented field is a contract violation, not a bonus. And Problem is documented, so error responses have a shape too.

Lint it before anything else. Redocly CLI ships rulesets, and minimal is the gentlest one:

npm i -D @redocly/cli
printf 'extends:\n  - minimal\n' > redocly.yaml
npx redocly lint openapi.yaml

A valid document prints “Your API description is valid” and, on the minimal ruleset, a handful of warnings about missing security and licence info. Those are real warnings for a production API and noise for this exercise.

The rule for the team: a person reads and approves changes to openapi.yaml, and the agent is told it may propose changes to it but never make them silently. Everything below exists to make a drift from this file impossible to miss.

Types for the client, generated and never edited

The TypeScript side doesn’t get hand-written interfaces. It gets types generated from the document, and a thin typed client on top.

npm i -D typescript@5 openapi-typescript openapi-fetch vitest
npx openapi-typescript openapi.yaml -o src/api.d.ts

One snag from running this: openapi-typescript 7.13.0 declares a peer dependency on TypeScript 5, and npm refuses to install it beside TypeScript 7 (an ERESOLVE error). Pinning typescript@5 as above sidesteps it. Check the peer range before you copy a lockfile from a project that has moved on.

The generated src/api.d.ts is checked in, so a spec change shows up in the diff as a type change. Code that consumes the API reads from it:

import type { components } from "./api";

type Invoice = components["schemas"]["Invoice"];

export function formatTotal(invoice: Invoice, currency = "GBP"): string {
  return new Intl.NumberFormat("en-GB", { style: "currency", currency }).format(invoice.totalMinor / 100);
}

Now the drift from the opening scene. Rename the field in the spec, regenerate, and run tsc:

src/invoiceView.ts(6,89): error TS2339: Property 'totalMinor' does not exist on type '{ id: string; customer: string; lines: { description: string; quantity: number; unitPriceMinor: number; }[]; total: number; status: "draft" | "sent" | "paid"; }'.
tests/invoices.test.ts(13,18): error TS2339: Property 'totalMinor' does not exist on type '{ id: string; customer: string; lines: { description: string; quantity: number; unitPriceMinor: number; }[]; total: number; status: "draft" | "sent" | "paid"; }'.

Every consumer of the renamed field lights up, in both source and tests, before a single request is made. The same holds the other way: an openapi-fetch call made with quantity: "3" fails with TS2322: Type 'string' is not assignable to type 'number'. The client is createClient<paths>({ baseUrl }), and client.POST("/invoices", { body }) is checked against the document’s request schema, with the response typed as { data, error, response }.

Add one more gate: regenerate into a temporary file in CI and diff it against the committed one.

npx openapi-typescript openapi.yaml -o /tmp/api.d.ts && diff -q /tmp/api.d.ts src/api.d.ts

A stale api.d.ts fails the build. The compiler can only check against the types it has, so those types must never lag the spec.

The part the compiler can’t see

Generated types are compile-time only. They say nothing about what the server actually sends. For that, validate real responses at runtime, against the very same document.

JSON Schema covers this, and OpenAPI 3.1 schemas are JSON Schema 2020-12, so Ajv’s 2020 build can use them directly. A small helper dereferences the spec once and checks a body against the schema for a given path, method and status:

import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
import $RefParser from "@apidevtools/json-schema-ref-parser";

type Operation = { responses: Record<string, { content?: { "application/json"?: { schema: object } } }> };
type Spec = { paths: Record<string, Record<string, Operation>> };

const ajv = addFormats(new Ajv2020({ strict: false, allErrors: true }));
const spec = (await $RefParser.dereference("openapi.yaml")) as unknown as Spec;

/** Throws with the schema errors if `body` is not valid for that operation and status. */
export function expectMatchesContract(path: string, method: string, status: number, body: unknown) {
  const response = spec.paths[path]?.[method]?.responses[String(status)];
  if (!response) throw new Error(`${method.toUpperCase()} ${path} documents no ${status} response`);
  const schema = response.content?.["application/json"]?.schema;
  if (!schema) throw new Error(`${method.toUpperCase()} ${path} ${status} has no JSON schema`);
  const validate = ajv.compile(schema);
  if (!validate(body)) {
    throw new Error(`${method.toUpperCase()} ${path} ${status} breaks the contract:\n` +
      validate.errors!.map((e) => `  ${e.instancePath || "/"} ${e.message}`).join("\n"));
  }
}

Two details matter. A status code the document doesn’t list throws, so an undocumented 500 or 409 is a failure and not a pass. And the thrown message names the offending path in the body, which is exactly what an agent needs as a repair brief.

Vitest needs the server running. A global setup file starts the built C# project on a fixed port, waits until it answers, and kills it afterwards:

import { spawn, type ChildProcess } from "node:child_process";

let server: ChildProcess;

export async function setup() {
  server = spawn("dotnet", ["server/Invoices/bin/Release/net10.0/Invoices.dll", "--urls", "http://127.0.0.1:5187"], {
    stdio: "ignore",
  });
  for (let i = 0; i < 50; i++) {
    try {
      await fetch("http://127.0.0.1:5187/invoices/00000000-0000-0000-0000-000000000000");
      return;
    } catch {
      await new Promise((r) => setTimeout(r, 200));
    }
  }
  throw new Error("server did not start");
}

export async function teardown() {
  server.kill();
}

Register it in vitest.config.ts with globalSetup: ["tests/global-setup.ts"]. Add "type": "module" to package.json, or Vitest warns about the config being loaded as CommonJS.

The tests themselves call the typed client and then run the contract check on whatever came back:

import createClient from "openapi-fetch";
import { describe, expect, it } from "vitest";
import type { paths } from "../src/api";
import { expectMatchesContract } from "./contract";

const client = createClient<paths>({ baseUrl: "http://127.0.0.1:5187" });
const valid = { customer: "Acme", lines: [{ description: "Widget", quantity: 3, unitPriceMinor: 250 }] };

describe("POST /invoices", () => {
  it("creates a draft with the total in minor units", async () => {
    const { data, response } = await client.POST("/invoices", { body: valid });
    expectMatchesContract("/invoices", "post", response.status, data);
    expect(data!.totalMinor).toBe(750);
    expect(data!.status).toBe("draft");
  });

  it("rejects an empty customer with a Problem", async () => {
    const { error, response } = await client.POST("/invoices", { body: { ...valid, customer: "" } });
    expectMatchesContract("/invoices", "post", response.status, error);
    expect(response.status).toBe(400);
  });
});

describe("GET /invoices/{id}", () => {
  it("returns what was created", async () => {
    const created = await client.POST("/invoices", { body: valid });
    const { data, response } = await client.GET("/invoices/{id}", { params: { path: { id: created.data!.id } } });
    expectMatchesContract("/invoices/{id}", "get", response.status, data);
    expect(data).toEqual(created.data);
  });

  it("returns a Problem for an unknown id", async () => {
    const { error, response } = await client.GET("/invoices/{id}", {
      params: { path: { id: "7d2c7a52-0000-4000-8000-000000000000" } },
    });
    expectMatchesContract("/invoices/{id}", "get", response.status, error);
    expect(response.status).toBe(404);
  });
});

Notice the split of labour. The behavioural assertions (the total is 750, the status is draft) are the part a human thinks about. The shape assertions are one line per test and cost nothing to add.

Give the agent the tests, not the spec alone

Now the C# side. The prompt works in any agent, and the key is what’s read-only:

Implement the Invoices API in server/Invoices (ASP.NET Core minimal API, net10.0, in-memory store).
openapi.yaml is the contract and tests/ is the acceptance suite. You may not edit either.
Build with `dotnet build -c Release` in server/Invoices, then run `npx vitest run` from the repo root.
You're done when every test passes. If a test seems to demand something the contract doesn't say, stop and tell me instead of changing the test.

An agent given only the spec tends to produce a server that looks right. An agent given the spec plus a suite that fails with a named JSON path has something to iterate against. The “stop and tell me” line matters: without it, the cheapest way to green is to loosen the check.

The server below is one implementation that passes. It isn’t agent output, just what the tests above require:

using System.Collections.Concurrent;

var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseStatusCodePages();

var store = new ConcurrentDictionary<Guid, Invoice>();

app.MapPost("/invoices", (NewInvoice? body) =>
{
    var errors = new Dictionary<string, string[]>();
    if (body is null || string.IsNullOrWhiteSpace(body.Customer))
        errors["customer"] = ["customer is required"];
    if (body?.Lines is null || body.Lines.Count == 0)
        errors["lines"] = ["at least one line is required"];
    else if (body.Lines.Any(l => l.Quantity < 1 || l.UnitPriceMinor < 0 || string.IsNullOrWhiteSpace(l.Description)))
        errors["lines"] = ["every line needs a description, a quantity of 1 or more and a non-negative price"];

    if (errors.Count > 0)
        return Results.Json(new Problem("Validation failed", 400, errors), statusCode: 400);

    var invoice = new Invoice(
        Guid.NewGuid(),
        body!.Customer,
        body.Lines!,
        body.Lines!.Sum(l => (long)l.Quantity * l.UnitPriceMinor),
        "draft");
    store[invoice.Id] = invoice;
    return Results.Json(invoice, statusCode: 201);
});

app.MapGet("/invoices/{id:guid}", (Guid id) =>
    store.TryGetValue(id, out var invoice)
        ? Results.Ok(invoice)
        : Results.Json(new Problem("No such invoice", 404, null), statusCode: 404));

app.Run();

record Line(string Description, int Quantity, long UnitPriceMinor);
record NewInvoice(string Customer, List<Line>? Lines);
record Invoice(Guid Id, string Customer, List<Line> Lines, long TotalMinor, string Status);
record Problem(string Title, int Status, Dictionary<string, string[]>? Errors);

The two lines that look odd, the WhenWritingNull option and the AddProblemDetails and UseStatusCodePages pair, were not in the first draft. They’re there because the tests demanded them, and the next section shows how.

What the contract check found

The first version of that server had no ConfigureHttpJsonOptions, no AddProblemDetails and no UseStatusCodePages. It compiled, it returned sensible JSON, and a human skimming it would have approved it. Run the suite and it prints:

 ❯ tests/invoices.test.ts (4 tests | 1 failed) 137ms
   ❯ GET /invoices/{id} (2)
     × returns a Problem for an unknown id 7ms

Error: GET /invoices/{id} 404 breaks the contract:
  /errors must be object

The 404 handler builds a Problem with Errors set to null, and System.Text.Json writes that as "errors": null. The schema says errors is an object when present. A null isn’t an object, so the contract is broken, and any client that does Object.keys(problem.errors) throws on the unhappy path, which is the path nobody tests by hand. Telling the serialiser to omit nulls fixes it.

Then come the edge cases, in a second file that sends raw fetch requests because the typed client won’t let you send a malformed body (which is the point of it). Malformed JSON, a fractional quantity and a non-UUID id each get one test: expect the documented status, and expect the body to match the contract.

it("malformed JSON gets a Problem", async () => {
  const { status, json } = await send("POST", "/invoices", "{ not json");
  expect(status).toBe(400);
  expectMatchesContract("/invoices", "post", status, json);
});

Against the first server, all three failed the same way:

Error: POST /invoices 400 breaks the contract:
  / must be object

The status codes were right. The bodies were empty, because ASP.NET Core’s built-in binding failures and unmatched routes send a bare status with no payload. That’s a perfectly reasonable default and a contract violation, since the document promises a Problem on a 400 and a 404. Registering AddProblemDetails and adding UseStatusCodePages makes the framework write a problem-details body for those responses. It has type, title, status and a traceId. That satisfies Problem, because the schema requires only title and status and doesn’t forbid extra properties. After both fixes:

 Test Files  2 passed (2)
      Tests  7 passed (7)

Notice that the fix for the empty bodies wasn’t in the spec or the tests. It was framework configuration the agent wouldn’t have known to add, found by a check that asked “does every response the server can send match something the document promised?”

Make it yours

Different stacks change the plumbing, not the loop.

  • A Node or Python server: replace the dotnet spawn in the global setup. The contract helper doesn’t care what’s on the other end of the port.
  • A bigger API: iterate over spec.paths to generate a “happy path plus every documented error” test per operation. At that point a tool built for it, such as a schema-driven fuzzer, earns its place. The hand-written helper is the right size for a service with a dozen endpoints.
  • Spec-first for a team: have the OpenAPI change in its own pull request, reviewed by a person, and merged before either side starts. Both agents then work from the same merged file.
  • Already have a server and no spec: dump the document the framework generates, commit it, and review that file once by hand. From then on it’s the contract, and the loop above applies.

What goes wrong

The contract check only sees the responses the tests provoke. A server that returns a malformed body only when a downstream service times out passes every test. Treat the suite as a floor and add a test whenever production teaches you a new response.

Schemas can be too loose to catch anything. Problem allows extra properties, which is why a framework-generated body passes. If drift in that object would hurt a client, tighten the schema and expect the fix to ripple outward. The same applies to additionalProperties: false: it makes a typo a failure, but it also makes every additive change a breaking one for strict consumers.

Agents will edit the contract if you let them. A schema relaxed to match a bug is the cheapest way to turn a red check green. Keep openapi.yaml and tests/ read-only in the prompt, and review any diff that touches either as a design change.

Don’t bother with this for a throwaway endpoint, or when one developer owns both ends and a shared TypeScript type already guards the boundary. The loop earns its keep when two people, two agents or two repositories sit on either side of an HTTP call.

The first 15 minutes

Pick one real endpoint. Write its request and response as an OpenAPI path with the error shape included, and run npx redocly lint on it. Generate the types with npx openapi-typescript, paste in the 20-line expectMatchesContract helper, and write one test that calls the endpoint through openapi-fetch and checks the response. Whatever it prints on the first run is your first drift report.