Chapter 6 of 6 · 13 min read
The fresh-subject handoff
On this page
A handoff is the moment a server stops being legible because you built it. You know that since wants an
ISO-8601 timestamp, that ids come from list_projects, that limit tops out somewhere; none of that is in
the surface. Running the handoff deliberately — somebody who has never seen the server, a fixed task list,
every question logged before it is answered — converts that invisible knowledge into a defect list with
locations.
The questions have a second audience, which is why the exercise is worth formalising. A human who cannot work out what a field wants sends a message and carries on; a model in the same position guesses, and the guess is indistinguishable from an answer.
Three questions, three locations
Three questions asked inside ten minutes by an engineer meeting a data platform server for the first
time: what format since wants, because the schema said type: string and two guesses had come back
rejected; whether limit had a maximum, because 5,000 had returned 1,000 rows and there was no way to
tell whether that was a cap or the size of the result; and what to do when a call came back
rate_limited, since the response said nothing about how long to wait or whether waiting would help.
Each is a defect with an exact location: since needs its format declared in the schema with an example,
limit needs its ceiling stated and enforced rather than silently applied, and rate_limited needs to
carry the wait.
{
"since": { "type": "string" },
"limit": { "type": "integer" }
}{
"since": {
"type": "string",
"format": "date-time",
"description": "ISO-8601 timestamp with offset, e.g. 2026-08-01T00:00:00Z. Events at exactly this instant are included."
},
"limit": {
"type": "integer", "minimum": 1, "maximum": 1000, "default": 100,
"description": "Rows per page, maximum 1000. Use next_cursor for more."
}
}The second version answers two of the three questions before they can be asked, and it answers them in
the one place both readers look. The rate_limited fix belongs in the response and follows the shape
from the previous chapter on error design, with retry_after_seconds present and non-negotiable.
Why three locations and not four or seven is worth deriving, because the number is not a
convention and the derivation is what rules out the fix everyone reaches for first. A model
receives exactly two things from your server: the tools/list payload, and the body of a
result. Everything else you might write — a README, a wiki page, a Slack answer, a comment in
the handler — is on a channel that never arrives. Within the payload, a fact about a field
can live in the schema, where it is machine-checked, or in a description, where it is not.
Within a result body, it lives in the text you compose. Schema, description, result body:
that is the complete list, arrived at by enumerating what crosses rather than by picking
three plausible places, which is why a fix that lands anywhere else has not been made.
The limit question repays a slower look, because the observation the subject had was
genuinely undecidable and that is the defect. They asked for five thousand rows and received
a thousand. Two hypotheses explain that response equally well: the query matched exactly a
thousand rows, or the server capped a larger result at a thousand. The response is identical
under both, so no amount of care on the caller's part separates them, and the caller does not
get to run the experiment that would — they would have to already know the ceiling to design
it. Declaring maximum: 1000 in the schema resolves it in the only way available. It does
not make the cap visible after the fact; it moves the event forward, so that the request for
five thousand fails validation before it is sent and the caller learns the ceiling from the
rejection. That is the difference between a silent clamp and a bound: one returns success for
a request it did not honour, and the other refuses a request it cannot honour.
A handoff you can run twice
The value of the exercise is destroyed by running it a second time on the same person, since they now
know what since wants. Treating the handoff as an acceptance test means designing it so it can be
re-run, which mostly means being disciplined about the subject, the tasks and the record.
The subject is someone who has not seen the server, and they are single-use. Most teams have four or five people who qualify at any moment, so the test is a scarce resource and should be spent on a version you believe is ready. The tasks are fixed and written down in advance — six to eight, drawn from what the server exists to do, phrased as outcomes rather than as calls: "find every dataset the billing team owns that has not been refreshed in 30 days" rather than "call query_datasets with owner and updated_before". The rule for you is that you log every question with a timestamp before you answer it, and that your answer never gets written into a document that ships with the server.
| Recorded | Why it is the thing to record |
|---|---|
| Time to first successful call | Compresses handshake, auth and schema legibility into one number; 4 minutes and 40 minutes mean different things |
| Each question, with the surface element that should have answered it | Turns a support conversation into a defect list with owners; the element is always a schema, a description or an error |
| Errors received, and whether they recovered without help | An error a fresh user cannot act on is one a model cannot act on |
| Calls per task, against your own baseline | A subject taking 9 calls where you take 3 has found a discoverability gap, even if they finished |
| Tasks abandoned, and the last thing they tried | The abandonment point names the tool whose contract is unreadable |
Each logged question is then located in exactly one place, and the discipline is refusing any fix that
lands outside those three. "Explain in the wiki that since is ISO-8601" is not a fix. "Add
format: date-time and an example to the field description" is.
The agent pass on the same task list is half of the criterion rather than a nice-to-have, and it is cheap once the tasks exist. Both halves record the same fields, which makes the two columns comparable and makes the asymmetries visible — and the asymmetries are where the interesting defects live. The exit condition is a fresh subject completing the task list with zero questions of the human-only class, and an agent completing the same list without a human intervening. Friction is allowed to remain; a subject who takes six calls where you take three has told you something worth knowing and has not necessarily found a defect.
The channel a model never reads
The response that presents itself when a lead sets this criterion is to write thorough documentation, publish it beside the server, and declare the criterion met when a reviewer from another team can follow it end to end. That reviewer will succeed, and the exercise will have measured the documentation rather than the server.
A paragraph in a wiki explaining that since is ISO-8601 has no path into the tools/list payload or into a
response body, which are the only two channels a model reads. The gap is invisible for exactly as long as
humans keep compensating for it, because every human reader has the wiki open and every one of them succeeds,
so the surface never gets the feedback that would have corrected it.
There is a second-order effect that shows up six months later. A schema is executed on every call, so a
wrong maximum produces a rejection somebody notices; a README is executed by nobody, so a since
documented as ISO-8601 after the handler quietly started accepting epoch seconds stays wrong indefinitely.
Contracts that are enforced stay true, and contracts that are prose drift.
None of which argues against writing documentation. Installation, authentication, deployment topology, the data model behind the tools, and worked end-to-end examples all belong in a README and are useful to the humans who operate the thing. The rule is narrower: nothing a caller needs at the moment of a call may live only there.
The tool a human uses and an agent cannot
The clearest example is not a rejected call. list_events returns an array and a next_cursor, the opaque
marker a caller sends back to receive the following page, and on a query matching 4,312 rows it returns the
first 100 with the cursor at the end of the payload. A human scrolls to the bottom, sees the cursor, and
pages. An agent takes the 100 rows as the answer and reports that the billing team has 100 stale datasets,
which is wrong, and which nothing in the run marks as wrong.
The mechanism is that the human brings context the response does not carry. Somebody who has seen a hundred paginated APIs reads an array of exactly 100 items with a round-numbered length as suspicious, and has a UI to cross-check against. The model has the payload and the tool description, and if neither says the result was truncated, the result was not truncated.
That is the same undecidable observation as the limit one, wearing different clothes, and
here the cost of getting it wrong is worth putting a number against. Two states of the world
— a query that matched a hundred rows, and a query that matched four thousand three hundred
and twelve of which a hundred were returned — produce byte-identical events arrays. A human
resolves it with a prior about round numbers and a console to check against; neither is in
the payload. And the error, if it goes unresolved, is not a rounding: a hundred against four
thousand three hundred and twelve is under a fortieth of the answer, and the full result runs
to forty-four pages at this page size. A report that the billing team has a hundred stale
datasets is not slightly low. It is wrong by a factor the reader has no way to estimate,
delivered with the confidence of a complete answer.
{
"events": ["...100 items..."],
"next_cursor": "eyJvIjoxMDB9",
"truncated": true,
"total_matched": 4312,
"returned": 100,
"note": "100 of 4312 matched. Call again with cursor=eyJvIjoxMDB9 for the next page."
}Three of those fields exist for the same reason. truncated is a boolean the model can branch on without
arithmetic, total_matched makes the shortfall a fact rather than an inference, and note states the
continuation in the form of an instruction, since instruction-shaped text in a response is what actually
changes the next call. The human loses nothing from any of it.
| Channel | Human | Agent |
|---|---|---|
| README, wiki, worked examples | yes | no |
| Ask the author on Slack | yes | no |
| Convention learned from other APIs | yes | partially, and unreliably |
| Trial and error across days, with memory | yes | within one run only |
| The web console or an existing UI | yes | no |
| Tool names, descriptions, schemas | yes | yes |
| Error and response bodies | yes | yes |
Only the last two rows are shared, and a server whose usability depends on any of the others has an agent problem it cannot see.
Tools that fail for the human and pass for the agent
A tool can pass for the agent and fail for the human, and it happens in more than one way. create_export
takes eleven required fields, four of them enums with exact string values and one an idempotency key, the
caller-supplied string that lets the server recognise a repeated call as the same export rather than a second
one, with a specified format. A model fills that from the schema in a single call and essentially never gets
it wrong, because reading a schema and emitting conforming JSON is the thing it is best at. A human
hand-writing the same JSON gets three round trips of rejections, gives up on the tool and asks for a CLI.
Discovery chains behave the same way. Reaching a dataset query requires list_workspaces, then
list_projects, then query_datasets, with each description naming the next tool as the source of its ids. An
agent walks that in about two seconds and treats it as ordinary. A person experiences it as a maze with no
map, because they cannot see the whole surface at once the way a tools/list payload presents it, and there
is no index page to start from.
Whether either of those counts as a defect depends on who the server is for, which is a question worth
answering explicitly rather than by default. A server whose only caller is an agent can accept eleven required
fields; if the acceptance criterion has both halves, the fixes are different in kind from the ones earlier in
this chapter. An examples array on each tool costs the model a few tokens and gives a human something to
copy. A thin CLI wrapper generated from the same schemas gives them tab completion and a --help that is
generated rather than written, so it cannot drift. The one thing not to do is solve it by putting
human-friendly prose in every description, since those tokens are paid on every call by the caller who did not
need them.
The two audiences genuinely diverge in places, and pretending otherwise produces a surface that serves neither. Where they conflict, the traffic decides: a server taking 40,000 agent calls a day and eleven human ones optimises the schema and ships a wrapper for the eleven.
Divide those two and the reason the rule is not a judgement call becomes plain. Forty thousand against eleven is over three thousand agent calls for every human one, which means any token spent making a description read nicely for a person is paid three thousand times by a caller who did not need it, and any awkwardness left in the schema is suffered eleven times by people who can be handed a different tool. The lopsidedness is what makes the two fixes non-competing rather than a trade: the wrapper serves the eleven at no cost to the forty thousand, and the schema serves the forty thousand at a cost the eleven never see. Where the ratio is closer to even the argument stops working, and then the divergence is real and has to be decided rather than derived.
The handoff is the only instrument in this topic that measures what the surface says rather than what it does. A direct suite asserts on behaviour you already understand and an agent run scores tasks against a planner; neither can tell you that a field's format lives only in your head. A person meeting the server cold can, once.
What the acceptance record gives you beyond a defect list is a baseline that survives the people who built the server. When somebody proposes a fifteenth tool next spring, the task list is already written, the fresh-subject protocol is a page long, and the omissions log from the second chapter says what was left out and why. The server stops being a design artefact at this point and becomes a dependency in somebody's daily loop, which is a different set of problems — how it gets attached to a client, what the surrounding prompt tells the model about when to reach for it, and which of your tools ends up sharing a context window with forty tools from three other servers.
Building a server — read the current version at https://daviddominguez.dev/learn/en/building-a-server/the-fresh-subject-handoff