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 this lesson, you'll work with the Ruby Agent class in src/agent.rb, which already 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 cleanly passes conversation context between agents, and see how the Anthropic::Client handles these interactions. 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. From that point forward, the specialist agent responds directly to the user, and the original agent is no longer involved. 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:

Ruby
def initialize(
  name:,
  system_prompt: "You are a helpful assistant.",
  model: "claude-sonnet-4-6",
  tools: nil,
  tool_schemas: nil,
  handoffs: nil,  # New parameter for handoff targets
  max_turns: 10
)
  @client = Anthropic::Client.new
  @name = name
  @model = model
  @system_prompt = BASE_SYSTEM_PROMPT + system_prompt
  @max_turns = max_turns

  # 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:

Ruby
@handoff_schema = {
  "name" => "handoff",
  "description" => "Transfer control to another specialized agent. Use this when the user's request is better handled by a different agent.",
  "input_schema" => {
    "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"]
  }
}

The handoff schema uses Ruby hash syntax with string keys, following the format expected by the Anthropic API. 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 Claude 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:

Ruby
def build_request_args(messages)
  args = {
    model: @model,
    system: @system_prompt,
    messages: messages,
    max_tokens: 8000
  }

  all_schemas = []
  all_schemas.concat(@tool_schemas) unless @tool_schemas.empty?
  all_schemas << @handoff_schema unless @handoffs.empty?

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

The method builds a hash using Ruby's symbol key syntax (model:, system:, messages:, max_tokens:). It creates an array called all_schemas 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.

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:

Ruby
def call_handoff(tool_use, messages)
  input = (tool_use.input || {}).transform_keys(&:to_s)

  agent_name = input["name"]
  reason = input["reason"] || "No reason provided"

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

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

  unless target_agent
    return [
      false,
      {
        type: "tool_result",
        tool_use_id: tool_use.id,
        content: "Handoff failed: Agent '#{agent_name}' not found. Available agents: #{available_handoff_names}"
      }
    ]
  end

  clean_messages = if messages.any? && messages.last[:role] == "assistant"
    messages[0...-1]
  else
    messages
  end

  begin
    [true, target_agent.run(clean_messages)]
  rescue => e
    [
      false,
      {
        type: "tool_result",
        tool_use_id: tool_use.id,
        content: "Handoff failed: Error during handoff to '#{agent_name}': #{e}"
      }
    ]
  end
end

The method executes the following steps in sequence:

  1. Input normalization: Transforms the tool input keys to strings using transform_keys(&:to_s), ensuring consistent access regardless of how the Anthropic API returns the data. Extracts 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. Context cleaning: Removes the assistant message containing the handoff tool call using Ruby's array slicing messages[0...-1]. The ... operator creates a range that excludes the last element, so the target agent receives a clean conversation history without seeing the internal handoff mechanics.

  4. Control transfer: Calls the target agent's run method with the clean_messages, effectively transferring complete control of the conversation.

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

  6. Error handling: Uses Ruby's unless guard to catch missing agents and rescue => e to catch any runtime errors. Failed handoffs return [false, tool_result_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:

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

  while turn < @max_turns
    turn += 1

    response = @client.messages.create(**build_request_args(messages))
    messages << { role: "assistant", content: response.content }

    if response.stop_reason.to_s == "tool_use"
      tool_results = []

      response.content.each do |content_item|
        next unless content_item.type.to_s == "tool_use"

        if content_item.name == "handoff"
          success, handoff_result = call_handoff(content_item, messages)
          return handoff_result if success
          tool_results << handoff_result
        else
          tool_results << call_tool(content_item)
        end
      end

      messages << { role: "user", content: tool_results }
    else
      return [messages, extract_text(response.content)]
    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 (success, handoff_result = call_handoff(...)) to unpack the two-element array. If the first element (success) is true, we immediately return handoff_result, which contains the target agent's complete response from target_agent.run(clean_messages).

This immediate return is what makes handoffs different from tool calls: instead of collecting the result in tool_results 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 (success is false), the handoff_result is a tool result hash, which gets added to tool_results just like any other tool call. The agent will see this error in the next turn and can respond accordingly.

Notice also the defensive check next unless content_item.type.to_s == "tool_use" — we convert the type to a string for comparison because the Anthropic Ruby client returns symbols in some contexts and objects in others. With all the handoff mechanics in place, let's create a complete example to test the system.

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:

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

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

math_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)
}

calculator_assistant = Agent.new(
  name: "calculator_assistant",
  system_prompt: "You are a calculator assistant. You specialize in mathematical calculations and solving equations using tools.",
  tools: math_tools,
  tool_schemas: tool_schemas
)

helpful_assistant = Agent.new(
  name: "helpful_assistant",
  system_prompt: (
    "You are a helpful assistant. You can assist with various tasks and handoff to the calculator assistant for math problems."
  ),
  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 math_tools dictionary 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:

Ruby
messages = [{ role: "user", content: "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:

text
The capital of France is **Paris**. It is not only the political capital but also the cultural, economic, and historical heart of the country. Paris is renowned for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral, and it is one of the most visited cities in the world.

The run method returns a two-element array: the message history and the final response 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

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
  • Clean conversation context using the messages[0...-1] slicing approach to remove handoff tool calls before transferring
  • 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
  • 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 becomes a regular tool result in the tool_results array, allowing the current agent to see the error message and respond appropriately. 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 message cleaning (messages[0...-1]) are essential for preventing bugs related to shared state and context pollution. Always maintain these safeguards when extending the handoff functionality.

Summary & Preparation for Practice

You've now mastered the handoff pattern in Ruby, 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, cleans message context, 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.

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