TL;DR
A vendor handed us a sandbox key, a 6-page PDF, and no OpenAPI spec. I used Claude Code to turn ~40 exploratory requests into an inferred schema, a typed client, and a contract test suite in two days. The trick was never letting the agent write types from the docs — only from captured responses. Here's the loop, plus the three times it confidently made things up.
The Problem
We had to integrate a partner's billing API. What we got was:
- A sandbox API key
- A 6-page PDF with five example requests
- A support email with a 3-business-day SLA
No OpenAPI spec. No Postman collection. No SDK. The PDF said amount was "an integer," which turned out to mean minor units as a string in three of the seven endpoints. It listed six fields on the invoice object; the real payload had thirty-one.
I've been down this road before, and the usual failure mode is nasty: you write a client against the docs, it works in sandbox, and then production returns a nullable field the docs never mentioned and your parser explodes at 2 AM. The docs aren't the contract. The responses are the contract.
So my constraint going in was simple: I wanted an integration where every type, every enum, and every nullability decision could be traced back to a real HTTP response I had actually observed — not to prose in a PDF, and not to a language model's prior about what a billing API "usually" looks like.
That second one matters more than people expect. If you paste a vague doc into an agent and ask for a TypeScript client, you will get a beautiful client. It will have status: 'pending' | 'paid' | 'failed' because that's what billing APIs usually have. The vendor's actual enum was PENDING | SETTLED | REVERSED | PARTIAL_REVERSED. Everything compiles. Nothing works.
How I Solved It
The whole thing is a four-stage loop. The agent is allowed to be creative in stages 1 and 3, and is aggressively constrained in stages 2 and 4.
flowchart LR
A[Agent proposes<br/>probe requests] --> B[Harness executes<br/>+ records to disk]
B --> C[Agent infers schema<br/>from captured JSON only]
C --> D[Contract tests run<br/>against captures]
D -->|gaps / mismatches| A
Stage 1: Let the agent design the probes, not the types
The first thing I asked for wasn't code. It was a list of questions about the API:
Read the vendor PDF at
docs/vendor-billing.pdf. Don't write any client code. Produce a list of HTTP requests that would resolve ambiguity in the docs — especially anything where a field's type, nullability, or enum values are unstated. For each request, say what you expect to learn.
This produced 41 probes, and a good chunk of them were things I wouldn't have thought to try:
- Create an invoice with zero line items (does it 422, or return an empty array, or
null?) - Fetch an invoice immediately after creation (is there read-after-write lag?)
- Request page 2 of a 1-item collection (cursor shape when exhausted)
- Send
amountas an integer where the PDF example used a string - Cancel an already-cancelled invoice (idempotent, or error?)
That last one saved us. It's a 409 with a body shape that appears nowhere else in the API.
Stage 2: A capture harness, not copy-paste
This is the load-bearing part. The agent does not get to hold response data in its context and then "remember" it later — that's exactly how you get hallucinated fields. Every response lands on disk as a file, and every later stage reads from disk.
# probe.py — Python 3.13, stdlib only on purpose
import hashlib, json, pathlib, time, urllib.request, urllib.error, os
CAPTURES = pathlib.Path("captures")
CAPTURES.mkdir(exist_ok=True)
def probe(name: str, method: str, path: str, body: dict | None = None) -> dict:
url = f"{os.environ['VENDOR_BASE_URL']}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
url, data=data, method=method,
headers={
"Authorization": f"Bearer {os.environ['VENDOR_SANDBOX_KEY']}",
"Content-Type": "application/json",
},
)
started = time.monotonic()
try:
with urllib.request.urlopen(req) as res:
status, payload = res.status, res.read().decode()
headers = dict(res.headers)
except urllib.error.HTTPError as e: # errors are data, not failures
status, payload = e.code, e.read().decode()
headers = dict(e.headers)
record = {
"name": name, "method": method, "path": path,
"request_body": body, "status": status, "headers": headers,
"response_body": json.loads(payload) if payload.strip() else None,
"elapsed_ms": round((time.monotonic() - started) * 1000),
}
slug = hashlib.sha1(f"{name}{method}{path}".encode()).hexdigest()[:8]
(CAPTURES / f"{name}-{slug}.json").write_text(
json.dumps(record, indent=2, sort_keys=True)
)
return record
Two decisions in there that I'd defend:
Errors are captured, not raised. A 409 or a 422 is the most information-dense response an API gives you. If your harness throws on non-2xx, you throw away half your schema.
Sorted keys, stable filenames. Captures get committed. When the vendor silently ships a change, the diff shows up in a pull request instead of in an incident channel. We caught a new settlement_reference field this way three weeks later.
Stage 3: Infer the schema from N samples, never 1
Now the agent gets to be clever again, but with a hard boundary:
Read every file in
captures/. Produce a JSON Schema for each distinct response shape. Rules: a field is optional only if it is absent in at least one capture. A field is nullable only if it is literallynullin at least one capture. Enum values are the exact set of observed strings — do not add plausible extras. For any field where you have fewer than 3 samples, list it underlow_confidenceinstead of guessing.
That low_confidence bucket is the single highest-value line in the prompt. It came back with eleven fields, and it was right to flag all of them. Four were genuinely ambiguous and needed more probes. Three were vendor-side bugs. Here's what shipped in the final schema versus what the PDF claimed:
| Field | PDF says | Reality |
|---|---|---|
amount |
integer | string, minor units |
status |
"pending / paid / failed" | 4 uppercase values, none matching |
customer.tax_id |
required | absent for non-EU customers |
line_items |
array |
null when empty, [] after first edit |
created_at |
ISO 8601 | ISO 8601, but no timezone offset |
That line_items row is my favorite. null on create, [] after any update. No human would document that, because no human knows.
Stage 4: Contract tests that run against the captures
The generated client is only trustworthy if something keeps it honest. Every capture becomes a test case, so the parser is verified against real bytes rather than against a mock somebody wrote by hand.
// contract.test.ts — TypeScript 5.x + Vitest
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { parseInvoice } from "../src/vendor/parse";
const captures = readdirSync("captures")
.filter((f) => f.startsWith("invoice-"))
.map((f) => JSON.parse(readFileSync(`captures/${f}`, "utf8")));
describe("invoice parser vs. captured responses", () => {
it.each(captures)("$name -> $status", (capture) => {
if (capture.status >= 400) {
expect(() => parseInvoice(capture.response_body)).toThrow();
return;
}
const parsed = parseInvoice(capture.response_body);
// no silent field drops: every key we received survives the round trip
for (const key of Object.keys(capture.response_body)) {
expect(parsed).toHaveProperty(key);
}
});
});
The round-trip assertion catches the quiet failure mode where a parser drops an unrecognized field and nobody notices for a month.
Total elapsed: about two days, most of it waiting on sandbox rate limits.
Lessons Learned
1. Ground the agent in artifacts on disk, not in its own context
The difference between "here's the doc, write me a client" and "here are 41 JSON files, write me a client" is the difference between fiction and engineering. Once responses live in files, every claim the agent makes is checkable with grep. I now treat this as the default shape for any integration work: capture first, generate second.
2. Errors are the best documentation the vendor has
The 409 body taught me more about their internal state machine than the entire PDF. If you're planning probes, spend at least a third of them deliberately breaking things — duplicate operations, empty payloads, wrong types, expired resources.
3. Make "I don't have enough samples" a first-class output
An agent asked for a schema will always produce a schema. An agent asked for a schema plus a low-confidence list will tell you where it's guessing. That one extra instruction turned eleven silent landmines into eleven tickets. Any time you request a confident artifact, request the uncertainty alongside it.
4. It lied to me three times, and all three were "reasonable"
Worth naming specifically, because the pattern is consistent:
- It added
page_sizeto the pagination params. Most APIs have it. This one doesn't — it'slimit. - It typed
currencyas a 3-letter ISO enum. The vendor returns lowercase for two currencies. - It marked
metadataasRecord<string, string>. Nested objects are allowed, undocumented, and we use them.
Every one of these is what a competent engineer would assume. None survived contact with the captures. The failures weren't random — they were the model regressing to the industry average API. The more standard the domain, the harder you have to anchor to observed data.
5. Commit the captures
They're your regression suite for the vendor's changes, not just yours. Ours have caught two undocumented vendor-side changes since. Cost: 400 KB in the repo.
What's Next
Three things on the list:
- Nightly re-probing. Run the capture harness against sandbox on a schedule, diff against committed captures, open an issue on drift. The vendor won't tell us when they change something, so we'll find out ourselves.
- Property-based probes. Right now the 41 probes are hand-curated. Fuzzing field types against the endpoints would surface coercion behavior faster than I can guess at it.
- Same loop, internal services. Half our own internal services have specs that drifted from reality a year ago. The technique doesn't actually care whether the API is someone else's.
Stack for anyone reproducing this: Claude Code CLI (August 2026), Python 3.13 for the harness, Node.js 22.x with TypeScript 5.x and Vitest for the client and tests. Nothing exotic — the leverage is entirely in the loop shape, not the tools.
Wrap-up
If you take one thing from this: when you point an AI agent at an integration, make real responses the only thing it's allowed to read. Docs are a hypothesis. Captures are evidence. The agent is excellent at turning evidence into types and terrible at knowing when it has none.
I'm writing up more of these build logs as I go — following me here on Dev.to is the easiest way to catch them. And if you've got a vendor API horror story, drop it in the comments. I collect them. 🚀
Top comments (0)