Delegating Work with Handoffs
Introduction & Overview
Welcome to another lesson about agentic patterns! In the previous lesson, you mastered orchestrating agents as tools, where a central planner agent could dynamically delegate tasks to specialist agents and receive their results back. Today, we're exploring a fundamentally different approach called the handoff pattern, where agents can completely transfer control to other specialized agents rather than just calling them as tools.
In the completed example for this lesson, the Ruby Agent class in src/agent.rb supports the handoffs: parameter in its initialize method. You'll learn how the handoff tool schema enables control transfers, understand the core handoff logic that passes conversation context between agents, and see how the OpenAI::Client handles these interactions through the Responses API. We'll build a practical example with a general assistant that can hand off mathematical problems to a specialized calculator assistant, demonstrating how agents make intelligent decisions about when to transfer control versus when to handle tasks themselves.
Understanding the Handoff Pattern
The handoff pattern represents a different philosophy of agent collaboration compared to the tool delegation approach you learned previously. When an agent uses another agent as a tool, it's essentially asking for help while maintaining responsibility for the final response. When an agent performs a handoff, it's saying, "this other agent is better equipped to handle this entire conversation from here on."
Consider the difference in conversation flow. In tool delegation, the user interacts with the orchestrator throughout: the user asks a question, the orchestrator calls a specialist tool, receives the result, and then provides its own response incorporating that information. The user never directly interacts with the specialist agent.
In the handoff pattern, the conversation flow changes completely. The user starts by talking to one agent, but that agent recognizes when another agent should take over. The first agent transfers not just the task, but the entire conversation context to the specialist. In this teaching implementation, the specialist produces the final response for the current run call; if you want later user turns to continue with that specialist, your application should store the active agent in session state and route subsequent messages there. This pattern is particularly powerful when you have agents with very different capabilities or when the nature of a request clearly falls into one agent's domain of expertise.
The Agent Constructor with Handoffs
The Ruby Agent class in src/agent.rb has already been extended to support handoffs through its initialize method. Let's examine the key parameters that enable this functionality:
The handoffs: parameter accepts an array of other Agent instances to which this agent can transfer control. Notice the defensive copying pattern used throughout: handoffs ? handoffs.dup : []. This is a Ruby idiom that prevents external mutation of the agent's internal state. If handoffs is nil, we use an empty array []; otherwise, we create a shallow copy using dup. This ensures that modifications to the original array passed in won't affect the agent's internal handoff list.
We store handoffs as an array rather than a hash because agents are identified by their name attribute, and we want to maintain the flexibility to search through available agents dynamically. With the constructor already supporting handoffs, the next step is understanding how the handoff tool schema enables control transfers.
Creating the Handoff Tool Schema
The Agent class automatically creates a handoff tool schema when handoff targets are provided. This schema is stored in the @handoff_schema instance variable and enables agents to request control transfers:
The handoff schema uses the OpenAI function-calling format with string keys: a top-level "type" => "function", a "name", a "description", and a "parameters" JSON Schema object. It includes two required parameters: the name of the target agent and a reason for the handoff. The reason parameter serves as both documentation for debugging and a way to help the agent think through whether a handoff is truly necessary.
Notice how we dynamically include the list of available agents using string interpolation: #{available_handoff_names.join(", ")}. The available_handoff_names method returns an array of agent names, which we join into a comma-separated string. This helps GPT-5 understand which handoff options are available at runtime. Now let's see how this schema is integrated into the agent's tool list.
Building Request Arguments with Handoffs
To make handoffs work seamlessly, the build_request_args method combines regular tool schemas with the handoff schema when building API requests:
The method first builds an array called all_tools and conditionally adds schemas based on what's available: regular tool schemas from @tool_schemas are concatenated if present, and the @handoff_schema is appended if any handoff targets exist. It then constructs the request hash using Ruby's symbol key syntax (model:, input:, reasoning:, store:), placing the developer message at the front of the input array followed by the conversation messages via the splat operator (*messages).
This approach ensures that the handoff tool is automatically available to any agent that has handoff targets configured, without requiring manual schema management. The final hash only includes the :tools key if there are actually tools to provide. With the handoff tool now available to agents, let's examine the logic that actually performs the control transfer when this tool is called.
Implementing the Handoff Logic
The core of the handoff pattern lies in the call_handoff method, which handles the actual transfer of control from one agent to another. This method performs several critical operations:
The method executes the following steps in sequence:
-
Argument parsing: GPT-5 returns tool arguments as a JSON string, so we parse
function_call.argumentswithJSON.parse. We then extract the target agent'snameand handoffreasonfor logging and agent lookup. -
Agent lookup: Uses
findwith a block to search the@handoffsarray for an agent matching the requested name. This returnsnilif no matching agent exists, which we check with theunlessguard clause. -
Control transfer: Calls the target agent's
runmethod with the currentmessages, effectively transferring complete control of the conversation. Because a successful handoff returns immediately, the current agent does not need to append thehandofffunction_callto its own history first. If the handoff later fails and the current agent continues, therunloop will append the originalfunction_callbefore itsfunction_call_outputso the next Responses API call remains valid. -
Success return: Returns
[true, target_agent_response]— this two-element array format is crucial because it allows the main execution loop to distinguish between successful handoffs that should end the current agent's processing versus failed handoffs that should continue as normal tool interactions. -
Error handling: Uses
Ruby'sunlessguard to catch missing agents andrescue => eto catch any runtime errors. Failed handoffs return[false, function_call_output_hash], indicating that the handoff should be treated as a regular tool result, allowing the current agent to continue processing and potentially respond with alternatives.
Now let's see how the main execution loop handles these handoff responses.
Integrating Handoffs into the Execution Flow
The main execution loop in the run method needs to detect handoff tool calls and handle them differently from regular tools. When a handoff succeeds, it should immediately return the target agent's response rather than continuing the current agent's execution:
The key insight here is in how we handle the return value from call_handoff. We use Ruby's multiple assignment (handoff_success, handoff_result = call_handoff(...)) to unpack the two-element array. If the first element (handoff_success) is true, we immediately return handoff_result, which contains the target agent's complete response from target_agent.run(messages).
This immediate return is what makes handoffs different from tool calls: instead of collecting the result in function_outputs and continuing the conversation, a successful handoff ends the current agent's involvement and returns the target agent's complete response tuple [messages, final_response].
When a handoff fails (handoff_success is false), the handoff_result is a function_call_output hash. Before adding it to function_outputs, we also append the original handoff function_call to messages. This matters because, when not using previous_response_id, a function_call_output in the next request should correspond to a prior function_call in the supplied history. By recording both items, we keep the conversation state valid and let the current agent continue processing the failure as a normal tool-style result.
Notice the important distinction: for a successful handoff, we return immediately and make no further Responses API call from the current agent, so no local append is needed. For regular tools — and for failed handoffs that continue locally — we append both the function_call item and its corresponding function_call_output.
Setting Up the Agent System
Let's create a complete example that demonstrates how agents make intelligent handoff decisions. We'll set up a general assistant that can hand off mathematical problems to a specialized calculator assistant:
We load the tool schemas from JSON using JSON.parse(File.read("schemas.json")), which reads the file and parses it into Ruby hashes. The tools hash maps string names to Ruby method objects using the method(:function_name) syntax. This creates callable objects that the agent can invoke with keyword arguments.
Notice how we create the calculator_assistant first without any handoffs, then create the helpful_assistant with the calculator in its handoffs: array. This creates a clear hierarchy where the general assistant can transfer control to the specialist, but not vice versa. Now let's test the system with different types of questions to see how it makes handoff decisions.
Testing General Knowledge Questions
Let's test the system with a general knowledge question to see how the agent decides whether to handle the task itself or perform a handoff:
When we run this test, the general assistant recognizes that this is a straightforward factual question that doesn't require mathematical expertise:
The run method returns a two-element array: the message history and the final response text (response.output_text). We use Ruby's underscore convention (_history) to indicate we're not using that value. The agent handled this question directly without any handoffs or tool calls, demonstrating that it can distinguish between tasks it should handle itself and those requiring specialist expertise. Now let's test with a mathematical problem that should trigger a handoff to see the complete control transfer process in action.
Testing Mathematical Problem Handoffs
Now let's test with a mathematical problem that should trigger a handoff to demonstrate the complete control transfer process:
This test demonstrates the complete handoff process in action:
The execution trace shows the complete handoff process: the general assistant recognized that this was a mathematical problem requiring specialist expertise, initiated a handoff to the calculator_assistant with a clear reason, and then the calculator assistant took complete control of the conversation. The calculator assistant used its mathematical tools to solve the equation step by step and provided the final response directly to the user.
Notice that the tool call logs show the parsed JSON arguments with string keys ({"a"=>5, "b"=>2}), because GPT-5 returns tool arguments as JSON strings that our call_tool and call_handoff methods parse with JSON.parse. The actual tool calls made by GPT-5 may vary based on the model's reasoning, but the Ruby Agent class will log all tool invocations via puts statements in both call_tool and call_handoff.
When to Use Agents as Tools vs Handoffs
Understanding when to apply each pattern is crucial for building effective agent systems.
Use agents as tools when you need an orchestrating agent to maintain control and synthesize multiple specialist inputs into a unified response. This works well for complex tasks requiring coordination across different domains, like planning a trip that involves flights, hotels, and restaurants.
Use handoffs when a specialist is clearly better equipped to handle the entire conversation from a certain point forward. This is ideal when the task falls entirely within one domain of expertise and the specialist can provide more value through direct interaction than if it were filtered through an orchestrator.
The key question: Does the task require orchestration and synthesis, or does it need deep specialization with direct user interaction? Choose accordingly.
Best Practices and Common Pitfalls
When implementing handoffs in Ruby, success depends heavily on designing clear decision boundaries and robust error handling. The most effective handoff systems define explicit criteria in agent prompts, helping agents make confident transfer decisions rather than hesitating between options. For example, your general assistant should know precisely when mathematical problems warrant a calculator handoff versus when they can provide basic arithmetic directly.
Key practices for reliable handoffs include:
- Define clear handoff criteria in agent prompts so agents know exactly when to transfer control
- Transfer the conversation directly by passing
messagesto the target agent'srunmethod — the Responses API design keeps this clean without manual context surgery - Implement robust error handling with
unless target_agentandrescue => eblocks to gracefully handle failed handoffs - Use descriptive handoff reasons for debugging and system transparency
- Preserve valid tool-call history on failed handoffs by appending the original
function_callitem before itsfunction_call_output; this is especially important when you are not usingprevious_response_id - Design handoff chains with clear direction to avoid circular transfers
- Respect the
max_turnslimit — even with handoffs, each agent has a finite number of turns before raising an error
The biggest pitfall to avoid is creating circular handoffs where agents pass control back and forth indefinitely. Design your handoff chains with clear directionality and avoid giving agents too many transfer options, which can lead to decision paralysis.
Remember that when a handoff fails, it should be treated like a regular tool interaction only after you append the original handoff function_call item to messages. That keeps the function_call_output paired with a prior call in the conversation history, which is especially important when you're not using previous_response_id. This fallback mechanism ensures your system can handle edge cases like requesting nonexistent agents or encountering runtime errors during transfer.
The Ruby implementation's defensive copying (handoffs ? handoffs.dup : []) and careful tracking of which function_call items are appended to history are essential for preventing bugs related to shared state and malformed conversations. Always maintain these safeguards when extending the handoff functionality.
Summary & Preparation for Practice
You've now mastered the handoff pattern in Ruby with GPT-5, a powerful approach for building agent systems where specialists can take complete control of conversations when their expertise is needed. This pattern differs fundamentally from agent-as-tool delegation because it transfers not only the task but also the entire conversation ownership to the most appropriate agent.
You've learned how the Ruby Agent class uses the handoffs: parameter, defensive copying with dup, and the two-element return array pattern [success, result] to enable clean control transfers. You've seen how the call_handoff method finds target agents and handles errors gracefully, and how the main run loop distinguishes between successful handoffs that immediately return versus failed handoffs that continue as tool results while preserving valid function_call / function_call_output history.
In your upcoming practice exercises, you'll build multi-agent systems with complex handoff chains, where agents can intelligently route conversations through multiple specialists based on the evolving needs of each interaction. This foundation will enable you to create sophisticated agent ecosystems that can handle diverse, complex tasks while maintaining clear specialization and efficient resource utilization.
