Chapter 1 of 7 · 16 min read
A missing constraint should be a validation failure, not a surprise
On this page
Two agents that divide a job between them have to move information from one to the other, and inter-agent communication is the design of the messages that do it: what one agent transmits when it hands work over, in what form, and what the receiver is entitled to assume about what arrives. Both components on either side of such a message are agents in the ordinary sense used throughout this topic: a language model wrapped in instructions and tools, given a job and left to decide the steps. Neither one can see inside the other. Everything the second one knows about what the first one discovered, it knows because the first wrote it into a message and sent it. This chapter is about the shape of that message, and the case it works from is a bid to Northmoor County Council that was disqualified over a delivery constraint the receiving agent never saw.
The design exists because dividing work between agents divides the context along with it. A single agent that reads a call transcript and then drafts a proposal holds everything it learned in one working context, where nothing has to cross anything and no information can be lost in transit. Splitting that job into a specialist for intake and a specialist for drafting — each with its own instructions, its own tools, sometimes its own model — buys focus, cheaper models on the easy steps, and the ability to change one half without touching the other, and it means the state that used to sit in one place now has to be moved to another. What arrives at the second agent is exactly what the first one sent and nothing else. The problem this topic is about is getting information from where it was discovered to where it is used, complete enough that the receiver acts on the same facts the sender held.
What the receiver is actually doing with your paragraph
You meet this wherever a system has more than one agent: a router passing a support ticket to a billing specialist, a planner handing steps to an executor, a research agent feeding a writer, or the peer transfers that topic 11.11 covered, where one agent stops and another takes over the conversation. Frameworks supply a default form for that message, and the default is almost always free text composed by the sending model. Here is one such message in full, as it appeared in the drafting agent's prompt after the intake agent had finished its discovery call.
Handoff from intake_agent:
Completed discovery call with Northmoor County Council (procurement ref
NMC-2024-118). They're replacing a 12-year-old case management system,
budget indicated around £1.8-2.2M over three years, decision by end of Q3.
Key stakeholders are Priya Raman (Head of Digital) and a finance director
who wasn't on the call. They seem to prefer in-house delivery teams and
mentioned a bad experience with a previous supplier. Strong interest in
the accessibility work we did for Harlow, they asked about it twice.
Timeline is tight — RFP responses due 14 March. Recommend we go hard on
the accessibility story and the public-sector references.
Please draft the technical response.That is a good message. A human account manager would be pleased to receive it. Read it as the drafting agent has to read it, though, and it contains at least four different kinds of statement with no marking to distinguish them. "Budget indicated around £1.8-2.2M" is a soft estimate from a conversation. "RFP responses due 14 March" is a hard external fact. "They seem to prefer in-house delivery teams" is a disqualifying requirement wearing the clothes of a preference. "Recommend we go hard on the accessibility story" is advice the receiver is free to ignore.
The receiver has to sort these into categories before it can act, and it is worth walking the reason that job falls to it at all, because the step is easy to skip and everything else follows from it. The drafting agent's entire input is a sequence of tokens. Nothing in a sequence of tokens marks the force of a sentence — whether it binds, informs or suggests — because English has no reserved syntax for that; "they seem to prefer" and "they require" are both ordinary declaratives, and a fluent writer chooses between them for tone as readily as for meaning. So force has to be recovered, and the only material available to recover it from is the wording. The wording was chosen by a sender that was optimising for sounding like a helpful colleague rather than for marking obligations. Each of those links is checkable on its own, and together they mean the receiver's classification of every sentence is a guess made from evidence that was not produced to support it.
That sorting job is a protocol. Every handoff has one — some agreement about what the sender is committing to and what the receiver is obliged to respect — and when you send prose, you are not removing the protocol, you are requiring the receiver to infer it fresh on every message. The inference is usually right, which is what makes this hard to argue about. It was right about the 14 March deadline. It was wrong once, on the field where being wrong costs nineteen days.
The specific failure mode is that absence is invisible. If the intake agent had never mentioned delivery teams at all, the paragraph would read exactly as fluently as it does now. There is no shape to a paragraph that says "a constraint should have been stated here and was not." Prose degrades gracefully into a slightly shorter, equally confident paragraph, and the receiver has no way to notice the difference between a run where the sender found no constraints and a run where the sender found constraints and forgot to write them down.
The belief that makes this defence look adequate is worth naming, because it is not carelessness and it survives a lot of review. The belief is that a fluent, well-organised handoff is evidence that the sender did its job, and the reason it is tempting is that fluency and diligence really do travel together in human writing: a colleague who files a vague note usually knows less than one who files a precise one. The reason it is wrong is that the quantity you are reading — fluency — is held constant by the model regardless of how much it found. A message from a run that established every constraint and a message from a run that asked about none of them are equally well-formed paragraphs. The property that varies is not the property you can see.
Giving each kind of statement a place to live
The alternative is a payload where each of those kinds has its own field, and where the boundary itself checks that the required ones are populated before the receiver ever sees them.
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class Evidence(BaseModel):
claim: str
source: str # "discovery_call:NMC-118:t=00:14:20"
confidence: Literal["stated", "inferred", "assumed"]
class Constraint(BaseModel):
rule: str # "all named delivery staff must be employees"
kind: Literal["hard", "preference"]
source: str
violation_consequence: str # "bid disqualified at compliance check"
class OpenQuestion(BaseModel):
question: str
blocks: bool # can the receiver proceed without an answer?
who_can_answer: str
class Handoff(BaseModel):
schema_version: Literal["1.0"] = "1.0"
correlation_id: str
goal: str = Field(min_length=1)
constraints: list[Constraint]
constraints_elicited: bool # did the sender actually look?
evidence: list[Evidence]
open_questions: list[OpenQuestion]
owner: Literal["intake", "drafting", "human_reviewer"]
returns_to: str | None
@model_validator(mode="after")
def constraints_must_be_deliberate(self) -> "Handoff":
if not self.constraints and not self.constraints_elicited:
raise ValueError(
"empty constraints with constraints_elicited=False: "
"sender did not establish whether constraints exist"
)
return selfRead the types rather than the field names and the design is legible. Constraint.kind is a Literal of two values rather than a free string, so the sender cannot express "sort of a requirement" and the receiver never has to interpret an adjective; the classification the drafting agent was guessing at in the paragraph is now made by the party that heard it said. Evidence.confidence is a Literal of three values for the same reason, separating what the buyer said from what the intake agent worked out. OpenQuestion.blocks is a bool because the only thing the receiver needs from an open question is whether it may proceed, and a boolean is the smallest type that answers exactly that. goal carries min_length=1 because an empty goal is a payload that passes structural validation and instructs nobody. Each of those is a decision about what the sender is forced to say, and a schema is nothing but the accumulation of such decisions.
The constraints_elicited flag is the part that does the work, and it is the part that gets cut in review for looking redundant. An empty constraints list is ambiguous in exactly the way the paragraph was: it might mean the buyer imposed none, or it might mean nobody asked. Forcing the sender to distinguish those two states means the boundary can reject the second one, and the model_validator is what does the rejecting — the validator runs after the fields are parsed, sees an empty list together with a false flag, and raises rather than returning an object. When the intake agent runs without ever raising the delivery-model question, this is what the run produces instead of a nineteen-day detour:
{
"event": "handoff_validation_failed",
"correlation_id": "nmc-118-a41c",
"from": "intake_agent",
"to": "drafting_agent",
"schema_version": "1.0",
"error": "empty constraints with constraints_elicited=False: sender did not establish whether constraints exist",
"action": "returned_to_sender",
"elapsed_ms": 3
}Three milliseconds, at the boundary, naming the agent that failed and the thing it failed to do. Compare that with the alternative diagnosis path, which runs through the drafting agent's output, past a human reviewer who assumed the subcontractor question had been checked, and terminates in a rejection letter from the buyer.
None of this is a new idea outside agent systems. Two services that exchange JSON with no agreed schema hit the same wall, and the industry spent two decades building Thrift, protobuf, OpenAPI and JSON Schema in response — formats and description languages whose common purpose is to let a machine check the shape of a message before anything acts on it — for the same reason: the caller and the callee are written by different people at different times, and the only thing that keeps them honest is a description of the message that a machine can check. What is unusual about agents is that the sender is a language model, so the temptation to skip the description is much stronger — the sender is fluent, its output reads like something a colleague wrote, and fluency is easily mistaken for an interface. A service that returned a paragraph instead of a response body would never survive review. A planner that returns a paragraph gets praised for being readable.
The same reasoning applies field by field. Evidence.confidence separates what the buyer said from what the intake agent inferred, so the drafting agent stops treating "budget around £1.8–2.2M" as a number it can quote. OpenQuestion.blocks gives the sender a way to say "I do not know who the finance director is and you cannot safely proceed without knowing," which prose can only express by hoping the receiver reads a hedge as a stop sign. owner and returns_to settle who is accountable for the outcome and where the result goes, which in a peer-transfer system — where, as topic 11.11 established, the sender stops running the moment it hands over — is otherwise nowhere at all.
The argument that sounds right and produces bad schemas
The most common defence of typed payloads in a design review is that structured data is more efficient: fewer tokens, easier for the receiving model to parse. Both halves of that are wrong, and believing them leads somewhere specific and bad.
Here are the two representations of the Northmoor handoff, measured with the same tokeniser.
| free-text paragraph | typed payload | |
|---|---|---|
| input tokens | 168 | 431 |
| of which key names and JSON punctuation | 0 | 94 |
| statements the sender is forced to make | 0 | 7 required fields |
| constraint present and marked as hard | no | yes |
| cost at $5/M input, per handoff | $0.00084 | $0.00216 |
| cost at 40k handoffs/month | $34 | $86 |
Every money figure in that table comes out of two multiplications, and doing them once makes the argument stop being a matter of taste. A price quoted per million input tokens is a price per token divided by a million, so a message of 168 tokens at $5/M costs 168 times five millionths of a dollar, which is $0.00084 — the first cost row. The typed payload's 431 tokens through the same arithmetic gives $0.00216. Multiply each by a month's traffic of 40k handoffs and you get $34 and $86, which is the last row. The difference between those two is $52 a month, and dividing the per-handoff difference the other way gives about $1.30 per thousand handoffs. That is the entire price of the change, and it is worth having in that form because the next section is a case where the money argument is made in the wrong direction.
The typed version is 2.6× the size, which is 431 divided by 168. Key names cost tokens on every message, and the 94-token row says how much of the increase that accounts for — under half of it. The rest is content the paragraph left implicit and the schema compels the sender to state: the source of each claim, whether a constraint is hard, whether it looked for constraints at all. That extra material is the entire benefit, and it is why the payload is larger. Paying $52 a month more for it is not a close call, but you have to be arguing for the right thing to see that.
Models also parse prose perfectly well. A frontier model, meaning one of the largest and most capable models available at a given moment, reading that paragraph will extract the deadline, the budget range and the stakeholder names without difficulty. What it cannot do is notice a constraint that is not there. The failure was never a parsing failure.
When a team adopts schemas on efficiency grounds, the schema it designs optimises for compactness, and the compact design is always the same one: three or four rigid fields plus a free-text notes or context string to carry everything else. Within two months the notes field is where constraints live, because constraints are irregular and a free-text field accepts anything. The team now has all the ceremony of a schema, all the cost of the key names, and the original failure mode intact inside one field, with the added problem that the notes field passes validation while empty. A schema that terminates in a prose blob has moved the boundary, not changed it.
Where the guarantee stops
It is worth stating exactly what a passing validation licenses, because the sentence a reader would confidently write after the section above — "the constraints have been checked" — is not true. What passes is a claim about transmission: the sender populated the fields the schema requires, and in particular it asserted, in a field it could not leave blank, that it either found constraints or established there were none. That is the whole licence.
The residual is everything about whether the sender was right. A validated payload says nothing about whether the constraints in it are correct, complete, or the ones that matter. An intake agent that never asks about delivery models can set constraints_elicited=True and pass every check in the file above. What the schema catches is the mismatch between what the sender knows and what it transmits; it has no view at all into whether the sender knows the right things. That is a problem about the sender's own instructions and tools, and no amount of typing at the boundary addresses it.
A form with required boxes is the closest everyday comparison, and it is worth naming as a comparison rather than letting it pass as a description. A form makes an applicant state their income rather than leaving the reader to infer it from context, which is exactly what the schema does to a constraint. Where the comparison breaks is that a form is checked by somebody who can also ask whether the applicant understood the question, and the boundary cannot: the validator is a function of the bytes it was handed and has no access to the conversation those bytes describe. Everything on the far side of that line belongs to the sender's design, not to the schema.
The failure the schema does convert is narrower than it first appears and worth stating exactly. Before the schema, a constraint that the sender held and did not transmit produced a behavioural difference somewhere downstream — a proposal built the wrong way, a quote approved that should not have been, a refund issued outside policy — with no marker anywhere connecting the outcome back to the moment the information stopped travelling. After the schema, the same event produces an exception with a timestamp, a correlation ID and the name of the agent that raised it, before the receiver spends a token. That is the whole trade: you have moved a class of silent behavioural difference into the class of loud, attributable errors, at a cost of about $1.30 per thousand handoffs and a coupling between two components that used to be free of one.
Nor does typing help where the value genuinely is unstructured. Some things a sender needs to pass really are a paragraph — a summary of the buyer's tone, a hunch about political dynamics inside the council, an observation that Priya Raman went quiet when the previous supplier came up. There is no field for that, and inventing one produces a schema nobody can fill in honestly. Prose carries whatever the schema has no slot for, which is a real capability rather than a consolation, and a design that forbids free text entirely will find that the irregular material simply stops crossing. The point of separating the two is that the typed fields are the part the boundary can check and the prose is the part it cannot, and keeping that line visible stops the checkable part from quietly leaking into the uncheckable one. A narrative field that explicitly holds colour, alongside a constraints list that explicitly holds limits, is a different design from a notes field that holds both, even though the JSON looks similar.
A handoff carried as prose asks the receiver to reconstruct a protocol from wording that was not written to encode one, and it gives a sender's omission no shape for anyone to notice. Typed fields move that reconstruction to the place where the information was actually known, and they make the one case that prose cannot represent — the constraint that should have been there and is not — into an exception with a name on it. The payload gets larger and the failure gets earlier, and those are the two halves of the same change.
There is one objection to all of this that is not confused, and it is the one that usually ends the discussion: a schema couples two agents that were previously free to change independently. Rename open_questions, and every sender and receiver deploys together or the boundary breaks. That is a real cost, prose does not pay it, and it deserves a better answer than the assurance that it rarely happens.
Inter-agent communication — read the current version at https://daviddominguez.dev/learn/en/inter-agent-communication/a-missing-constraint-should-be-a-validation-failure-not-a-surprise