OpenAI Agents SDK, CrewAI, LangGraph
What an Agents SDK handoff actually does
A handoff looks like transferring control to another agent. Underneath it is a tool call whose result is a different system prompt and a different tool list.
· 8 min read
Handoffs are presented as a distinct capability: a triage agent hands off to a billing agent, which hands off to a refunds agent. It reads like message passing between processes, and people reasonably assume there is machinery underneath.
The whole mechanism
Two ideas: expose each possible transfer as a tool, and treat its return value as a signal rather than content.
interface Agent {
name: string;
instructions: string; // becomes the system message
tools: Tool[];
handoffsTo: string[]; // agents it may transfer to
}
// A handoff is exposed to the model as an ordinary tool.
function handoffTools(agent: Agent, all: Map<string, Agent>): Tool[] {
return agent.handoffsTo.map((name) => ({
name: `transfer_to_${name}`,
description: `Hand the conversation to ${name}: ${all.get(name)!.instructions.slice(0, 80)}`,
parameters: { type: "object", properties: {} },
run: async () => `__handoff__:${name}`, // sentinel, not an answer
}));
}
async function run(start: Agent, all: Map<string, Agent>, messages: Msg[], maxHops = 5) {
let agent = start;
for (let hop = 0; hop < maxHops; hop++) {
const tools = [...agent.tools, ...handoffTools(agent, all)];
const result = await runAgentLoop(
[{ role: "system", content: agent.instructions }, ...messages],
tools,
);
const transfer = result.find((m) => String(m.content).startsWith("__handoff__:"));
if (!transfer) return result; // this agent answered
// The only thing that changed: which system prompt and which tools.
// The conversation carries over untouched.
agent = all.get(String(transfer.content).split(":")[1])!;
messages = result.filter((m) => !String(m.content).startsWith("__handoff__"));
}
throw new Error("handoff loop did not settle");
}That is it. There is no second process, no message bus, no agent registry beyond a map. The "agents" are configuration: instructions plus a tool list.
Why the description field is the routing logic
Look at what the model actually decides on. It sees transfer_to_billing and a description, and picks from those strings. There is no matcher, no embedding, no classifier.
So routing quality is a copywriting problem. Two agents whose descriptions overlap produce transfers that look arbitrary, and the fix is editing prose rather than code. This is the biggest practical difference from a router you wrote yourself, where the branch is inspectable and testable.
Three things that look alike and are not
supervisor a manager model picks a worker, the worker returns a result,
the manager decides what happens next. Control comes back.
handoff the conversation is transferred. The new agent owns it, and the
previous one is not consulted again unless handed back.
subagent a nested run with its own context. Returns a summarised result
to the caller, who never sees the sub-transcript.The distinction that matters operationally is whether control returns. A supervisor sees every result and can correct course, at the cost of a model call per step. A handoff is cheaper and one-way: once billing has the conversation, nothing is watching whether billing was the right choice.
The failure modes are specific
Ping-pong. Two agents that each believe the other owns the request will transfer back and forth until something stops them. That is what maxHops is for, and it is the first thing to add when a framework does not give you one.
The transcript carries over, tools do not. The receiving agent reads everything the previous one said, including its reasoning about tools that no longer exist. It will occasionally try to use them. Filtering the history on transfer is a real design decision, and most defaults do not.
Instructions are replaced, not merged. Anything global (tone, safety rules, output format) has to be repeated in every agent's instructions, or it silently stops applying after the first transfer.
Nobody owns the outcome. With a supervisor there is one place to ask "did this task succeed?" After a chain of handoffs, the answer is distributed across agents that each did their part. This is a real cost in production, and it lands on whoever debugs it.
When it is the right shape
Handoffs fit when the specialisations are genuinely disjoint and the conversation belongs to one of them at a time: support triage, language routing, tiered escalation. They fit badly when a task needs several specialists to contribute to one answer, because that is a supervisor problem and modelling it as transfers means nobody is assembling the result.
Once you can see it as a tool call with a sentinel return value, the question stops being "which framework has handoffs" and becomes "should control come back or not". That question outlives every SDK that answers it.
The same mechanism, with its tradeoffs
Every pattern page runs its code on the page, in TypeScript, Python and Rust.