Chapter 3 of 8 · 16 min read
The model-facing contract
On this page
The argument against building a separate model-facing surface is a good one, which is why it wins so often. It usually arrives in a planning meeting sounding like this: the billing API has 140 endpoints, it has been in production for four years, it has integration tests, it is versioned, three internal clients depend on it, and there is a generator that turns the OpenAPI document — the machine-readable file, published alongside most services, that lists every route with its parameters and response shapes — into tool definitions in about a day. Anything you build by hand is a second surface that has to be kept in sync with the first, and it will drift the first time someone adds a field.
Every clause of that is true. The counter is not that the wrapper is more work than it looks; the wrapper really is a day. The counter is that the day is the only cost that gets paid once. Everything the wrapper exposes — the cursor, the UUID, the HTTP status code, the twenty-row page size — is paid again on every call the agent makes, forever, in model turns and in calls that come back invalid. A one-off engineering cost is being compared against a recurring per-request cost, and the comparison is being made without anyone writing down the second number.
Putting the recurring cost in the same units
Take the invoice workflow from chapter 1, where an endpoint-per-tool surface ran a seven-call sequence of which one call was about invoices, and price both sides. The wrapped surface ran a median of 31,000 input tokens per completed run; the purpose-built one ran about 4,200. At $3 per million input tokens that is a difference of roughly $0.08 per run. At 1,200 runs a day it is $96 a day, or about $35,000 a year, and that is only the successful runs.
Every step of that is one multiplication, and they are worth doing separately, because the shape of the argument lives in the order rather than in the answer. Start with the difference in tokens, which is just under twenty-seven thousand per run and is the only quantity the redesign changes directly. Turn tokens into money by multiplying by the price, remembering that the price is quoted per million, so the multiplier is three divided by a million and not three: a little over eight cents a run. Turn money per run into money per day by multiplying by the traffic, which is where a figure that sounded like loose change stops sounding like it — eight cents against twelve hundred runs is a shade under a hundred dollars a day. Turn that into a year by multiplying by the days. Nothing in the chain is subtle, and that is the point: each step multiplies by a number that grows with the business, while the wrapper's one engineer-day is never multiplied by anything at all.
The marginal-cost row in the table below is the same arithmetic done to each column separately rather than to the difference between them. Thirty-one thousand tokens against twelve hundred runs is a bit over thirty-seven million a day, which at three dollars a million is the hundred and twelve; the purpose-built column is that multiplication again with the smaller token figure and lands in the middle teens. Subtracting one from the other returns the same ninety-odd dollars a day the per-run route produced, which is the check worth running on any figure like this — if the two routes to it disagree, one of the inputs is being quoted against a denominator nobody has stated.
The failure side is larger. A 74% completion rate against 96% means 264 additional incomplete runs a day, each of which lands on a human, at six minutes of handling time — 26 hours of support time daily, which is three full-time people. The build cost being weighed against all of that was four engineer-days.
That second calculation runs on percentage points rather than on tokens, and it is the one people skip, because the units change twice on the way through. The gap between the two completion rates is twenty-two points; a point here is one run in a hundred; so twenty-two points of twelve hundred runs is the two hundred and sixty-odd runs a day that finish somewhere they should not have. Each of those is a piece of work a person now does, at six minutes apiece, and six minutes multiplied by that many runs is a shade over twenty-six hours. Dividing by the hours in one person's working day is the last conversion, and it is the step that turns a duration into headcount — a bit over three people, every day, indefinitely. The table below states the two ends of that calculation rather than the difference between them, at about thirty-one hours a day of handling under the wrapper against about five under the purpose-built surface, and the twenty-six hours is what separates the ends.
Every figure in that comparison is conditional on three inputs that belong to your system rather than to the industry, and a reviewer should make somebody produce all three before the number is quoted anywhere. The traffic figure decides everything downstream of it and is the one most likely to be a launch-week guess rather than a measurement. The price per token is a list rate that a committed-spend agreement, a cheaper model on the mechanical hops, or prompt caching will each move, and caching can take a large part of the token half of the argument away on its own — which is why the token line is the supporting figure here and the handling time is the headline. And the six minutes is an average over incomplete runs that are not alike: a run that stops early and says so costs a fraction of a run that produced a wrong email somebody then has to unpick. Move any one of the three and the conclusion survives at a different magnitude. Move all three in the wrapper's favour and the recurring cost can genuinely come out smaller than the build, which is the case the last section of this chapter is about.
| Endpoint-per-tool wrapper | Purpose-built surface | |
|---|---|---|
| Build cost | ~1 engineer-day | ~4 engineer-days |
| Median input tokens / run | 31,000 | 4,200 |
| Tool calls / run | 7 | 2 |
| p95 wall time | 41 s | 9 s |
| Completion rate | 74% | 96% |
| Marginal cost / 1,200 runs / day | $112 | $16 |
| Human handling / day | ~31 h | ~5 h |
The numbers will be different for your system, and the shape will not be. A wrapper's costs scale with traffic and a purpose-built surface's costs scale with the number of operations you decide to support, which is a number you control and which changes a few times a year rather than a few thousand times a day.
There is a second recurring cost that the token count hides. Every internal mechanic in the surface is also a thing your on-call rotation now has to reason about when a run goes wrong, because a failed run's transcript is full of cursors and opaque ids and the question is always whether the model got them wrong or the backend did. Debugging cost is not usually counted in these arguments and it is not small.
The pattern this is a repeat of
Around 2015, teams that had built one general-purpose API for every client started splitting it. A mobile app on a slow connection wanted three screens' worth of data in one round trip; a web front end wanted the same data in a different shape; an internal batch job wanted none of it and all of something else. Serving all three from one general API meant every client did its own assembly, over the network, on every screen load. The response was the backend-for-frontend, or BFF: a thin, client-specific service that sits in front of the general API, calls it several times, and returns exactly what one client needs in one round trip. Nobody argued that the general API should be deleted, and nobody argued that the BFF was a competing source of truth.
A model-facing surface is the same pattern with a new client whose round trips are unusually expensive and unusually error-prone. The general API stays where it is, keeps its versioning and its tests and its other three consumers, and the new layer calls it. This framing settles most of the sync anxiety, because the answer to "what happens when someone adds a field to the invoice resource" is the same as it is for any BFF: nothing, unless the model-facing contract wants to expose it.
That is a precedent rather than a metaphor — the model-facing layer really is a backend-for-frontend, built and deployed the same way — and it is worth marking the one place the correspondence stops being exact. A mobile client's round trips are expensive in latency and entirely predictable in content: the client asks for what somebody programmed it to ask for, so a badly shaped API makes it slow rather than wrong. A model's round trips are expensive in latency and unpredictable in content, because what it asks for on the next hop is derived from what came back on the last one. The usual justification for a BFF — fewer round trips — is therefore the smaller half of the case here. The larger half is that every round trip removed is also a place where an argument could have been constructed wrongly, and that half has no analogue in the mobile story.
Jentic's write-up The MCP Tool Trap makes the same argument from the failure side, if you want it stated by someone who has watched a lot of mirrored REST surfaces fall over.
The contract test against staging
The sync objection deserves a mechanism rather than an assurance, because "the BFF pattern handles this" is a claim about architecture and the worry is about a specific Tuesday when someone renames a field. The billing API in this example merged 43 changes over twelve months. Thirty-one added a field to a response, which a layer that names the fields it reads never notices. Seven added endpoints, which the model-facing layer is free to ignore until a workflow needs them. Five changed the behaviour of an operation the layer actually calls — a status value split into two, a date field that started arriving with a timezone offset, three renames behind a deprecation window — and those five are the whole problem, so the mechanism only has to catch that class.
Those three counts add back to the forty-three, which matters more than it looks. It means the classification is exhaustive rather than illustrative, so the case for a nightly test rests on the smallest of the three categories and not on a sample of the scary ones. A mechanism that had to catch every change to the internal API would be a second copy of that API's test suite. A mechanism that only has to catch changes to operations this layer actually calls, and among those only the ones that alter behaviour rather than add to it, is a few seconds of probing. The ratio is that favourable because most API change is additive, and additive change is invisible to a consumer that names the fields it reads — which is the one design decision that buys the whole property.
REQUIRED_UPSTREAM = {
"GET /v3/customers": ["id", "name", "status"],
"GET /v3/customers/{id}/invoices": ["id", "amount_due", "currency",
"due_date", "status", "next_cursor"],
"GET /v3/invoices/{id}/document": ["url", "expires_in"],
}
ENUMS = {"invoice.status": {"draft", "open", "paid", "void", "uncollectible"}}
def test_upstream_contract(staging):
for route, fields in REQUIRED_UPSTREAM.items():
body = staging.probe(route)
missing = [f for f in fields if f not in flatten(body)]
assert not missing, f"{route} no longer returns {missing}"
seen = {i["status"] for i in staging.sample("GET /v3/invoices", n=500)}
assert seen <= ENUMS["invoice.status"], f"unknown status: {seen - ENUMS['invoice.status']}"That test runs nightly against staging and takes about nine seconds. It fails on a removal, a rename and a type change, and it fails on a new enum value appearing in production data even when nobody announced one, which is the case that broke the layer twice in that year. What it does not catch is a field whose meaning changes while its name and type stay put, and there is no automated answer to that; the answer is that the model-facing layer has an owner who reads the internal API's changelog, which is the same arrangement the other three internal clients already have.
The important asymmetry is that a wrapper does not remove this work, it relocates it. When an endpoint-per-tool surface drifts, the failure surfaces as a model producing calls the backend now rejects, discovered by whoever is on call, at whatever rate the traffic finds it. A hand-built layer with a contract test fails in CI at 03:00 with a route name in the message. Both cost engineering time; one of them costs it before the users do.
What the contract keeps and what it drops
The line to draw is between the operations and constraints the task genuinely needs, which must survive intact, and the mechanics of how your services talk to each other, which must not appear at all.
| Concern | In the internal API | In the model-facing contract |
|---|---|---|
| Pagination cursors, page size | explicit, caller-driven | absorbed; a bounded result set and a flag |
| Record identifiers | UUIDs the caller carries | server-held handles, or none |
| HTTP status codes | passed through | translated to outcomes with a next step |
| Retries and backoff | caller's responsibility | inside the tool |
| Auth scopes | header, per request | fixed per tool, enforced server side |
| API versioning | /v3/, Accept headers | invisible |
Field selection, expand= | caller composes | fixed, task-shaped response |
| Business constraints | validated, 422 on breach | preserved, stated in the schema |
| Irreversible operations | one endpoint among many | separate tool, separate permission |
The last two rows are where a purpose-built surface goes wrong when it goes wrong, and they are worth stating as strongly as the drops. Simplifying a surface is not licence to drop a constraint. If your API refuses to issue a refund larger than the original charge, or requires a reason code on a credit note, or forbids closing a ticket with an open child, the model-facing tool has to refuse the same things, and the refusal has to be enforced in the tool rather than described in the prompt. A surface that is easy for a model to call and permits states your business logic forbids is worse than the wrapper it replaced, because the wrapper at least inherited the guard.
The same holds for authorization. Wrapping an API endpoint-per-tool inherits its auth model by accident, which is at least an auth model. A hand-built surface has to make those decisions deliberately, per tool, and this is one of the genuine risks of the approach: a tool that quietly runs as a service account with broader scope than the user who triggered it is an escalation path that no amount of task success makes acceptable.
Here is the difference in the smallest form that shows it. The wrapper's contract:
{"name": "list_invoices",
"description": "GET /v3/customers/{customer_id}/invoices",
"input_schema": {"customer_id": "string (uuid)",
"status": "string|null", "cursor": "string|null",
"limit": "integer, max 100"}}The contract built for the task:
{"name": "find_unpaid_invoice",
"description": "Find a customer's unpaid invoices, most recent first.",
"input_schema": {
"customer": {"type": "string",
"description": "Company name or account number as the user gave it"},
"max_results": {"type": "integer", "default": 5, "maximum": 20}},
"returns": {
"matched_customer": "string, the resolved account name",
"ambiguous_matches": "array of names when the customer name matched several",
"invoices": "array of {reference, amount, currency, due_date, days_overdue}",
"more_exist": "boolean"}}The second one costs a few hours to write and a few hundred lines to implement. It also removes four rows from the failure taxonomy in chapter 1 at once, because there is no cursor to invent, no UUID to transcribe, no status code to classify, and the two-Acme ambiguity that silently produced the wrong answer now comes back as a field the model can act on.
When the wrapper is the right answer
The case against wrapping is not a case for always building. Wrapping wins when the endpoints are already task-shaped, which happens more often than the mirrored REST horror stories suggest — a well-designed public API with resource-level operations, sensible defaults, and errors that describe conditions rather than transport is not far from a model-facing contract already. If your endpoint takes a human-readable identifier, returns a bounded result, and reports "no such customer" distinctly from "customer has no invoices", there is very little for a translation layer to do.
Treating this as one decision for the whole API is what makes it feel like a large commitment. Applied endpoint by endpoint against the drop list above, a single well-regarded payments API contains operations of both kinds.
| Endpoint | What leaks into the model's turn | Verdict |
|---|---|---|
GET /v1/customers/{id} | an id the model is already holding | wrap unchanged |
POST /v1/refunds | a 422 when the amount exceeds the charge | wrap, translate the 422 into an outcome |
GET /v1/charges?created[gte]=&starting_after= | cursor, 100-row pages, bracket filter syntax | rebuild |
GET /v1/search?query=status:'open' AND amount>1000 | a query language inside a string argument | rebuild or constrain to an enum |
The first two are already task-shaped and a translation layer over them would be ceremony. The third and fourth are the ones that cost turns, and they are the ones the invoice workflow leans on hardest.
Wrapping also wins on time horizon. A prototype that needs to exist on Thursday to prove the workflow is worth automating should be a wrapper, because the point of the prototype is to find out whether anyone wants it. The mistake is not building the wrapper; it is the wrapper going to production and the traffic growing while the recurring cost stays uncounted, which is exactly the condition the previous chapter's instrumentation is for. A wrapper with a measured per-tool invalid rate is a decision. A wrapper without one is a default.
And there is a real cost to the second surface that the BFF framing softens but does not remove. It is code you own, with its own bugs, its own deploys, and its own on-call. It will lag the internal API when a new capability lands, and someone has to notice. Teams that do this well keep the model-facing layer small enough — ten to fifteen tools rather than a hundred and forty — that the lag is visible and the review is cheap.
The contract is the first decision because every later one is expressed inside it. Whether a tool is broad or narrow, whether two tools merge, what a result looks like — all of that assumes there is a layer whose vocabulary is the task's rather than the service's. Ten to fifteen tools is not an arbitrary number, and neither is the choice of which tasks get a tool of their own. The next chapter takes the granularity question directly, with two designs for the same analytics agent that differ by a factor of five in tool count and by considerably more in what happens when they are wrong.
Tool design — granularity and surface — read the current version at https://daviddominguez.dev/learn/en/tool-design-granularity-and-surface/the-model-facing-contract