Chapter 2 of 7 · 17 min read
A transcript is evidence; the receiver needs a contract
On this page
When one agent hands a conversation to another, something crosses the boundary between them, and in most implementations that something is the conversation so far. The receiving agent starts its turn with every message the sender saw, including the tool calls and their results, and nothing else. This chapter is about what that arrangement asks the receiver to do, why it fails at a rate you can measure, and what a transfer carries instead once the failure is understood.
The failure has a plain symptom. Customers were being asked for their order number twice, once by a triage agent and again, thirty seconds later, by the agent triage had handed them to. The complaint that reached the team was not about a wrong refund; the refunds were correct. It was about being asked the same question twice by what the customer had every reason to think was one system.
[triage] Can you give me the order number? It's at the top of your receipt.
[user] 41182
[triage] → lookup_order(order_id="41182")
→ {"order_id":"41182","total":68.40,"placed":"2026-07-29","status":"delivered"}
[triage] Thanks — I can see order 41182 for £68.40, delivered on 29 July.
Let me get you to our refunds team.
[triage] → transfer_to_refunds()
[refunds] Happy to help with that refund. Could I take your order number?
[user] ...I literally just gave it to youThe refunds agent received the entire transcript above, including the tool result. Nothing was lost in transmission. Over the week the team measured, 22% of the 418 refund handoffs re-asked for something already collected, adding one customer turn and a median of 38 s to the conversation.
Evidence is not instructions
What refunds received was a record of things that happened. What it needed was a statement of where the task stands. Those are different objects, and the transcript only becomes the second one after the receiving agent performs an inference over it: reading back through the messages, deciding which facts were established rather than merely mentioned, deciding which of those were confirmed by a tool rather than asserted by a customer, working out which parts of the original request are still outstanding, and inferring whether it is now expected to finish the job or to pass it on again.
Count those. There are five of them, each a judgement made under a token budget over material that grows at every hop, and each able to go wrong on its own. Suppose for a moment that each one lands correctly with some probability, and that the five are independent. Then the chance of a receiver getting the whole set right is the product of the five probabilities, and the chance of at least one going wrong is one minus that product. Two things follow directly from the shape of that expression, and both match what the team observed. Adding an instruction to the receiver's prompt raises at most one of the five factors, so it moves the product a little and cannot move it far. And because the five factors are multiplied rather than added, the failure rate does not fall proportionally with effort spent on any single one of them.
The independence assumption in that model is generous, and it fails in the direction that matters. The five judgements are made in one pass by one model over one context, so a context that is too long or too noisy to support the first judgement is the same context that has to support the fourth. When the material degrades, all five factors fall together, which means the product falls faster than independence predicts. Any figure derived from a model like this is a floor on the error rate rather than an estimate of it.
Clinical handover has the same structure and solved it the same way, and it is an analogy rather than a claim about agents. A nurse transferring a patient at shift change has the complete chart available and hands over an SBAR anyway — situation, background, assessment, recommendation — because the chart is what happened and SBAR is what you are being asked to do about it. The chart does not stop being useful; it stops being the interface. The receiving nurse reads the four fields, acts on them, and consults the chart when a field prompts a question.
The place that analogy breaks is worth naming, because the broken part is where the engineering goes. The nurse who writes an SBAR is still on the ward for a few minutes, can be asked a question, and is accountable for what she wrote. The agent that composed a transfer has stopped running by the time the receiver reads it; there is nobody to ask, and the fields were written by a model summarising its own turn. Every guarantee in the rest of this chapter exists because the author of the handover is gone before it is read.
The pattern arrived in agent systems by roughly this route. The OpenAI cookbook notebook Orchestrating Agents (routines and handoffs) implemented transfers from scratch as a function returning an agent, which is the loop from the previous chapter, and Swarm — OpenAI's small reference implementation, which is where the pattern got its name — packaged that. What OpenAI's Agents SDK added when it superseded Swarm was a typed input on the handoff itself and a filter over what the receiver inherits — handoff(agent=..., input_type=..., input_filter=...) in the Handoffs documentation — which exists because passing the conversation alone turned out not to be enough in practice. LangChain's multi-agent handoff documentation reaches the same conclusion from the other side, describing the transfer as a state handover rather than a message pass.
The five things a receiver has to be told
A payload that removes the inference has to answer five questions, and it is worth naming them separately because teams reliably build the first and stop.
| the receiver needs to know | the transcript gives it as | what goes wrong when it is inferred |
|---|---|---|
| what has been established, and by whom | prose scattered across turns | facts re-collected; tool results ignored |
| what remains to be done | implied by what has not been said | the receiver answers the wrong sub-task |
| who owns the task now | nothing at all | both agents wait, or both act |
| what budget the run has left | nothing at all | the run circulates until something times out |
| what would end the run | the original request, several thousand tokens back | the receiver transfers rather than finishing |
Read the middle column downwards and the design follows from it. Two of the five rows say the transcript supplies nothing at all: a conversation has no representation of ownership and no representation of a budget, so no amount of reading can recover them. Those two are not inference failures, they are absences, and the receiver's only options are to guess or to act as though the question does not arise. The other three rows are the inference failures, where the information is present and has to be dug out. A fix that only improves the digging leaves the two absent rows exactly where they were.
Expressed as a type, that is small. The version below is the one worth arguing about in review, because the arguments are all in the field types rather than in the prose around them.
from pydantic import BaseModel
from typing import Literal
class Fact(BaseModel):
key: str # "order_id", "refund_amount_gbp"
value: str
source: Literal["tool_result", "customer_utterance", "agent_inference"]
evidence: str | None # tool_call_id, or the message id it came from
as_of_s: int # when it was established, not when it was copied
class Constraint(BaseModel):
text: str # "do not email the customer about this"
scope: Literal["run", "task"]
stated_by: Literal["customer", "policy"]
class Handoff(BaseModel):
from_agent: str
to_agent: str
owner: str # the agent expected to act next; == to_agent
objective: str # the run's goal, verbatim from the opening turn
established: list[Fact]
constraints: list[Constraint]
remaining: list[str] # ordered, imperative, one clause each
stop_conditions: list[str] # what makes this run finished
budget: "RunBudget" # hops, tokens, spend, deadline — chapter 6Each row of the table has become a field, and the two absences have become fields with no transcript equivalent at all: owner, which names the agent expected to act next, and budget, which the run carries and each transfer decrements. Literal types are doing more work than they look like — a field restricted to three values can be compared for equality by code, which is what takes the check out of a model's hands, and a later chapter builds two whole classes of test on exactly that property.
Filled in for the conversation at the top of this chapter, the transfer carries about 220 tokens where the transcript carried 1,340, and nothing in it requires re-derivation.
{
"from_agent": "triage", "to_agent": "refunds", "owner": "refunds",
"objective": "Refund order 41182 if eligible and confirm the outcome to the customer.",
"established": [
{"key": "order_id", "value": "41182",
"source": "tool_result", "evidence": "call_7f31"},
{"key": "order_total_gbp", "value": "68.40",
"source": "tool_result", "evidence": "call_7f31"},
{"key": "delivery_status", "value": "delivered 2026-07-29",
"source": "tool_result", "evidence": "call_7f31"}
],
"constraints": [],
"remaining": ["Check refund eligibility for a delivered order past 14 days.",
"Issue the refund or explain why it cannot be issued.",
"Confirm the outcome to the customer."],
"stop_conditions": ["A refund decision has been communicated to the customer."],
"budget": {"hops_left": 11, "tokens_left": 178000,
"spend_left_usd": 1.40, "deadline": "2026-08-08T21:46:10Z"}
}The transcript still travels, and it is still useful — the customer's tone, the exact words of the complaint, and everything the payload's author did not think to extract all live there. What changes is its status. The payload becomes the receiver's instructions, rendered into its system message — the standing block of text at the top of a model's context, which it reads as direction rather than as conversation — where instructions belong, and the transcript sits behind it as material to consult rather than material to parse.
def build_receiver_prompt(agent: Agent, h: Handoff, transcript: list[dict]) -> list[dict]:
established = "\n".join(
f"- {f.key} = {f.value} (established by {f.source}, {f.evidence})"
for f in h.established
)
remaining = "\n".join(f"{i+1}. {s}" for i, s in enumerate(h.remaining))
constraints = "\n".join(f"- {c.text}" for c in h.constraints) or "- none recorded"
system = f"""{agent.instructions}
You now own this task. Objective: {h.objective}
ALREADY ESTABLISHED — do not ask the customer for any of these again:
{established}
CONSTRAINTS that apply to everything you do and to anything you hand on:
{constraints}
REMAINING:
{remaining}
The run is finished when: {"; ".join(h.stop_conditions)}
Budget left: {h.budget.hops_left} hops, ${h.budget.spend_left_usd:.2f}.
The conversation so far is attached as evidence. Consult it for wording and
context; do not re-derive the facts above from it."""
return [{"role": "system", "content": system}] + transcriptThe last two lines of that prompt are the whole mechanism stated in English, and the position of the block is the reason it works. The payload is rendered above the transcript, into the system message, so the receiver reads the established facts as direction before it reads a single conversational turn. Reversing that order — appending the payload after the transcript, as one more message — puts the same words in the same context window and returns the receiver to deciding for itself which of two accounts of the task to believe.
What the contract costs to compose
The payload is not free, and the place it costs something is the sending agent's own turn. A transfer_to_refunds() that takes no arguments is four output tokens; the same transfer carrying a typed Handoff is about 180 output tokens of generated JSON, which at $25 per million output tokens is $0.0045 and roughly 0.9 s of extra generation per hop. On a six-hop conversation that is under three cents and about five seconds, against a re-ask that was costing a customer turn and a median of 38 s each time it happened. The trade is favourable here, and it is worth checking rather than assuming, because a swarm with twenty hops and a payload that nobody reads is paying the composition cost for nothing.
The larger question is what happens to the transcript once the contract exists. Three arrangements are in use, and they differ on more than tokens.
| transcript only | contract + transcript | contract + last two turns | |
|---|---|---|---|
| input tokens at hop 3 | 12.4k | 12.8k | 2.1k |
| cost per hop, at $5/M in | $0.062 | $0.064 | $0.011 |
| re-ask rate | 16% | 1.4% | 1.9% |
| customer's own wording available | yes | yes | no |
| something to assert on in a test | no | yes | yes |
| behaviour when the payload is wrong | n/a | receiver can consult the evidence | receiver acts on the error |
The gaps between the columns are what the table is for, and there are two of them, pointing different ways. Between the first two columns the token gap is negligible — the payload is a few hundred tokens against a transcript of twelve thousand — while the re-ask gap is the whole effect. Between the second and third the token gap is large and the re-ask gap is nearly nothing. Read together, those say that the contract is doing the work and the transcript is doing almost none of it, right up until the last row.
The third column is the Agents SDK's input_filter applied aggressively, and its cost line is the reason people reach for it. What it gave up on this workload was recoverability. In the 1.9% of runs where the payload was wrong — a fact extracted from a customer's typo, a remaining list that omitted a step — the receiver with the transcript behind it noticed the mismatch and asked one clarifying question, while the receiver with two turns of context confidently executed the wrong task. Six complaints in a fortnight were traced to the second behaviour, all of them variants of the refunds agent refunding the wrong one of two orders the customer had mentioned. The arrangement that survived was the middle one until the transcript passes 15k tokens, at which point the filter drops the middle of the conversation and keeps the opening turn and the last four, which brought the median swarm conversation from chapter 1's $1.30 to $1.06 without moving the re-ask rate.
There is a circularity in that last row that a reviewer should see rather than be told. The recovery it describes depends on the receiver noticing that the payload disagrees with the transcript, and noticing that is an inference over the transcript — the same class of inference the contract was introduced to remove. Keeping the transcript therefore buys a second, weaker copy of the mechanism it replaced, which is genuinely worth having and is not a guarantee. The rate it recovers at is bounded by the same context pressure that produced the original errors.
The field that makes the payload trustworthy
That last detail is the difference between a payload that helps and a payload that launders a guess. Everything in established was written by the sending agent, which is a language model, and a model that says order_id = 41182, source: tool_result when no tool ran has produced a fact that looks more reliable than the sentence it replaced. Prose at least reads as prose. A JSON field reads as a fact, and downstream agents stop checking exactly the things that are now hardest to check.
This is the chapter's first trap worth naming as a trap, because it is a belief that a defence works. Typing a field constrains its shape, and a reader who has spent a day writing pydantic models will slide from there into believing it constrains its truth. It does not. source: Literal["tool_result", ...] guarantees that the string is one of three permitted strings and guarantees nothing whatsoever about whether a tool ran. The validator will accept a fabricated provenance claim as readily as a real one, because both are spelled the same way.
The source and evidence fields exist to keep that from being a silent failure, because between them they record each fact's provenance, meaning where the value came from and what a later reader could check it against. A fact claiming tool_result names a call_id, which is in the run's own message history, so the claim is verifiable without a model in the loop.
def verify(h: Handoff, tool_calls: dict[str, dict]) -> list[str]:
"""Return the facts whose provenance does not hold up."""
bad = []
for f in h.established:
if f.source != "tool_result":
continue
call = tool_calls.get(f.evidence or "")
if call is None:
bad.append(f"{f.key}: cites {f.evidence}, no such tool call")
elif f.value not in json.dumps(call["result"]):
bad.append(f"{f.key}={f.value}: not present in {f.evidence} result")
return badRunning that at the transfer boundary costs nothing — it is a dictionary lookup and a substring check, on the order of tens of microseconds — and it caught 31 fabricated provenance claims in the first 10,000 handoffs after it was deployed, all of them in one place: an agent whose instructions asked it to "summarise what you found", which it took as licence to state a plausible order total it had not looked up.
That number is a floor, and the two branches of the function say exactly why. The first branch catches a cited call that does not exist. The second catches a value that does not appear anywhere in the cited call's result. Neither branch can catch a fact that cites a real call and copies a real substring out of it under the wrong key — a total lifted from the wrong order in a two-order lookup passes both tests. The check is a containment test on a serialised blob, not a claim about which field of that blob the value came from, so it rules out invention and does not rule out misattribution.
A fact can be true when it is written and false when it is read
Provenance answers whether a fact was ever established. It does not answer whether it is still the case, and the second failure this payload produces is a fact that passes every check and is out of date. Order 41182 was looked up at 14:22:09 and came back delivered, and that is what established recorded, correctly, with a real call_id behind it. At 14:29 the customer explained that they had refused the parcel at the door and the carrier had already marked it returned. Two hops later the orders agent read delivery_status = delivered 2026-07-29, source: tool_result out of the payload and declined the refund under the policy for goods the customer has accepted, and the verification function above passed it, because the tool call happened and did return that string.
The transcript would not have saved this either — the refusal was mentioned once, in a sentence about the doorstep, twelve turns back. What fixes it is a timestamp on the fact and a statement of how long that kind of fact stays good for, which is a property of the key rather than of the run.
FRESHNESS_S = {
"order_id": None, # immutable
"order_total_gbp": None,
"delivery_status": 120, # carrier events land within two minutes
"refund_status": 0, # never trust a cached one; re-read
"stock_level": 300,
}
def render_fact(f: Fact, now_s: int) -> str:
ttl = FRESHNESS_S.get(f.key)
if ttl is None:
return f"- {f.key} = {f.value} ({f.source}, {f.evidence})"
age = now_s - f.as_of_s
if age <= ttl:
return f"- {f.key} = {f.value} ({f.source}, {f.evidence}, {age}s old)"
return (f"- {f.key} was {f.value} {age // 60}m ago and may have changed. "
f"Re-check with the appropriate tool before relying on it.")Rendering a stale fact with its age is better than dropping it, because dropping it sends the receiver back to the customer and reproduces the re-ask this chapter opened with. Told that a value is eight minutes old and volatile, the agent runs one cheap lookup and carries on. Over 10,000 handoffs the pattern showed up eleven times before the as_of field existed — six on delivery status, four on refund status, one on a stock level that had gone to zero between hops — which is a rate of about one in nine hundred, low enough that it was never going to be found by staring at a dashboard and expensive enough that two of the eleven became refund disputes.
The table of freshness values is where the second defence-that-works belief lives. A time-to-live is a constant attached to a key, and the thing it is defending against is the rate at which the world invalidates that key, which is not constant at all. A delivery status is immutable for weeks and then changes twice in ten minutes at somebody's front door. The constant is chosen for the ordinary case, so it is at its least useful precisely during the unusual event that makes the fact dangerous. What the mechanism actually buys is that a fact past its age is rendered with a warning attached rather than as a bare assertion; it does not buy freshness, and a reviewer who reads the dictionary as a guarantee of currency has read a heuristic as a proof.
Where the contract does not reach
A typed payload solves a transmission problem and leaves a judgement problem alone. It does not make the sender's summary complete, because remaining is the sender's opinion of what is left; it does not make stop_conditions correct, because they were written by whoever first phrased them and are copied forward; and it does nothing about the fact that a well-formed payload can be sent to entirely the wrong agent. A receiver that gets a perfect description of a refund task and is the notifications agent will do something confident and useless with it.
There is a general form to those three, and it is the limit on everything in this chapter. Every field in the payload is populated by the sending agent, so the payload can only be as complete as the sender's own view of the task. A check can compare what the payload says against something else the run holds — a tool call id, a clock — and no check can compare it against something the sender never noticed. The measurements this chapter reports are therefore all bucketed by what the senders chose to write down, which makes them statements about the design's behaviour rather than about the work's requirements.
It also cannot repair a constraint that was never extracted. constraints is empty in the example above because the customer did not state one, and the interesting failure — the one where the customer did state one and the field is still empty three hops later — is a different problem, because it involves loss across a chain rather than loss at a single transfer.
The move worth carrying out of this chapter is the demotion rather than the schema. A transcript is evidence: complete, honest, and silent about what anyone is supposed to do with it. Making it the interface asks every receiver to re-derive a task description from a record of events, and the derivation gets harder at every hop while the material it works over gets longer. Making it the appendix, behind a payload that states what has been established, what remains, who owns the work, what the run may still spend and what would finish it, moves that derivation to the one place where it is cheapest — the sender's turn, where the facts were just established and the context is smallest.
Both of the failures left open above are questions about the graph the payload travels over: who is allowed to hand to whom, whether control may come back, and what a sender is shown about a destination before it commits. That graph is usually not designed at all, which is the next chapter's subject.
Swarm / handoff — read the current version at https://daviddominguez.dev/learn/en/swarm-handoff/a-transcript-is-evidence-the-receiver-needs-a-contract