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:

def initialize(
  name:,
  system_prompt: "You are a helpful assistant.",
  model: "gpt-5",
  tools: nil,
  tool_schemas: nil,
  handoffs: nil,  # New parameter for handoff targets
  max_turns: 10,
  reasoning_effort: "low"
)
  @client = OpenAI::Client.new
  @name = name
  @model = model
  @developer_prompt = BASE_DEVELOPER_PROMPT + system_prompt
  @max_turns = max_turns
  @reasoning_effort = reasoning_effort

  # Defensive copying to avoid shared mutable defaults
  @tools = tools ? tools.dup : {}
  @tool_schemas = tool_schemas ? tool_schemas.dup : []
  @handoffs = handoffs ? handoffs.dup : []  # List of agents for handoffs
end

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:

@handoff_schema = {
  "type" => "function",
  "name" => "handoff",
  "description" => "Transfer control to another specialized agent. Use this when the user's request is better handled by a different agent.",
  "parameters" => {
    "type" => "object",
    "properties" => {
      "name" => {
        "type" => "string",
        "description" => "Name of the agent to handoff to. Available agents: #{available_handoff_names.join(", ")}"
      },
      "reason" => {
        "type" => "string",
        "description" => "Brief explanation of why this handoff is needed"
      }
    },
    "required" => ["name", "reason"],
    "additionalProperties" => false
  }
}

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:

def build_request_args(messages)
  all_tools = []
  all_tools.concat(@tool_schemas) unless @tool_schemas.empty?
  all_tools << @handoff_schema unless @handoffs.empty?

  args = {
    model: @model,
    input: [
      text_message("developer", @developer_prompt),
      *messages
    ],
    reasoning: { effort: @reasoning_effort },
    store: false
  }

  args[:tools] = all_tools unless all_tools.empty?
  args
end

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:

def call_handoff(function_call, messages)
  args = JSON.parse(function_call.arguments || "{}")
  agent_name = args["name"]
  reason = args["reason"] || "No reason provided"

  puts "🔄 Handoff to: #{agent_name}"
  puts "📝 Reason: #{reason}"

  target_agent = @handoffs.find { |agent| agent.name == agent_name }

  unless target_agent
    return [
      false,
      {
        type: "function_call_output",
        call_id: function_call.call_id,
        output: JSON.generate(
          result: "Handoff failed: Agent '#{agent_name}' not found. Available agents: #{available_handoff_names}"
        )
      }
    ]
  end

  begin
    [true, target_agent.run(messages)]
  rescue => e
    [
      false,
      {
        type: "function_call_output",
        call_id: function_call.call_id,
        output: JSON.generate(
          result: "Handoff failed: Error during handoff to '#{agent_name}': #{e}"
        )
      }
    ]
  end
end

The method executes the following steps in sequence:

  1. Argument parsing: GPT-5 returns tool arguments as a JSON string, so we parse function_call.arguments with JSON.parse. We then extract the target agent's name and handoff reason for logging and agent lookup.

  2. Agent lookup: Uses find with a block to search the @handoffs array for an agent matching the requested name. This returns nil if no matching agent exists, which we check with the unless guard clause.

  3. Control transfer: Calls the target agent's run method with the current messages, effectively transferring complete control of the conversation. Because a successful handoff returns immediately, the current agent does not need to append the handoff function_call to its own history first. If the handoff later fails and the current agent continues, the run loop will append the original function_call before its function_call_output so the next Responses API call remains valid.

  4. 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.

  5. Error handling: Uses Ruby's unless guard to catch missing agents and rescue => e to 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:

def run(input_messages)
  messages = input_messages.map(&:dup)
  turn = 0

  while turn < @max_turns
    turn += 1

    response = @client.responses.create(**build_request_args(messages))

    function_calls = response.output.select do |item|
      item.type.to_s == "function_call"
    end

    if function_calls.any?
      function_outputs = []

      function_calls.each do |function_call|
        if function_call.name == "handoff"
          handoff_success, handoff_result = call_handoff(function_call, messages)
          return handoff_result if handoff_success

          messages << {
            type: "function_call",
            name: function_call.name,
            arguments: function_call.arguments,
            call_id: function_call.call_id
          }

          function_outputs << handoff_result
        else
          messages << {
            type: "function_call",
            name: function_call.name,
            arguments: function_call.arguments,
            call_id: function_call.call_id
          }

          function_outputs << call_tool(function_call)
        end
      end

      messages.concat(function_outputs)
    else
      messages << text_message("assistant", response.output_text)
      return [messages, response.output_text]
    end
  end

  raise "Max turns reached"
end

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:

require "json"
require_relative "agent"
require_relative "functions"

def text_message(role, text)
  {
    role: role,
    content: [
      { type: "input_text", text: text }
    ]
  }
end

tool_schemas = JSON.parse(File.read("schemas.json"))

calculator_assistant = Agent.new(
  name: "calculator_assistant",
  system_prompt: (
    "You are a calculator assistant. You specialize in mathematical calculations and solving equations. " \
    "Always use your available tools to compute."
  ),
  tools: {
    "sum_numbers" => method(:sum_numbers),
    "multiply_numbers" => method(:multiply_numbers),
    "subtract_numbers" => method(:subtract_numbers),
    "divide_numbers" => method(:divide_numbers),
    "power" => method(:power),
    "square_root" => method(:square_root)
  },
  tool_schemas: tool_schemas
)

helpful_assistant = Agent.new(
  name: "helpful_assistant",
  system_prompt: "You are a helpful assistant. You can assist with various tasks, but should always handoff specific tasks to specialist agents.",
  handoffs: [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:

messages = [text_message("user", "What is the capital of France?")]
_history, response = helpful_assistant.run(messages)
puts response

When we run this test, the general assistant recognizes that this is a straightforward factual question that doesn't require mathematical expertise:

Paris.

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:

messages = [text_message("user", "Solve x^2 - 5x + 6 = 0.")]
_history, response = helpful_assistant.run(messages)
puts "\n=== Final Response ===\n"
puts response

This test demonstrates the complete handoff process in action:

🔄 Handoff to: calculator_assistant
📝 Reason: Solve the quadratic equation x^2 - 5x + 6 = 0.
🔧 Tool called: power({"a"=>-5, "b"=>2})
🔧 Tool called: subtract_numbers({"a"=>25, "b"=>24})
🔧 Tool called: square_root({"a"=>1})
🔧 Tool called: sum_numbers({"a"=>5, "b"=>1})
🔧 Tool called: divide_numbers({"a"=>6, "b"=>2})
🔧 Tool called: subtract_numbers({"a"=>5, "b"=>1})
🔧 Tool called: divide_numbers({"a"=>4, "b"=>2})

=== Final Response ===
The solutions are x = 2 and x = 3.

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 messages to the target agent's run method — the Responses API design keeps this clean without manual context surgery
  • Implement robust error handling with unless target_agent and rescue => e blocks 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_call item before its function_call_output; this is especially important when you are not using previous_response_id
  • Design handoff chains with clear direction to avoid circular transfers
  • Respect the max_turns limit — 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.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal