Chapter 2 of 7 · 8 min read
The output contract decides whether isolation survives
On this page
Kettleworth is a marketplace moving from the legacy stripe.Client constructor in Stripe's SDK — Stripe being the payments provider that takes the money for every order on the platform — to a factory that injects the correct account for each seller, and the first step is an inventory: every place in the monolith where a Stripe client is constructed. The engineer running the migration asked the coding agent for it, the agent delegated to a child with Read, Grep and Glob, and eleven seconds later the answer came back.
It came back as 8,900 tokens. The child had run four search patterns through ripgrep, the recursive text search that agent tooling usually exposes as rg, opened twelve files to work out whether each hit was a real construction or a type annotation, narrated its reasoning about each one, quoted the surrounding function from six of them, and finished with a paragraph offering to start the migration. Somewhere in the middle of that was the list of nineteen call sites, correct and complete.
Compare that against the cheapest thing the parent could have done, which is run the search in its own loop and look at the hits. That is 1,400 tokens of ripgrep output. The delegation made the parent's window grow by six and a half times what the inline search would have cost, and it charged an extra model call and eleven seconds for the privilege. The isolation was real while the child was running — those twelve files never touched the parent — and then it was given back at the handoff.
What was missing was a shape, not an instruction to be brief
The child's definition said what to do and said nothing about what to return, which leaves the model to infer it, and the inference it makes is the one its training rewards in ordinary conversation: show your work, be helpful, offer the next step. An output contract replaces that inference with a stated shape. For this task the shape is a list of file-and-line references, one line of surrounding context each, and nothing else.
---
name: call-site-finder
description: Enumerates every call site matching a construction pattern.
tools: Read, Grep, Glob
---
Return ONLY a fenced JSON array. Each element:
{ "file": "<repo-relative path>",
"line": <integer>,
"code": "<the single source line, trimmed, max 120 chars>",
"certain": <true if this constructs the object,
false if it is a type hint, mock, or comment> }
No prose before or after the fence. No summary. No offer of next
steps. If a candidate is ambiguous, include it with certain: false —
do not resolve it by reading more files. If you find nothing, return
an empty array.The result is 19 elements and 780 tokens, which is smaller than the ripgrep output the parent would have generated inline, because the child spent its own window filtering the seven false positives out of twenty-six raw hits. That is the delegation working: the child absorbed 34,000 tokens of file reads and the parent's window grew by less than a kilobyte.
Two clauses in that definition are doing more work than they look like they are. The instruction to include ambiguous hits with certain: false rather than resolving them stops the child from wandering — without it, a model that finds a stripe.Client inside a test fixture will open the fixture's imports, then the factory it imports, then the config that factory reads, and each of those detours is invisible to the parent and paid for anyway. The instruction to return an empty array on no matches removes the most common contract violation in practice, which is a child that finds nothing and returns an apology instead of a value, breaking the parent's parser on the one path nobody tested.
The distinction that matters here is between a contract and a request for brevity. "Be concise" and "summarise your findings" are not contracts, because they constrain length without constraining structure, and length is the thing the model is worst at controlling. A shape constrains both: {file, line, code, certain} has no room for a narrative because there is no field to put one in. It also makes the parent's next step deterministic, since the parent can loop over the array, sort by file, and open the three files it actually needs to edit, rather than parsing English to find out what the child meant.
What crosses the line in each direction
The note in the middle is the whole mechanism. Everything the child accumulates is thrown away when it returns, so the only question that determines whether the delegation was worth anything is how wide the last arrow is. A subagent with no output contract has an unbounded last arrow, and the width of it is decided by a sampling process rather than by you.
This is the same discipline that governs any process boundary you already work with. An HTTP handler that reads four tables and returns a 40 KB response is not a service boundary that helps anyone; the value of the boundary comes from the response being narrower than the work behind it. The difference is that an HTTP handler's response shape is enforced by the code you wrote, and a subagent's is enforced by an instruction that the model usually follows. That gap is real and it is the honest limitation of the technique: contracts are strong conventions, not types. Anything downstream of the handoff that would break on a malformed result needs to be defended at the boundary.
MAX_RESULT_TOKENS = 1_500
def accept(raw: str) -> list[CallSite]:
"""Validate a child's result before it enters the parent's window."""
if estimate_tokens(raw) > MAX_RESULT_TOKENS:
raise ContractViolation("result exceeds budget; re-run with a narrower brief")
match = re.search(r"```json\s*(.+?)```", raw, re.S)
if not match:
raise ContractViolation("no JSON fence in result")
return [CallSite(**row) for row in json.loads(match.group(1))]Choosing the number in that budget is not arbitrary. The useful heuristic is to set it about an order of magnitude below the bulk the child is expected to consume, because that ratio is what the delegation is being paid for; here the child reads roughly 35,000 tokens and is allowed to return 1,500, a ratio of 23 to 1. When you sit down to write the contract and find that the natural output is within a factor of two or three of the input, the delegation is not compressing anything and the question to ask is why the parent is not simply reading the input itself. Kettleworth added budgets to their six subagent definitions after this incident and instrumented the boundary; across the next thirty delegations the mean result dropped from 6,400 tokens to 690, and the ninety-fifth percentile — the number that actually determines whether a long session survives — dropped from about 14,000 to 1,100. Two delegations tripped the budget and were re-run with narrower briefs, which is a cheap failure compared with the alternative.
Raising rather than repairing is deliberate. The tempting alternative is to hand the malformed blob to the parent with a note saying the child misbehaved, which puts the 8,900 tokens into the window anyway and adds an apology on top. Failing at the boundary keeps the parent clean and gives you a signal you can count, which turns out to matter later when you are deciding which delegations are worth keeping.
The shape follows from what the parent will do next
There is no universal contract, because the right shape depends on what the parent needs the answer for. The single-question case from the previous chapter — which endpoints accept a cursor parameter — has a one-sentence contract: return the endpoint paths that accept a cursor query parameter, one per line, with no prose. That is enough because the parent's next action is to iterate over paths.
A harder case makes the reasoning visible. A support agent forty turns into a customer conversation needs three months of billing events reconciled, and the child comes back with a paragraph: the customer was double-charged in March, the duplicate was partially refunded, and the balance is off by roughly forty dollars. Everything in that paragraph might be true, and the parent cannot act on it, because the customer will ask which charge and the parent has no charge to name. Worse, the only way to check the claim is to read the billing events, which is exactly what the delegation existed to avoid.
The contract that fixes it returns checkable references instead of a narrative.
{
"verdict": "duplicate_charge_partially_refunded",
"events": [
{"id": "ch_3Ox81QLk", "date": "2025-03-04", "amount_cents": 4900, "kind": "charge"},
{"id": "ch_3Ox82RLm", "date": "2025-03-04", "amount_cents": 4900, "kind": "charge"},
{"id": "re_1Pa07TLn", "date": "2025-03-19", "amount_cents": 900, "kind": "refund"}
],
"net_owed_cents": -4000,
"unexplained_events": []
}Every claim now has an identifier attached to it, so the parent can quote ch_3Ox82RLm to the customer, a human reviewing the transcript can pull that charge in one lookup, and the reconciliation is auditable without anyone re-reading three months of events. The unexplained_events field carries the child's own uncertainty in a form the parent can branch on: if it is non-empty, escalate rather than answer.
| Question type | Contract shape | Parent's next action |
|---|---|---|
| Enumerate call sites | array of {file, line, code, certain} | open and edit specific files |
| One factual lookup | bare list of values, no prose | iterate |
| Reconcile a ledger | verdict + referenced event IDs + residual | quote to the user, or escalate |
| Assess a design | ranked findings, each with a file reference | decide, with the option to verify one |
Reading down the last column explains the pattern. In every row the parent's next action is mechanical once the result is in hand, which is the property the contract is protecting; a result the parent has to interpret before it can act is a result that has moved the work across the boundary without moving the cost. The certain: false flag in the first row is an example worth sitting with, because it looks like a detail and is really a division of labour. The child is the component with the files in front of it and the parent is the component with the migration plan, so the child says what it saw and the parent decides what to do about the ambiguous seven — three of which turned out to be in a test helper Kettleworth was deleting that week, a fact the child had no way to know and would have burned four file reads failing to discover.
The failure mode at the other extreme is real and less discussed. A contract tighter than the question destroys the answer: force a strict JSON schema onto "does this migration plan have a problem" and you get three findings that fit the schema and silence about the fourth, which was the one that did not fit any field you thought to define. Exploratory questions want a contract on length and provenance — at most five findings, each naming a file — and a free-text body inside each finding. The rule is that the shape should be as specific as the parent's next action requires and no more specific than that.
Getting the contract right settles what comes back. It says nothing about what the child is permitted to do while it is out there, which is a separate question with a much worse worst case: the call-site-finder above happened to be given Read, Grep and Glob, and nobody has yet justified why that list stops there.
Subagents — read the current version at https://daviddominguez.dev/learn/en/subagents/the-output-contract-decides-whether-isolation-survives