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 extend the Agent class constructor to support handoff targets, create a handoff tool schema that enables control transfers, and implement the core handoff logic that cleanly passes conversation context between agents. 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 handling 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 that 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 is directly responding 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.

Extending the Agent Class Constructor

To implement handoffs, we need to extend our existing Agent class with the ability to transfer control to other agents. This requires adding a new parameter to track available handoff targets and creating a special handoff tool that agents can use to transfer control.

export interface AgentOptions {
  name: string;
  systemPrompt?: string;
  model?: string;
  tools?: Record<string, Function>;
  toolSchemas?: Anthropic.Tool[];
  handoffs?: Agent[];  // New parameter for handoff targets
  maxTurns?: number;
}

export class Agent {
  private static BASE_SYSTEM_PROMPT =
    "You are an autonomous agent that can take multiple tool-calling steps when helpful. " +
    "The user only sees your response when you stop using tools, not your tool usage or reasoning steps. " +
    "When you provide your answer without calling tools, make it complete and standalone.\n" +
    "Additional instructions:\n";

  private client: Anthropic;
  public name: string;
  private model: string;
  private systemPrompt: string;
  private maxTurns: number;
  private tools: Record<string, Function>;
  private toolSchemas: Anthropic.Tool[];
  private handoffs: Agent[];  // List of agents for handoffs
  private handoffSchema: Anthropic.Tool;

  constructor({
    name,
    systemPrompt = "You are a helpful assistant.",
    model = "claude-sonnet-4-6",
    tools = {},
    toolSchemas = [],
    handoffs = [],  // Default to empty array
    maxTurns = 10,
  }: AgentOptions) {
    this.client = new Anthropic();
    this.name = name;
    this.model = model;
    this.systemPrompt = Agent.BASE_SYSTEM_PROMPT + systemPrompt;
    this.maxTurns = maxTurns;

    // Copy to isolate from external mutation
    this.tools = { ...tools };
    this.toolSchemas = [...toolSchemas];
    this.handoffs = [...handoffs];  // Create new array to prevent external mutation
  }
}

The handoffs parameter accepts an array of other Agent instances to which this agent can transfer control. TypeScript's object destructuring pattern in the constructor allows us to specify default values directly in the parameter list, making the API clean and intuitive. We use the spread operator (...) to create shallow copies of the arrays and objects, protecting against external mutation while maintaining the original data. With the constructor updated, we need to create the handoff tool schema that will enable agents to request control transfers.

Creating the Handoff Tool Schema

Next, we need to create a tool schema that allows the agent to request handoffs. This schema will be automatically added to the agent's available tools when handoff targets are provided.

// Define handoff tool schema
this.handoffSchema = {
  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: ${JSON.stringify(this.handoffs.map(agent => agent.name))}`
      },
      reason: {
        type: "string",
        description: "Brief explanation of why this handoff is needed"
      }
    },
    required: ["name", "reason"]
  }
};

The handoff schema includes two required parameters: the name of the target agent and a reason for the handoff. The reason parameter serves both as documentation for debugging and as a way to help the agent think through whether a handoff is truly necessary. Notice how we use TypeScript's template literals (backticks) to dynamically include the list of available agents in the description. We need both map() and JSON.stringify() for different reasons: map() extracts just the agent names from the Agent objects (otherwise we'd try to stringify entire Agent instances with all their methods and properties), transforming the handoffs array into a simple string array like ["calculator_assistant", "researcher_assistant"]. Then JSON.stringify() converts this array into a properly formatted string representation that includes brackets and quotes, making it clear to Claude that these are distinct string values rather than just comma-separated words. Now we need to make this handoff schema available to the agent alongside its other tools.

Updating Tool Schema Building

To make handoffs work seamlessly, we need to modify the buildRequestArgs method to include the handoff schema when handoff targets are available.

private buildRequestArgs(messages: Anthropic.MessageParam[]): Anthropic.Messages.MessageCreateParams {
  // Create an object with the basic request arguments
  const requestArgs: Anthropic.Messages.MessageCreateParams = {
    model: this.model,
    system: this.systemPrompt,
    messages: messages,
    max_tokens: 8000,
  };

  // Build the complete tool schemas list
  const allTools: Anthropic.Tool[] = [];

  // Add regular tool schemas if they exist
  if (this.toolSchemas.length > 0) {
    allTools.push(...this.toolSchemas);
  }

  // Add handoff schema if handoffs are available
  if (this.handoffs.length > 0) {
    allTools.push(this.handoffSchema);
  }

  // Add tools to request if any exist
  if (allTools.length > 0) {
    requestArgs.tools = allTools;
  }

  // Return the complete set of arguments to use for the API call
  return requestArgs;
}

This modification ensures that the handoff tool is automatically available to any agent that has handoff targets configured, without requiring manual schema management. The method builds a complete list of available tools by combining regular tool schemas with the handoff schema when appropriate. TypeScript's explicit return type annotation (Anthropic.Messages.MessageCreateParams) provides compile-time type safety, ensuring we always return the correct structure for the API call. We use the spread operator with push() to add multiple tool schemas at once and check array lengths explicitly with .length > 0 rather than relying on truthy checks. With the handoff tool now available to agents, we need to implement the logic that actually performs the control transfer when this tool is called.

Implementing the Handoff Logic

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.

public async run(inputMessages: Anthropic.MessageParam[]): Promise<[Anthropic.MessageParam[], string]> {
  const messages = [...inputMessages];

  let turn = 0;

  while (turn < this.maxTurns) {
    turn++;

    const response = await this.client.messages.create(this.buildRequestArgs(messages));

    messages.push({ role: "assistant", content: response.content });

    // Execute all tools if Claude requests any
    if (response.stop_reason === "tool_use") {
      const toolResults: Anthropic.ToolResultBlockParam[] = [];
      for (const contentItem of response.content) {
        if (contentItem.type === "tool_use") {
          // If the tool use is a handoff
          if (contentItem.name === "handoff") {
            // Try to transfer control to another agent
            const [handoffSuccess, handoffResult] = await this.callHandoff(contentItem, messages);
            // If handoff was successful, return the result from the other agent
            if (handoffSuccess) {
              return handoffResult;
            }
            // If handoff failed, treat it as a regular tool result
            else {
              toolResults.push(handoffResult);
            }
          } else {
            // Execute regular tools
            const toolResult = await this.callTool(contentItem);
            toolResults.push(toolResult);
          }
        }
      }

      // The rest of the method stays the same...

This code creates two distinct execution paths based on handoff success:

Successful handoffs: When handoffSuccess is true, the method immediately returns the target agent's complete response using return handoffResult, bypassing all remaining tool processing and ending the current agent's involvement in the conversation. This is the key difference between handoffs and tool calls—handoffs transfer complete control rather than just collecting results.

Failed handoffs: When handoffSuccess is false, the handoffResult contains a tool result block with an error message like "Handoff failed: Agent 'unknown_agent' not found. Available agents: ["calculator_assistant"]". This gets pushed into the toolResults array just like any other tool result. Later in the tool processing loop, these results will be added to the messages array, which means Claude will see the error message in its next turn. The agent can then respond intelligently—perhaps by explaining the limitation to the user, suggesting alternatives, or attempting to handle the task itself instead of giving up.

TypeScript's array destructuring syntax (const [handoffSuccess, handoffResult] = await ...) cleanly unpacks the tuple returned by callHandoff, making the code readable and type-safe. The await keyword throughout ensures all asynchronous operations complete before proceeding, and we use push() to add elements to arrays, maintaining consistency with TypeScript's array methods.

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.

import fs from 'fs';
import Anthropic from "@anthropic-ai/sdk";
import { Agent } from './agent';
import {
  sumNumbers,
  multiplyNumbers,
  subtractNumbers,
  divideNumbers,
  power,
  squareRoot
} from './functions';

// Load the schemas from JSON file
const schemasJson = fs.readFileSync('schemas.json', 'utf-8');
const toolSchemas = JSON.parse(schemasJson);

// Create a map of tool names to functions
const mathTools: Record<string, Function> = {
  "sum_numbers": sumNumbers,
  "multiply_numbers": multiplyNumbers,
  "subtract_numbers": subtractNumbers,
  "divide_numbers": divideNumbers,
  "power": power,
  "square_root": squareRoot
};

// Create a calculator assistant
const calculatorAssistant = new Agent({
  name: "calculator_assistant",
  systemPrompt: "You are a calculator assistant. You specialize in mathematical calculations and solving equations.",
  tools: mathTools,
  toolSchemas: toolSchemas
});

// Create a general assistant (can handoff to calculator)
const helpfulAssistant = new Agent({
  name: "helpful_assistant",
  systemPrompt: "You are a helpful assistant. You can assist with various tasks and handoff to the calculator assistant for math problems.",
  handoffs: [calculatorAssistant]
});

Notice how we create the calculator assistant first without any handoffs, then create the general 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. TypeScript's Record<string, Function> type annotation explicitly declares that mathTools is an object mapping string keys to function values, providing type safety when accessing tools. We use fs.readFileSync() to synchronously read the schemas file and JSON.parse() to convert the JSON string into a JavaScript object. The new Agent({ ... }) syntax with object literals makes the instantiation clean and self-documenting. 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.

// Create a message list with a general knowledge question
let messages: Anthropic.MessageParam[] = [
  {
    role: 'user',
    content: 'What is the capital of France?'
  }
];

// Run the orchestrator agent
let [resultMessages, response] = await helpfulAssistant.run(messages);

// Display the final response
console.log(response);

We use let for the messages variable because we'll reassign it in the next test, and TypeScript's explicit type annotation (Anthropic.MessageParam[]) ensures type safety for the message array. The await keyword is essential since run() is an async method that returns a Promise. Array destructuring ([resultMessages, response]) cleanly unpacks the tuple returned by the agent, and we use console.log() to display the output.

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

The capital of France is Paris. Paris is located in the north-central part of France and is the country's largest city and political, economic, and cultural center. It's famous for landmarks like the Eiffel Tower, Louvre Museum, Notre-Dame Cathedral, and the Champs-Élysées.

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.

// Create a message list with a math question
messages = [
  {
    role: 'user',
    content: 'What is the solution to the equation x² - 5x + 6 = 0?'
  }
];

// Run the orchestrator agent
[resultMessages, response] = await helpfulAssistant.run(messages);

// Display the final response
console.log('\n=== Final Response ===\n');
console.log(response);

We reassign the messages variable with a new array containing the mathematical question, demonstrating TypeScript's mutable variable handling with let. The same async/await pattern and array destructuring syntax apply here, maintaining consistency with the previous test.

This test demonstrates the complete handoff process in action:

🔄 Handoff to: calculator_assistant
📝 Reason: The user is asking for the solution to a quadratic equation, which requires mathematical computation and algebraic problem-solving skills that the calculator assistant is specialized for.
🔧 Tool called: power({"base":-5,"exponent":2})
🔧 Tool called: multiply_numbers({"a":4,"b":1})
🔧 Tool called: multiply_numbers({"a":4,"b":6})
🔧 Tool called: subtract_numbers({"a":25,"b":24})
🔧 Tool called: square_root({"number":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 to the equation x² - 5x + 6 = 0 are:

**x = 3** and **x = 2**

This can also be verified by factoring: x² - 5x + 6 = (x - 3)(x - 2) = 0

Therefore, x - 3 = 0 or x - 2 = 0, giving us x = 3 or x = 2.

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.

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 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, 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
  • Always clean conversation context by removing handoff tool calls before transferring
  • Implement robust error handling for failed handoffs with graceful fallbacks
  • Use descriptive handoff reasons for debugging and system transparency
  • Design handoff chains with clear direction to avoid circular transfers

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 handoffs should feel like natural conversation flows, similar to being transferred to the right department in a well-organized company rather than bouncing between confused representatives.

Summary & Preparation for Practice

You've now mastered the handoff pattern, 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 just the task, but the entire conversation ownership to the most appropriate agent.

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