Skip to content
Chapter 6 of 6 · Building a serverThe fresh-subject handoff

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.

json
{
  "since":  { "type": "string" },
  "limit":  { "type": "integer" }
}
json
{
  "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.

RecordedWhy it is the thing to record
Time to first successful callCompresses 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 itTurns 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 helpAn error a fresh user cannot act on is one a model cannot act on
Calls per task, against your own baselineA subject taking 9 calls where you take 3 has found a discoverability gap, even if they finished
Tasks abandoned, and the last thing they triedThe 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.

Flowchart: Fresh subject + fixed task list → Run, log every question; Run, log every question → Question answerable only by a human?; Question answerable only by a human? → Defect: locate in schema, description or error (yes); Question answerable only by a human? → Record as friction (no); Defect: locate in schema, description or error → Fix in the surface; Fix in the surface → Same task list through an agent; Same task list through an agent → Fresh subject + fixed task list.

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.

json
{
  "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.

ChannelHumanAgent
README, wiki, worked examplesyesno
Ask the author on Slackyesno
Convention learned from other APIsyespartially, and unreliably
Trial and error across days, with memoryyeswithin one run only
The web console or an existing UIyesno
Tool names, descriptions, schemasyesyes
Error and response bodiesyesyes

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

Back to top