Everyone's first instinct, when they see "turn an OpenAPI document into tool definitions an AI agent can call," is to hand the spec to a model and ask nicely.
The trouble with that is the failure rate you land on. Not 100%, so you can't ship it unread. Not 50%, so you can't dismiss it. Somewhere in between — which means you spend longer reviewing the output than you would have spent writing it, and the mistakes are precisely the ones that look correct.
So archstone init has no LLM on any path. Not as a purity thing — because the interesting parts of this problem are the parts a model is worst at.
The problem, precisely
You have a REST API. You want AI agents — Claude, ChatGPT, whatever ships next quarter — to be able to call it. The modern answer is MCP, and the modern shortcut is "generate an MCP server from your OpenAPI spec."
That shortcut has a hole in it. An OpenAPI document describes your HTTP surface. It does not describe your business. It says POST /v1/bookings returns 201. It does not say whether calling that charges someone's card.
That difference is the whole job.
It's a loop, not a generator
The first thing that makes this different from codegen: nothing is written to your directory until it has already compiled.
init drafts the manifest in a temp directory, runs the real pipeline over it — load → validateSemantics → compile → new Registry(), the same code path the actual compiler uses, not a lookalike — and only then commits the files.
There are exactly two terminal states:
- a manifest that compiles was written, or
- nothing was written, and here is why
No half-scaffold. If the draft doesn't compile, if two capabilities collide on a tool name, if the confirmed set comes back empty — zero files. You never clean up after it.
That invariant is load-bearing enough that it has a bug story. During review, commitFileSet({force: true}) was found overlaying files onto the target with cpSync without deleting first — so a file left by a previous run could survive into the committed output having never been compiled. It wasn't in the temp directory that got validated. The second run would report ok: true, failures: [] while compiling the result actually failed with two semantic errors.
Every individual function was correct. The defect lived entirely in the gap between which file set was validated and which file set reached disk. That's the class of bug that "just ask the model" cannot even be wrong about, because it never had the invariant in the first place.
The one answer it never guesses
CDL capabilities carry an effect: read, write, or irreversible.
No OpenAPI document contains this. You might think method maps to it — GET is read, POST is write — and then you meet POST /search, which every real API has, and the mapping is dead.
effect is the difference between looking up a price and charging a card. An agent that treats one as the other is not a bug report, it's an incident. So init refuses to infer it. A confirmed effect exists only in a Decision Record — an explicit artifact of a human answering — and the emitter takes the Decision Record, never an adapter's hint.
Slower. Correct.
Ambiguous is a refusal, never a guess
The rule the inference code is built around, quoted from its own header:
AMBIGUOUS IS A REFUSAL, NEVER A GUESS.
Here's where it bites. A response body like this:
{
"estimatedPrice": 240,
"currency": "EUR",
"warnings": ["dimensions rounded to nearest cm"]
}
is structurally identical to a paginated list wrapper. There is one array of objects in there. Pick the array and you get a collection of warnings, and you have silently dropped the price — the entire point of the call.
That is not hypothetical. It happened, on a real document, to this tool. The price capability emitted a collection of warnings and no price. It compiled. It passed apply. It wrote files. It was found by running the tool against a real API, not by reading the rules.
The fix wasn't a better heuristic. It was to stop picking: init now enumerates the candidate response loci and asks which one is the payload.
The failure mode that would have reached your customers
This is the subtle one, and it's why I think the whole "just generate it" approach is more dangerous than it looks.
CDL fields can be required. Get that wrong in the strict direction and the compiler yells at you — loud, immediate, free.
Get it wrong in the loose direction — mark something required that your backend sometimes returns as null — and you ship a manifest that compiles, passes verification green, and then throws a contract violation the first time a real null comes back. Silent at build time. Loud in front of a customer.
So the rule is narrow on purpose: required: true only on positive evidence of non-nullability — declared required, and non-nullable, and (if probed) present and non-null on every recorded item. Anything less is optional.
The two ways of being wrong have completely different costs, so they don't get the same default.
It tells you what it didn't understand
The usual failure of a spec-reading tool is silent partial comprehension: it consumes your document, emits something plausible, and never mentions the 30% it skipped.
init inverts the default. Each object type declares the keys it reads and the keys it argues are inert — and everything else, including keys from a future OpenAPI revision and keys a contributor forgot to declare, falls through into the report rather than being dropped.
Anything it can't handle gets exactly one disposition: skip it, name it with a reason code, emit nothing for it. The codes are a closed, written-down list — no-response-shape, ambiguous-collection, pagination-not-modeled, nested-object-not-mapped, field-path-not-expressible, and so on. A contributor adding support for a new construct has to either reuse a code or argue for a new one in the open, rather than quietly half-handling it.
Every fact in the intermediate model also carries its derivation — declared (from the spec), observed (from a real response), or absent — so a classification made from three observed items says so, instead of presenting itself as a measurement.
Optionally, it calls your backend once
With --probe, init will make one read-only request and record a real fixture, so archstone verify has something true to replay later.
The consent model is deliberately annoying:
- off by default
- asked per capability, not once for the run
- never issued for a capability whose confirmed
effectisn'tread - a non-
GET/HEADmethod needs a second, separate confirmation - refused outright when there's no terminal to ask at
And the recorded fixture is written by the same recordContract() in the runtime, over the same invokeRest call, that verifyTool uses. So the fixture init writes is by construction the artifact verify later replays — not a lookalike that drifts.
Try it
npm i -g @archstone/cli
archstone init openapi.yaml --out manifest --company acme --domain catalog
There's a spec in the repo you can run it against without pointing it at anything of yours (examples/demo/stays-openapi.yaml), and a live compiled capability you can add to Claude in about thirty seconds, no install:
https://demo.archstone.dev/mcp
Apache-2.0: https://github.com/Archstone-Romania/archstone
The broader argument — why any of this is a compiler and not just another MCP server — is here. This post is the part you can run.

Top comments (0)