Handoffs

Transfer conversations between specialized agents

Agents can transfer conversations to other specialized agents when needed.

Diagram

Agent Handoff

billing

support

👤 User

🤖 Agent

💰 Billing

🔧 Support

Quick Start

  1. Simple Usage
    typescript
    import { Agent, handoff } from 'praisonai';
    
    const billingAgent = new Agent({
      name: 'Billing',
      instructions: 'You handle billing and payment questions'
    });
    
    const mainAgent = new Agent({
      name: 'Assistant',
      instructions: 'You are a helpful assistant',
      handoffs: [billingAgent]
    });
    
    // Automatically transfers billing questions
    await mainAgent.chat("I have a question about my invoice");
    // → Billing agent responds
  2. With Configuration
    typescript
    const mainAgent = new Agent({
      handoffs: [
        handoff({
          agent: billingAgent,
          description: 'Transfer for payment or billing questions'
        }),
        handoff({
          agent: supportAgent,
          description: 'Transfer for technical issues'
        })
      ]
    });

User Interaction Flow

Diagram
Billing AgentMain AgentUserBilling AgentMain AgentUser“Question about my invoice”Detect billing topicTransfer conversation“I can help with that…”

Configuration Levels

typescript
// Level 1: Array - Simple agent list
const agent = new Agent({
  handoffs: [billingAgent, supportAgent]
});

// Level 2: Dict - With descriptions
const agent = new Agent({
  handoffs: [
    { agent: billingAgent, description: 'For billing questions' }
  ]
});

// Level 3: Instance - Full control with conditions
import { handoff, handoffFilters } from 'praisonai';

const agent = new Agent({
  handoffs: [
    handoff({
      agent: billingAgent,
      description: 'For billing',
      condition: handoffFilters.topic(['invoice', 'payment', 'refund'])
    })
  ]
});

Generated tool name

Each handoff is exposed to the model as a tool named transfer_to_<lowercased_snake_of_agent_name>. The SDK sanitizes the agent name so any name — including spaces or punctuation — becomes a provider-legal tool name (capped at 64 characters).

Agent nameGenerated tool name
"Billing"transfer_to_billing
"Support Bot"transfer_to_support_bot
"Tier-2 Ops"transfer_to_tier-2_ops

Pass a name: on handoff() to override it. This matters when you reference the tool in prompts or read it in logs.


Generated tool description

The tool’s description is what the model reads to decide when to hand off. When you don’t pass a description:, the SDK builds one from the target agent’s name, role, and goal.

typescript
// No role/goal → sparse description
const plain = new Agent({ name: 'Support Bot' });
// Handoff description: "Transfer task to Support Bot"

// With role and goal → richer description, better routing
const rich = new Agent({
  name: 'Support Bot',
  role: 'Support Specialist',
  goal: 'Resolve customer tickets',
});
// Handoff description: "Transfer task to Support Bot (Support Specialist) - Resolve customer tickets"

Define role and goal on handoff targets and the main agent routes more accurately — no custom description: needed.


When to Transfer

User TopicBest Agent
Payment questionsBilling Agent
Technical issuesSupport Agent
Product inquiriesSales Agent
General questionsMain Agent

Safety Features

Handoffs include built-in protections, all controllable per handoff:

  • Cycle detection: Prevents A → B → A loops. Toggle via detectCycles; enabled by default. Raises HandoffCycleError before touching the target.
  • Depth limits: Maximum 10 handoffs in a chain by default; override with maxDepth. Raises HandoffDepthError before touching the target.
  • Timeouts: Handoffs time out after 5 minutes by default; override with timeoutSeconds (in seconds; <= 0 disables). Raises HandoffTimeoutError (always retryable).
  • Concurrency: Each Handoff instance has its own concurrency semaphore (default 5); override with maxConcurrent. Not process-wide.

The cycle check runs before the depth check, and both run before anything is pushed onto the chain — a rejected handoff never consumes a chain slot.


Controlling context, safety, and concurrency

handoff() accepts eight settings that steer what the target sees and how the transfer behaves. Add them one at a time.

  1. Steer what the target sees

    Show the target only the last N messages instead of the summary default.

    typescript
    import { Agent, handoff } from 'praisonai';
    
    const billing = new Agent({ name: 'Billing', instructions: 'Handle billing questions.' });
    
    const mainAgent = new Agent({
      name: 'Assistant',
      instructions: 'You are a helpful assistant',
      handoffs: [
        handoff({ agent: billing, contextPolicy: 'last_n', maxContextMessages: 10 })
      ]
    });
  2. Cap tokens and keep the system prompt

    Drop the oldest messages until the context fits a token budget, but never drop system messages.

    typescript
    handoff({
      agent: billing,
      contextPolicy: 'last_n',
      maxContextTokens: 4000,
      preserveSystem: true,
    });
  3. Bound execution

    Give the target a time limit and cap how many copies run at once.

    typescript
    handoff({ agent: billing, timeoutSeconds: 30, maxConcurrent: 3 });
  4. Add safety guards

    Refuse cycles and cap the chain depth.

    typescript
    handoff({ agent: billing, detectCycles: true, maxDepth: 5 });
  5. Set everything at once

    Combine all eight in one config object.

    typescript
    handoff({
      agent: billing,
      contextPolicy: 'last_n',
      maxContextMessages: 10,
      maxContextTokens: 4000,
      preserveSystem: true,
      timeoutSeconds: 30,
      maxConcurrent: 3,
      detectCycles: true,
      maxDepth: 5,
    });

Which context policy should I pick?

contextPolicy decides how much history the target sees.

Diagram

No, first turn

Yes, tokens matter

Semantic gist

Raw last N

Target starts fresh

What should the target see?

Long conversation?

contextPolicy: ‘full’

Semantic gist or raw tail?

contextPolicy: ‘summary’

contextPolicy: ‘last_n’ + maxContextMessages

contextPolicy: ‘none’


Inside a handoff

execute() runs a safety check, selects the context, seeds it onto the target, runs the target, then restores the target’s original history.

Diagram
TargetHandoffSourceUserTargetHandoffSourceUserAsk a questionexecute(context)checkSafety (detectCycles, maxDepth)selectHandoffMessages (contextPolicy, tokens, system)withSeededHistory(…)responserestore target’s original historyHandoffResult (context.handoffChain updated)Answer

HandoffConfig reference

Every option lives on the HandoffConfig object passed to handoff() (or new Handoff({ agent, ... })).

OptionTypeDefaultDescription
contextPolicy`’none’ \‘last_n’ \‘summary’ \‘full’`'summary'What the target sees. none = nothing; last_n = last maxContextMessages; summary = last 3 messages (hard-coded); full = everything.
maxContextMessagesnumber10Slice size for last_n only. <= 0 keeps everything. Ignored by other policies.
maxContextTokensnumber4000Token ceiling applied last; drops oldest first. <= 0 disables. TypeScript honours this beyond Python, which declares but never reads the field.
preserveSystembooleantrueKeep system messages regardless of slice or token budget. A system prompt larger than the budget is kept in full.
timeoutSecondsnumber300Bounds the target’s turn. <= 0 disables. Throws HandoffTimeoutError (always retryable).
maxConcurrentnumber5Per-instance semaphore. <= 0 = unlimited. Not process-wide — two Handoff instances at 1 each run in parallel.
detectCyclesbooleantrueThrows HandoffCycleError if the target is already in the handoff chain.
maxDepthnumber10Throws HandoffDepthError before the target is touched when the chain is already this deep.

Chain threading

The handoff chain tracks the agents already traversed to reach the current point. HandoffContext carries it on an optional field:

typescript
handoffChain?: readonly string[];

Leave it unset and the chain in force for the current execution is used — what nested handoffs want. Set it to resume a chain that crossed a process boundary (a queued handoff, a rehydrated request) or to start one deliberately deep. execute() writes the extended chain back onto the result’s context, so passing result.context onward threads it by hand.

The handoff module exposes helpers for advanced flows: currentHandoffChain() reads the chain in scope (oldest first), currentHandoffDepth() its length, runWithHandoffChain(chain, fn) runs fn under a given chain, selectHandoffMessages(messages, config) is the standalone context selector, and resetHandoffChain() clears the module-level fallback used where AsyncLocalStorage is unavailable (Node 18, browsers).

parallelHandoffs snapshots the current chain once and passes each sibling an explicit copy, so fan-out never reads the ambient value — each sibling gets an isolated chain, and cycle detection and maxDepth are enforced per task, not shared across siblings.


API Reference

Agent Module

Agent module with handoff support

AgentConfig

Agent configuration


Best Practices

Create focused specialists

Each agent should handle one domain well. This makes handoffs accurate.

Give agents clear roles and goals

Set role and goal on each handoff target. They are woven into the auto-generated handoff description (Transfer task to <name> (<role>) - <goal>), so the main agent routes more accurately without a custom description:.

Write clear descriptions

The main agent uses descriptions to decide when to transfer.

Explain transfers to users

Let users know they’re being connected to a specialist.


Tool-Boundary Policy & Parallel Handoffs

The TypeScript handoff stack matches the Python surface: a HandoffToolPolicy controls which tools survive a transfer, and parallel_handoffs fans a source agent’s work out across several specialists at once.

typescript
import { Agent, parallel_handoffs } from 'praisonai';

const billing = new Agent({ name: 'Billing', instructions: 'Handle billing inquiries.' });
const refund = new Agent({ name: 'Refunds', instructions: 'Process refund requests.' });
const triage = new Agent({ name: 'Triage', instructions: 'Route inquiries to specialists.' });

const results = await parallel_handoffs(
  triage,
  [
    [billing, 'Why was I charged twice?'],
    [refund, 'I want a refund for order #42'],
  ],
  { maxConcurrent: 5 },
);

for (const r of results) {
  console.log(r.handedOffTo, r.success, r.durationSeconds);
}
HandoffToolPolicy modes

resolveHandoffToolPolicy and HandoffToolPolicyMode enforce the tool boundary during a transfer — decide whether the target keeps the source’s tools, only its own, or a filtered set. DEFAULT_HANDOFF_TOOL_POLICY is applied when you don’t pass one.

HandoffTimeoutError is always retryable

A HandoffTimeoutError sets isRetryable === true unconditionally — a timeout may succeed on a second attempt. Cycle and depth failures raise HandoffCycleError (carrying context.cycle_path) and HandoffDepthError (carrying context.max_depth / context.current_depth).

Back-compat context aliases: chain / depth / max_depth

The handoff context accepts the legacy aliases chain, depth, and max_depth so older Python-shaped snippets keep working without a rewrite.


Related