Agent Handoffs

Delegate tasks between specialised agents so each handles its area of expertise

Handoffs let one agent transfer a conversation to a specialist agent based on the request.

Python

python
from praisonaiagents import Agent

billing = Agent(name="Billing", instructions="Handle billing inquiries.")
refund = Agent(name="Refunds", instructions="Process refund requests.")
triage = Agent(
    name="Triage",
    instructions="Route customer inquiries to the right specialist.",
    handoffs=[billing, refund],
)

triage.start("I need a refund for my last order")

TypeScript

typescript
import { Agent } 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 customer inquiries to the right specialist.',
  handoffs: [billing, refund],
});

await triage.start('I need a refund for my last order');

The user describes their issue; the triage agent delegates to a specialist via a handoff tool call.

Diagram

Agent Handoffs

billing

refund

👤 User

🤖 Triage Agent

💳 Billing Agent

🔄 Refund Agent

✅ Response

Quick Start

  1. Pass agents directly

    Pass specialist agents to handoffs and the routing agent gets a transfer tool for each.

    python
    from praisonaiagents import Agent
    
    billing = Agent(name="Billing", instructions="Handle billing inquiries and payments.")
    refund = Agent(name="Refunds", instructions="Process refund requests.")
    
    triage = Agent(
        name="Triage",
        instructions="Route customer inquiries to the right specialist.",
        handoffs=[billing, refund],
    )
    triage.start("I need a refund for my order")
  2. Configure the handoff tool

    Use handoff() to rename the transfer tool or steer when the agent should call it.

    python
    from praisonaiagents import Agent, handoff
    
    escalation = Agent(name="Manager", instructions="Handle escalations.")
    
    triage = Agent(
        name="Triage",
        instructions="Route inquiries; escalate hard cases.",
        handoffs=[
            handoff(
                escalation,
                tool_name_override="escalate_to_manager",
                tool_description_override="Escalate complex issues to a manager.",
            ),
        ],
    )
    triage.start("This is my third failed delivery — I want a manager.")

How It Works

Diagram
SpecialistTriageUserSpecialistTriageUserRequestDecide which specialist fitstransfer_to_<agent> tool callRun full chat pipelineResponseFinal answer

When you set handoffs, PraisonAI converts each target into a transfer_to_<agent> tool, adds routing instructions to the agent’s prompt, and passes conversation history when control transfers.

PhaseWhat happens
1. DetectThe routing agent decides a specialist is needed
2. TransferThe matching handoff tool is called
3. RunThe target agent runs its full chat() pipeline
4. ReturnThe specialist’s response flows back to the user

Which Handoff Setup to Use?

Diagram

No, simple routing

Steer the choice

Need typed input

How should routing work?

Need custom naming or criteria?

Pass Agent directly
handoffs=&[billing, refund&]

handoff&(&) with
tool_description_override

TypedHandoff with
a Pydantic schema


Configuration Options

handoff() builds a configured transfer tool for a target agent.

Card

Handoff Tool Policy — tool security boundary options

OptionTypeDefaultDescription
agentAgentrequiredTarget agent to hand off to
tool_name_override`str \None`NoneCustom tool name (default transfer_to_<agent>)
tool_description_override`str \None`NoneCustom tool description shown to the LLM
on_handoff`Callable \None`NoneCallback run when the handoff is invoked
input_type`type \None`NoneExpected input type for structured data
input_filter`Callable \list[Callable] \None`NoneFunction (or chain) to filter/transform the history the target sees. The filtered result is now seeded onto the target’s chat_history.
tool_policy_mode`”intersect” \“passthrough”`"intersect"Which tools the target inherits
blocked_tools`list[str] \None`NoneTools always stripped from the target

What the default (SUMMARY) ships to the target:

  • any system messages,
  • the first user message (the task goal),
  • the last 3 messages, with any orphan tool/tool_calls message dropped so provider calls stay valid.

To get an LLM-generated summary, pass an input_filter.


Common Patterns

Pattern 1 — Callback on handoff

python
from praisonaiagents import Agent, handoff

def log_handoff(from_agent, to_agent, context):
    print(f"Transfer: {from_agent.name} -> {to_agent.name}")

billing = Agent(name="Billing", instructions="Handle billing inquiries.")

triage = Agent(
    name="Triage",
    instructions="Route billing questions to the specialist.",
    handoffs=[handoff(billing, on_handoff=log_handoff)],
)
triage.start("Why was I charged twice?")

Pattern 2 — Filter passed history

python
from praisonaiagents import Agent, handoff
from praisonaiagents import handoff_filters

technical = Agent(name="TechSupport", instructions="Solve technical problems.")

triage = Agent(
    name="Triage",
    instructions="Route technical issues to support.",
    handoffs=[handoff(technical, input_filter=handoff_filters.remove_all_tools)],
)
triage.start("The app crashes when I upload a file.")

Pattern 3 — Type-safe handoff

python
from praisonaiagents import Agent
from praisonaiagents.agent.handoff import TypedHandoff
from pydantic import BaseModel

class TaskData(BaseModel):
    priority: int
    description: str

specialist = Agent(name="Specialist", instructions="Resolve prioritised tasks.")

triage = Agent(
    name="Triage",
    instructions="Route tasks to the specialist with priority and description.",
    handoffs=[TypedHandoff(agent=specialist, input_schema=TaskData)],
)
triage.start("Escalate: priority 1, database is down.")

Running handoffs in parallel

parallel_handoffs runs several handoffs at once with concurrency control.

  1. List the targets

    Pass (agent, prompt) tuples — each runs as its own task.

    python
    from praisonaiagents import Agent, parallel_handoffs
    
    billing = Agent(name="Billing", instructions="Handle billing inquiries.")
    refund = Agent(name="Refunds", instructions="Process refund requests.")
    tech = Agent(name="TechSupport", instructions="Solve technical problems.")
    triage = Agent(name="Triage", instructions="Route inquiries to specialists.")
  2. Run them concurrently

    Await parallel_handoffs and inspect each HandoffResult.

    python
    import asyncio
    
    async def main():
        results = await parallel_handoffs(
            triage,
            targets=[
                (billing, "Why was I charged twice?"),
                (refund, "I want a refund for order #42"),
                (tech, "The app crashes on upload"),
            ],
            max_concurrent=5,
        )
        for r in results:
            print(r.target_agent, r.success)
    
    asyncio.run(main())

Concurrent handoffs to the same target

When two entries in targets point at the same specialist agent, both handoffs now run one-at-a-time on that agent. Each seeded turn is atomic, so one caller’s context can never leak into another’s — no user code change is needed.

python
from praisonaiagents import Agent, parallel_handoffs
import asyncio

specialist = Agent(name="Summariser", instructions="Summarise the chunk.")
main = Agent(name="Router", instructions="Route work to specialists.")

async def main_run():
    results = await parallel_handoffs(
        main,
        targets=[
            (specialist, "Summarise chunk 1"),
            (specialist, "Summarise chunk 2"),
        ],
        max_concurrent=2,
    )
    for r in results:
        print(r.target_agent, r.success)

asyncio.run(main_run())

The same fan-out is available in TypeScript via parallel_handoffs (aliased parallelHandoffs), imported top-level from praisonai:

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 tech = new Agent({ name: 'TechSupport', instructions: 'Solve technical problems.' });
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'],
    [tech, 'The app crashes on upload'],
  ],
  { maxConcurrent: 5 },
);

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

Per-Handoff concurrency

max_concurrent is enforced by a semaphore private to each Handoff instance, not shared across the process.

Two Handoffs with different max_concurrent values each get their own limit — the earlier one no longer clamps the later one.

python
from praisonaiagents import Agent, Handoff, HandoffConfig

triage = Agent(name="Triage")
billing = Agent(name="Billing")
refunds = Agent(name="Refunds")

to_billing = Handoff(billing, config=HandoffConfig(max_concurrent=10))
to_refunds = Handoff(refunds, config=HandoffConfig(max_concurrent=1))

triage.handoffs = [to_billing, to_refunds]

The billing lane runs up to 10 concurrent handoffs; the refunds lane serializes to one at a time. Each semaphore also rebinds automatically to the current event loop, so a process that calls asyncio.run() more than once keeps working.

The same per-instance limit works in TypeScript — pass maxConcurrent in each handoff’s config:

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

const triage = new Agent({ name: 'Triage' });
const billing = new Agent({ name: 'Billing' });
const refunds = new Agent({ name: 'Refunds' });

const toBilling = new Handoff({ agent: billing, config: { maxConcurrent: 10 } });
const toRefunds = new Handoff({ agent: refunds, config: { maxConcurrent: 1 } });

triage.handoffs = [toBilling, toRefunds];

Handoff Failures

When the LLM calls a handoff tool and it fails, the agent sees a dict, not a string.

python
from praisonaiagents import Agent, handoff

billing = Agent(name="Billing", instructions="Handle billing issues.")
support = Agent(name="Support", instructions="Front-line support.", handoffs=[handoff(billing)])

# On cycle, depth, or exception the tool result is:
#   {"error": "Error during handoff to Billing: ...", "handoff_failed": True}
support.start("Please escalate this refund.")
Diagram

cycle / depth / exception

🤖 Agent

🔧 Handoff Tool

{error, handoff_failed: True}

🪝 Hooks & Telemetry see a failure

Handoff Results

handoff_to() returns a HandoffResult describing the outcome.

python
from praisonaiagents import Agent

billing = Agent(name="Billing", instructions="Handle billing issues.")
support = Agent(name="Support", instructions="Front-line support.", handoffs=[billing])

result = support.handoff_to(billing, "Handle this billing issue")

if result.outcome.status == "success":
    print(result.outcome.output)
elif result.outcome.status == "timeout":
    print(f"Timed out: {result.outcome.error}")
else:
    print(f"Failed: {result.outcome.error}")
FieldTypeDescription
successboolWhether the handoff completed successfully
responsestrResponse from the target agent
target_agentstrName of the agent that received the handoff
source_agentstrName of the agent that initiated the handoff
duration_secondsfloatTime taken for the handoff
errorstrError message if the handoff failed
outcomeAgentRunOutcomeTyped outcome with .status and .is_retryable()

Best Practices

Give each agent one clear responsibility

A specialist with a focused role routes cleanly. Overlapping responsibilities make the routing agent’s tool choice ambiguous.

Filter history to cut tokens

Pass input_filter=handoff_filters.remove_all_tools or handoff_filters.keep_last_n_messages(5) so the target agent gets only the context it needs.

Use TypedHandoff for structured data

When a specialist needs typed fields, use TypedHandoff(agent=..., input_schema=Model) — the framework validates the payload at the boundary.

Keep a fallback path

Let the routing agent answer requests it cannot route rather than failing silently.


Related