Handing off between agents
The words cross, the state doesn't.
On this page · 11 sections
- Two things cross a handoff
- What the frameworks pass
- What Bookings’ model actually gets
- ”Nothing found” doesn’t mean “nothing will happen”
- The filter that erases the call
- The handoff record
- Authority doesn’t cross by itself
- What to drop on purpose
- Check what actually crosses
- Five questions for your handoff
- Run the two timings
Bookings told the user it had reserved one table. The restaurant was holding two.
This was a small scripted run. A concierge agent tried to book dinner, got no reply within ten seconds, and handed the job to a specialist agent called Bookings. The handoff included the full transcript: every message and tool call, 1,838 bytes. Bookings could see that a request had gone out without a result. It checked for a reservation, found nothing, and made one. Then the first request arrived.
The setup is the dinner concierge from the event-bus post. The user asks for Friday at seven, four people, Chez Lili, and a text to the group once the booking is confirmed. The concierge has a simple reserve_table tool; Bookings owns the restaurant integration. Calendar and Messaging handle the other parts of the evening.
Every request that changes something gets an idempotency key from the runtime. The restaurant uses that key to avoid executing the same request twice. Here, the concierge sends s1:op-a2. In it, s1 is the concierge’s session and op-a2 is its second request to the restaurant, the first being the availability check. Bookings later sends s2:op-a1, the first request in its own session. Those are different keys, so the restaurant treats them as different requests.
1 user Book Friday at seven for four at Chez Lili and text the group once it's confirmed. 2 tool.sent s1 reserve_table(Fri, 19:00, 4) key=s1:op-a2 3 tool.unknown s1 reserve_table: no reply within 10 s 4 handoff concierge -> bookings: full transcript (words 1838 B, record none) 5 model.read sees reserve_table sent, and no result 6 lookup.details Fri 19:00 x4: nothing found (no key to close) 7 tool.sent s2 reserve_table(Fri, 19:00, 4) key=s2:op-a1 8 restaurant committed R1 Fri 19:00 x4 9 tool.result s2 reserve_table -> R110 restaurant committed R2 Fri 19:00 x4-- restaurant holds: R1 (Fri 19:00), R2 (Fri 19:00)-- agent's last message: Booked Fri 19:00 for 4 (R1). Texting the group.Line 10 is the request from line 2, finally reaching the restaurant. The transcript let Bookings understand the situation. It did not give Bookings a way to settle that outstanding request before starting another.
The short version
- A handoff has two payloads. The next model needs enough conversation to understand the task. The next runtime needs the task’s origin, its constraints, the requests still waiting for an answer, and any approvals still pending.
- A complete transcript can still be incomplete state. In this demo, the sending runtime adds the booking’s key after the model makes the call. That key never appears in the conversation, however much history is forwarded.
- The receiver has work to do before planning. With a 310-byte record, it can first ask the restaurant, by the old request’s key, whether that request went through, and tell it to refuse that key from then on. Both network timings then leave one table.
- Once the state has its own record, trim the words. The receiving model needs only the user’s request here: 112 bytes. The record remains intact beside it.
Two things cross a handoff
The concierge’s model saw a request, a search result, an availability check, a booking call and a timeout. That is the conversation most handoff mechanisms are designed to move.
The runtime, the code that executes tools and tracks the work, knew things the model did not. It had assigned the booking’s key. It had marked the operation as in doubt. It knew the task had started with the user, rather than with a restaurant webhook. If an approval were pending, it would also know which action the user was being asked to approve.
There is a good reason to keep those details out of the model’s context, but they still have to move with the task.

These payloads serve different readers. A summary can be a good input to the next model. An unsettled operation needs an exact identifier and a known state. It should survive the handoff whether or not a summary mentions it.
What the frameworks pass
The common handoff styles make different choices about conversation history. These are the documented mechanisms the demo starts from, in the docs’ own words:
| Style | What the next agent gets | Source |
|---|---|---|
| Full history | ”it’s as though the new agent takes over the conversation, and gets to see the entire previous conversation history” | OpenAI Agents SDK, handoffs |
| Shared context | ”all agents share the same message context” | AutoGen Swarm |
| The last pair | ”Pass only these two messages, not the full subagent history”: the last AI message and the transfer’s tool message | LangChain, handoffs |
| A brief the model writes | ”they know nothing about the task, so share absolutely everything you know” | CrewAI’s delegation tool prompt |
| A message to another service, via A2A (Agent2Agent, an open protocol for agents in different services) | agents collaborate “without needing access to each other’s internal state, memory, or tools” | A2A 1.0.0 |
There are good reasons to argue about how much history to send. Cognition, the company behind the Devin coding agent, makes the case for sharing “full agent traces, not just individual messages” (Don’t Build Multi-Agents), to preserve the context behind earlier decisions. A short brief costs less to read, but it can leave out something the next model needs.
Neither choice, by itself, settles what happens to work already in progress. These conversation mechanisms do not define the application’s record of outstanding restaurant requests. A framework may give you a place to carry that record, such as shared context or a structured message part. You still have to write it and make the receiver use it.
What Bookings’ model actually gets
The demo compares five ways of forwarding words with a sixth option that adds the runtime’s record. It scripts the receiving model to behave sensibly: if anything it reads suggests a booking was attempted, it looks for that booking before making another. There are no live model calls, so the comparison isolates what the handoff carries.
Start with the full transcript. This is the companion program’s plain rendering of the messages; a provider’s API would wrap them in its own format.
-- Bookings' model reads: full transcript, 9 item(s), 1838 B 1 user Book Friday at seven for four at Chez Lili and text the group once it's confirmed. 2 assistant, tool call search_restaurants {"q": "Chez Lili", "near": "Paris"} 3 tool result, search_restaurants [{"name": "Chez Lili", "cuisine": "bistro", "rating": 4.6,... (1053 B in all) 4 assistant, tool call check_availability {"day": "Fri", "time": "19:00", "party": 4} 5 tool result, check_availability available 6 assistant Friday at 19:00 is free. Booking it now. 7 assistant, tool call reserve_table {"day": "Fri", "time": "19:00", "party": 4} 8 tool result, reserve_table error: no reply from the restaurant within 10 s 9 assistant The restaurant isn't answering. Handing over to Bookings: Friday at 19:00 for four.-- no record: Bookings' runtime has nothing of its own to act onItem 7 has the arguments the model supplied: day, time and party size. It has no idempotency key. The runtime attached s1:op-a2 when it sent the request, so sending more of this conversation cannot recover it. Item 3 alone accounts for 1,053 bytes of the payload.
A delegation brief removes most of that bulk. This one retains the fact that a booking went out:
-- Bookings' model reads: model brief, mentions the call, 1 item(s), 265 B 1 user task: Book the table context: User wants Chez Lili, Friday 19:00, party of 4; text the group once confirmed. I already sent the booking request but the restaurant didn't reply within 10 s. coworker: Bookings-- no record: Bookings' runtime has nothing of its own to act onThat last sentence is useful. It gives Bookings a reason to look for an existing reservation, though still no way to identify the exact request. Without it, the brief falls to 185 bytes, and Bookings has no reason to look before booking.
With a runtime record attached, the model gets the shortest input of all: the user’s original request, one item and 112 bytes. The separate 310-byte record goes to the receiving runtime.
-- Bookings' model reads: runtime handoff record, 1 item(s), 112 B 1 user Book Friday at seven for four at Chez Lili and text the group once it's confirmed.-- not for the model: the record, 310 B, which Bookings' runtime acts on first { "task": "book Fri 19:00 for 4 at Chez Lili, then text the group", ... }Here are the outcomes under both network timings. “Landed” means the restaurant made the booking but its reply was lost. “Delayed” means the request was still travelling at the handoff.
| Handoff style | Words | Record | Tables: request landed | Tables: request delayed |
|---|---|---|---|---|
| Full transcript | 1,838 B | none | 1 | 2 |
| Transcript, tools removed | 303 B | none | 1 | 2 |
| Last message and transfer | 200 B | none | 1 | 2 |
| Brief that mentions the call | 265 B | none | 1 | 2 |
| Brief that omits it | 185 B | none | 2 | 2 |
| Runtime handoff record | 112 B | 310 B | 1 | 1 |
From python -m agent_runtime.handoff. Bytes measure the payload as compact JSON, the way JavaScript’s JSON.stringify writes it. They are not token counts.

The last-pair handoff illustrates one of those gaps. It retains enough narration for the scripted receiver to look for a booking, but it loses the user’s instruction to text the group after confirmation. Counting reservations alone would miss that broken promise.
”Nothing found” doesn’t mean “nothing will happen”
The first four rows use the same fallback: look for a table on Friday at 19:00 for four people. If the original request has already landed, the lookup finds it and Bookings stops, though that outcome comes from when the network delivered the request rather than from anything Bookings knew. If the request is still travelling, the lookup finds nothing and Bookings makes another reservation. The lookup answers what is in the restaurant’s database now. It says nothing about a request that may arrive next.
This is the in-doubt operation from the revision post, now crossing an agent boundary. The sender dispatched a request and does not know whether it took effect, and a timeout does not settle that question.
The demo’s restaurant supports closing a key: in one operation, it reports whether that request landed and refuses any later attempt under the same key. If the service cannot do that, the alternative described in the revision post is to wait for the call to finish, or for a deadline enforced by the service to pass. Either approach needs the identity of the outstanding request.
A lookup by details has a second problem: those details need not belong to this booking. In another run, a different customer already has a Friday 19:00 table for four. Bookings finds that reservation and reports it as the user’s. The user’s delayed request then arrives separately.
1 restaurant R1 Fri 19:00 x4 is another customer's booking ... 6 model.read sees reserve_table sent, and no result 7 lookup.details Fri 19:00 x4: R1 (no key to close) 8 restaurant committed R2 Fri 19:00 x4-- restaurant holds: R1 (Fri 19:00, another customer's), R2 (Fri 19:00)-- agent's last message: Booked Fri 19:00 for 4 (R1). Texting the group.A request key identifies the operation, while a date, time and party size only describe what it was trying to do. The companion tests check that the key is absent from every words-only style in this example.
The filter that erases the call
Removing tool history is an appealing shortcut. Search results are large, availability checks go stale, and the receiving agent can often repeat a read. The OpenAI Agents SDK ships a remove_all_tools handoff filter for this, documented as “Filters out all tool items: file search, web search and function calls+output”.
At the commit used here, its removal list includes ToolCallItem, ToolCallOutputItem and ToolApprovalItem. Those item types cover more than disposable context. A booking in doubt and an approval awaiting a decision are removed along with the search results. After filtering this conversation, only three messages remain:
-- Bookings' model reads: transcript, tools removed, 3 item(s), 303 B 1 user Book Friday at seven for four at Chez Lili and text the group once it's confirmed. 2 assistant Friday at 19:00 is free. Booking it now. 3 assistant The restaurant isn't answering. Handing over to Bookings: Friday at 19:00 for four.-- no record: Bookings' runtime has nothing of its own to act onThe actual call is gone, and “Booking it now” is all that remains of the attempt. The scripted reader notices that sentence and looks for a reservation, which gives this row the same outcomes as the full transcript:
... 5 handoff concierge -> bookings: transcript, tools removed (words 303 B, record none) 6 model.read reads "Booking it now", and no result 7 lookup.details Fri 19:00 x4: R1 (no key to close)-- restaurant holds: R1 (Fri 19:00)-- agent's last message: Booked Fri 19:00 for 4 (R1). Texting the group.That result depends on the model having narrated the call. A quieter assistant could leave no trace of it in ordinary messages. The brief that omits the call demonstrates the consequence: Bookings starts a new reservation under either network timing. There is still a good reason to trim the conversation, provided the unfinished work is preserved before a filter removes its visible trace.
The handoff record
The sending runtime therefore needs to write down what it knows before handing the task over. In the demo, that is a task description and four fields. The record is written out by hand, standing for what the sending runtime would fill in from its operations log:
RECORD = { "task": "book Fri 19:00 for 4 at Chez Lili, then text the group", "origin": "user", "constraints": ["text the group only after the booking is confirmed"], "open_operations": [{"op": "op-a2", "tool": "reserve_table", "args": {"day": "Fri", "time": "19:00", "party": 4}, "key": CONCIERGE_KEY, "state": "unknown"}], "pending_actions": [],}originpreserves who started the task. A request from the user and a request triggered by a restaurant’s webhook can look similar in a chat message, but they do not carry the same permission to act. The turn post made this the rule for every turn.constraintspreserves the limits on the plan. “Text the group once it’s confirmed” means confirmation must come first, and the obligation has to survive a handoff that trims away the original message.open_operationsnames each request with an effect that has not been settled. Its arguments describe it, its key identifies it, andunknownrecords that its outcome is in doubt.pending_actionscarries decisions waiting for the user. It is empty in this run. When an approval is pending, the action and its ID need to move together, so the receiver does not ask again or treat silence as approval.

The receiver acts on the open operation before its model plans another booking:
in_doubt = [o for o in (record or {}).get("open_operations", []) if o["state"] == "unknown"]if in_doubt: op = in_doubt[0] rid = restaurant.close(op["key"]) tl.add("resolve", f"{op['key']}: {rid or 'nothing has landed'}; key closed") if rid: return ridThis example has one in-doubt operation, which is why the snippet takes the first entry. A receiver taking over several operations would have to settle each one before proceeding. The ordering is the same either way: resolve existing work, then plan new work.
restaurant.close checks and closes the key in one step. If the reservation already exists, the receiver adopts it. If it does not, the restaurant records that the old key is closed, and Bookings can make a new request under its own key without leaving the first one able to create a second table:
... 4 handoff concierge -> bookings: runtime handoff record (words 112 B, record 310 B) 5 resolve s1:op-a2: nothing has landed; key closed 6 tool.sent s2 reserve_table(Fri, 19:00, 4) key=s2:op-a1 7 restaurant committed R1 Fri 19:00 x4 8 tool.result s2 reserve_table -> R1 9 restaurant refused a late request: key s1:op-a2 is closed-- restaurant holds: R1 (Fri 19:00)-- agent's last message: Booked Fri 19:00 for 4 (R1). Texting the group.The old request still arrives, and this time it is refused.

In the lab, you can switch between all six handoff styles and both network timings. Start with the full transcript and a delayed request, then change only the handoff to the runtime’s record. Compare what the model reads with the last lines of the runtime log:
This lab runs a real call in your browser, so it needs JavaScript.
Authority doesn’t cross by itself
The booking race is visible in the restaurant’s final count. Losing the task’s origin can be harder to spot, because the next agent may complete exactly what it was told to do, under authority the sender never had.
Suppose a restaurant webhook starts the task. By the time it reaches Bookings or Messaging, it may be wrapped in an ordinary message from the concierge. That wrapper does not turn the restaurant’s request into the user’s instruction to book something or text the group.
The event-bus post covers the attack. The handoff’s responsibility is narrower: preserve the origin, so the receiving runtime can apply the same approval rule as the sender. The record must come from the runtime’s account of the task, not from a model’s guess about who seems to be speaking.
Why a message role is not the task's origin
In the OpenAI Agents SDK version used here, HandoffInputData holds the input history, the items before and during the handoff, and the run context. It does not define an application-level field for whose authority the task carries. An application can put that information in the context, but the history does not establish it.
In A2A 1.0.0, the delegating agent’s message travels as ROLE_USER, which the spec defines as “The message is from the client to the server”. The client may itself be another agent, so the role describes the protocol direction, not the person or system that originally requested the work.
That is why the record carries origin explicitly. The receiving application still has to trust the source of the record and enforce what the origin permits. A string saying "user" is not proof of consent.
What to drop on purpose
Once the runtime state has somewhere reliable to go, the conversation becomes easier to cut. The full transcript’s largest item is a search result: five restaurants, with addresses, ratings and phone numbers. It occupies 1,053 of the 1,838 bytes, and Bookings already knows which restaurant the user chose. For this task, I would make these cuts:
| Material | Keep it? | Reason |
|---|---|---|
| The user’s request | Yes | It tells the receiving model what the task is for, in the user’s own words |
| Search results and availability checks | No, here | They add bulk and may be stale. The receiver can ask again if it needs them |
| The sender’s reasoning and drafts | No | Decisions the receiver must respect belong in the record as constraints. Working notes can anchor it to an obsolete plan |
| Someone else’s raw text, such as an email or webhook | Only if needed, with attribution | The next agent needs to know whose words it is reading |
| Unsettled tool calls with effects | In the record | The runtime needs each operation’s key and state even if every related message is removed |
There is a privacy reason to make those cuts as well. The OpenAI docs warn that nesting the history “does not redact sensitive data” and say to “Treat the receiving agent and its model provider as recipients of the forwarded history” (handoffs.md). Putting old messages inside a summary wrapper does not change who can read them.
On the multi-agent platform I architected for inbound communications, handoffs between agents were events, and the privacy design limited what each stage could see. The small record here follows the same approach. Work that the next runtime must resolve crosses explicitly, and the model receives only the context it needs for its part of the task.
Check what actually crosses
The thing worth inspecting is the payload that reaches the next agent, after any filters or summaries have run. Capture it at the receiving side, then compare it with the sending runtime’s operations log. For every request with an effect that is still in doubt, the key should appear in the record, not only a description of the call in the words.
If the sending runtime doesn’t write a record yet, this is the shape to start from. Every placeholder is something the runtime knows and the transcript does not:
{ "origin": "<user, or the system that started the task>", "constraints": ["<what the user asked for that limits the plan>"], "open_operations": [ {"tool": "<the call with an effect>", "key": "<its idempotency key>", "state": "unknown"} ], "pending_actions": []}Fill it from the operations log, not from the conversation. A key that never reached the transcript cannot be recovered from it later.
Where the record lives in a framework
For an in-process OpenAI Agents SDK handoff, the record can live in the run context: “The context object is not sent to the LLM. It is purely a local object that you can read from, write to and call methods on it” (context.md). HandoffInputData gives the input filter access to it, so a filter can trim the messages while the record stays available to runtime code.
The same version has two relevant caveats. Server-managed conversations “do not support handoff input filters” (handoffs.md), and “Tool input guardrails do not run for handoffs” (context.md). Checks on handoff arguments therefore need to happen in the handoff path, such as on_handoff, before any side effects.
Across processes, a local context object does not cross the boundary. A2A parts can be “a structured data blob (i.e. JSON)”, so the record can travel as a typed data part beside the text. The receiving application then reads and validates it with code before handing the task to its model.
Five questions for your handoff
For a handoff that can trigger real actions, these are the questions I would put in the review:
- Which operations are unsettled, and does each key cross? Check the runtime’s operations log against the receiving payload. A transcript that mentions a call is not enough if it omits the identifier needed to settle it.
- What does the receiver do before planning? Close or otherwise settle each in-doubt operation under the service’s contract, and make that ordering part of the runtime.
- Whose authority does the task carry? Preserve its origin independently of message roles and summaries, then apply the corresponding permission rules.
- What is waiting for the user’s yes? Carry the pending action with the same ID and scope, so that moving between agents does not change what a later approval means.
- Which words still need to cross? Once the first four have a reliable representation, remove the context the next model does not need.
Run the two timings
The handoff runs live in mainlog-code/turn-without-the-user, in agent_runtime/handoff.py. They reuse the fake restaurant, idempotency keys and log format from the turn and revision posts.
cd mainlog-code/turn-without-the-user
python -m agent_runtime.handoff # compare the six handoff stylespython -m agent_runtime.handoff --sees full # what Bookings' model reads, one stylepython -m agent_runtime.demo handoff-full-delayed # the openingpython -m agent_runtime.demo handoff-record-delayed # with the record
python -m pip install pytestpython -m pytest tests # 83 tests, 11 of them for handoffsCI checks the lab’s recorded runs, the comparison table and each style’s model input. It also checks the two outcomes the argument depends on: two tables in the opening run, and the refusal of the late request in the record run.
The limits are in the README. Both agents run in one process, and the receiving model is scripted. “Lookup by details” is a helper over the fake restaurant’s bookings, not an API every restaurant provides. The demo shows what these payloads allow under two specific timings; it is not a benchmark of the frameworks or a complete distributed handoff protocol.
For an existing system, I would start with those same two timings. First let the request take effect while its reply is lost, then delay the request itself until after the handoff. A receiver tested only on the first case will pass, and still book twice when the second one happens.
This record carries one task across an agent boundary. Some information needs to survive well beyond that task: what the user said last month, what the restaurant claimed, and what has changed since. That is the subject of What an agent should remember.
Related posts

The agent event bus
When one agent calls another with a sentence, the caller's model writes the callee's prompt, and a stranger's note can ride along. In a small scripted demo a bus seemed to fix that, but a typed fact sent by call was just as safe, and a bus event with a note was not. What the bus adds is that one agent being down doesn't leave another's work half done.
AI & agentic architecturesai-agentsarchitecturedistributed-systems
Diagrams worth reading
My agent could already write Mermaid. Diagrams worth reading took a skill that chooses the picture, keeps the facts intact, and checks the drawing before handing it back.
AI & agentic architecturesai-agentsarchitecture
What a state machine buys an agent
The same dinner booking, sent to Claude as a free-form tool loop and as a state machine. Per-state tool lists sent 61% fewer tokens, but with prompt caching on they cost 23% more on input, and with output counted the two came out about even. The real saving was code running the steps that need no model, 8 calls down to 2 and about 72% off the total, plus a table of every state against every event.
AI & agentic architecturesai-agentsprompt-engineeringarchitecture
AIThe agent event bus
When one agent calls another with a sentence, the caller's model writes the callee's prompt, and a stranger's note can ride along. In a small scripted demo a bus seemed to fix that, but a typed fact sent by call was just as safe, and a bus event with a note was not. What the bus adds is that one agent being down doesn't leave another's work half done.
ai-agentsarchitectureSep 2026
AIDiagrams worth reading
My agent could already write Mermaid. Diagrams worth reading took a skill that chooses the picture, keeps the facts intact, and checks the drawing before handing it back.
ai-agentsarchitectureSep 2026
AIWhat a state machine buys an agent
The same dinner booking, sent to Claude as a free-form tool loop and as a state machine. Per-state tool lists sent 61% fewer tokens, but with prompt caching on they cost 23% more on input, and with output counted the two came out about even. The real saving was code running the steps that need no model, 8 calls down to 2 and about 72% off the total, plus a table of every state against every event.
ai-agentsprompt-engineeringOct 2026
Comments