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
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
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.
Quick Start
- Pass agents directly
Pass specialist agents to
handoffsand 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") - 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
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.
| Phase | What happens |
|---|---|
| 1. Detect | The routing agent decides a specialist is needed |
| 2. Transfer | The matching handoff tool is called |
| 3. Run | The target agent runs its full chat() pipeline |
| 4. Return | The specialist’s response flows back to the user |
Which Handoff Setup to Use?
Configuration Options
handoff() builds a configured transfer tool for a target agent.
Handoff Tool Policy — tool security boundary options
| Option | Type | Default | Description | ||
|---|---|---|---|---|---|
agent | Agent | required | Target agent to hand off to | ||
tool_name_override | `str \ | None` | None | Custom tool name (default transfer_to_<agent>) | |
tool_description_override | `str \ | None` | None | Custom tool description shown to the LLM | |
on_handoff | `Callable \ | None` | None | Callback run when the handoff is invoked | |
input_type | `type \ | None` | None | Expected input type for structured data | |
input_filter | `Callable \ | list[Callable] \ | None` | None | Function (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` | None | Tools always stripped from the target |
What the default (SUMMARY) ships to the target:
- any
systemmessages, - the first user message (the task goal),
- the last 3 messages, with any orphan
tool/tool_callsmessage dropped so provider calls stay valid.
To get an LLM-generated summary, pass an input_filter.
Common Patterns
Pattern 1 — Callback on handoff
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
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
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.
- 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.") - Run them concurrently
Await
parallel_handoffsand inspect eachHandoffResult.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.
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:
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.
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:
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.
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.")Handoff Results
handoff_to() returns a HandoffResult describing the outcome.
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}")| Field | Type | Description |
|---|---|---|
success | bool | Whether the handoff completed successfully |
response | str | Response from the target agent |
target_agent | str | Name of the agent that received the handoff |
source_agent | str | Name of the agent that initiated the handoff |
duration_seconds | float | Time taken for the handoff |
error | str | Error message if the handoff failed |
outcome | AgentRunOutcome | Typed 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.