Executive Summary
Multi-agent AI systems are increasingly used for lead qualification and top-of-funnel discovery. OpenAI’s Swarm was an experimental, educational Python framework built around two primitives, agents and handoffs. OpenAI’s repository now states that Swarm has been replaced by the OpenAI Agents SDK, which it describes as a production-ready evolution of Swarm, and recommends migrating for all production use cases. The Agents SDK is available for Python and for JavaScript/TypeScript.
In a typical AI-assisted revenue pipeline, specialized agents split the work:
- A prospecting agent verifies firmographic data.
- A discovery (BDR-style) agent holds the first conversations.
- A technical scoping agent gathers requirements and checks fit.
Qualification can be automated, but the close usually needs a person for negotiation, relationship building and contract execution. The hard engineering problem is therefore the human interception: reliably catching the moment agents finish their work and routing the deal, with its context, to a human Account Executive (AE) fast enough to matter.
Speed matters because response time affects outcomes. A widely cited 2011 Harvard Business Review study, The Short Life of Online Sales Leads, audited 2,241 U.S. companies by submitting test web leads. Only 37% responded within an hour, 23% never responded, and the average response among responders took about 42 hours. Firms that contacted a lead within an hour were nearly seven times as likely to qualify it as firms that waited even one hour longer. The study is more than a decade old and measured web-form leads, so treat it as directional rather than as a benchmark for AI-routed handoffs.
This guide covers how to intercept agent handoffs and route them to humans through Slack and Microsoft Teams, CRMs such as Salesforce and HubSpot, and telephony via Twilio. It uses Swarm to illustrate the concept and the Agents SDK for the production-oriented pattern.
Swarm vs. the OpenAI Agents SDK
| Swarm | OpenAI Agents SDK | |
|---|---|---|
| Status | Experimental, educational; replaced by the Agents SDK | Actively maintained by OpenAI; positioned for production |
| Core primitives | Agents and handoffs | Agents, handoffs, guardrails, sessions, tracing |
| Underlying API | Chat Completions API | Built on the Responses API |
| Memory | Stateless; you pass the history in on every call | Optional session objects for conversation memory |
| Languages | Python 3.10+ | Python and JavaScript/TypeScript |
| Observability | None built in | Built-in tracing, with support for custom trace processors |
| Human-in-the-loop | Not built in | Tool-approval flow that pauses a run and resumes from a serializable RunState |
If you are starting a new project, build on the Agents SDK. The Swarm examples below remain useful because they show handoff mechanics in very little code, and the interception patterns carry over.
Understanding the Handoff Architecture
Agents, Routines and Handoffs
Swarm’s README describes two primitives: agents and handoffs.
- Agents bundle instructions with a set of tools (plain Python functions).
- Handoffs occur when a function returns another
Agent. The run loop then switches to that agent’s instructions and tools while keeping the conversation history.
“Routines” (a set of instructions plus the tools needed to carry them out) is a pattern described in OpenAI’s cookbook material that Swarm was designed to demonstrate. It is a design idea rather than a separate class in the library.
In the Agents SDK, a handoff is also modeled as a tool call. The model sees each handoff target as a tool named transfer_to_<agent_name> by default. By default the receiving agent sees the entire prior conversation. You can change this with an input_filter, run custom logic with an on_handoff callback, and require structured input from the model via an input schema. The SDK also ships a recommended prompt prefix (RECOMMENDED_PROMPT_PREFIX) to help models use handoffs reliably.
+-------------------------------------------------------------------------+ | Autonomous Sub-Agent Loop | | | | +--------------------+ Handoff +-----------------------------+ | | | Discovery Agent | -------------> | Technical Scoping Agent | | | +--------------------+ +-----------------------------+ | +-------------------------------------------------------|-----------------+ | Escalation tool call | (halts the loop) v +-------------------------------------------------------------------------+ | Human AE Escalation & Routing Layer | | | | +-----------------------+ +-------------------+ +-----------------+ | | | Real-time alert | | CRM update | | Voice / chat | | | | (Slack / Teams) | | (Salesforce/HubS) | | takeover | | | +-----------------------+ +-------------------+ +-----------------+ | +-------------------------------------------------------------------------+ Context and State
- Swarm passes the message history plus a
context_variablesdictionary. If a tool function declares acontext_variablesparameter, Swarm injects it automatically and hides it from the model-facing schema. Swarm is stateless between calls, so your application must persist the history and context. - Agents SDK passes a typed context object to tools through
RunContextWrapper. The context is local to your code and is not sent to the model. Sessions can persist conversation history between runs.
Why Human Handoffs Are Different
A normal handoff assumes another agent is ready to take the next turn. A human AE introduces three frictions:
- Asynchronous availability. An AE’s availability depends on calendars, routing rules and presence, while the agent loop expects an immediate tool result.
- Context fragmentation. A human cannot read 40 turns of raw transcript in real time. They need a structured brief.
- Ownership and state. Once the handoff fires, the AI must stop replying to the prospect. Otherwise the AI and the human can answer the same message.
Pattern 1: Intercepting a Swarm Handoff
In Swarm, the simplest interception is a special tool whose job is to stop the autonomous loop and pass control to your routing layer. The example below raises a custom exception. Swarm’s run loop calls your tool functions directly, so an exception raised inside a tool propagates out of client.run() unless you catch it, and you handle it in the calling code.
Two caveats apply to this pattern. First, the exception approach is a pragmatic hack suited to prototypes. Second, any changes made to context_variables inside the tool are lost when the exception propagates, so the payload must be built in the tool and carried on the exception.
import json import logging from typing import Any, Dict, List from swarm import Swarm, Agent logging.basicConfig(level=logging.INFO) logger = logging.getLogger("SalesHandoffLogger") client = Swarm() class HumanHandoffException(Exception): """Raised by the escalation tool to break out of the autonomous loop.""" def __init__(self, agent_name: str, payload: Dict[str, Any]): self.agent_name = agent_name self.payload = payload super().__init__(f"Handoff intercepted from {agent_name} to a human AE.") def transfer_to_technical_scoping() -> Agent: """Hand the conversation to the technical scoping agent.""" return tech_scoping_agent def escalate_to_human_ae( context_variables: Dict[str, Any], qualification_summary: str, urgency_level: str = "HIGH", ) -> Agent: """Call when qualification criteria are met and a human Account Executive is required to continue.""" payload = { "session_id": context_variables.get("session_id"), "lead_profile": { "crm_lead_id": context_variables.get("crm_lead_id"), "email": context_variables.get("email"), "company_name": context_variables.get("company_name"), }, "qualification_data": { "bant_score": context_variables.get("bant_score"), "estimated_contract_value": context_variables.get("estimated_arr"), "urgency": urgency_level, "technical_requirements": context_variables.get("tech_stack", []), }, "conversation_digest": {"summary": qualification_summary}, "status": "AWAITING_HUMAN", } raise HumanHandoffException("Technical Scoping Agent", payload) tech_scoping_agent = Agent( name="Technical Scoping Agent", instructions=( "You evaluate technical and security readiness. When the prospect has " "confirmed their requirements and wants a custom quote, contract or demo, " "call escalate_to_human_ae with a concise summary." ), functions=[escalate_to_human_ae], ) discovery_agent = Agent( name="Discovery Agent", instructions=( "You are a discovery agent. Ask about team size, tech stack, budget and " "timeline. Once budget is confirmed, call transfer_to_technical_scoping." ), functions=[transfer_to_technical_scoping], ) def run_sales_pipeline(messages: List[Dict[str, str]], context: Dict[str, Any]) -> Dict[str, Any]: # Swarm is stateless: load the full history yourself and pass it in. try: response = client.run( agent=discovery_agent, messages=messages, context_variables=context, ) return {"status": "SUCCESS", "response": response.messages[-1]["content"]} except HumanHandoffException as event: logger.warning("Intercepted handoff: %s", json.dumps(event.payload)) dispatch_human_routing_pipeline(event.payload) return { "status": "HANDOFF_INTERCEPTED", "message": "Connecting you with an Account Executive.", "payload": event.payload, } def dispatch_human_routing_pipeline(payload: Dict[str, Any]) -> None: # Publish to your event bus / webhook layer (see the routing section below). logger.info("Dispatching handoff payload: %s", json.dumps(payload)) One limitation of this design is that the escalation message returned to the prospect is hardcoded in your application, and the run ends without a final model turn. That is usually what you want. Do not promise a specific wait time unless your routing layer can guarantee it.
Pattern 2: The Production Approach with the Agents SDK
The Agents SDK gives you cleaner ways to stop an agent loop without exceptions.
Halting the Loop with a Stop-at-Tool Behavior
An agent can be configured to end the run as soon as a specific tool is called, using tool_use_behavior. Because the run then ends with the tool’s output as the final result, your application sees a deterministic signal.
from dataclasses import dataclass, field from typing import List from agents import Agent, Runner, RunContextWrapper, StopAtTools, function_tool @dataclass class DealContext: session_id: str crm_lead_id: str company_name: str estimated_arr: int tech_stack: List[str] = field(default_factory=list) @function_tool def escalate_to_human_ae( ctx: RunContextWrapper[DealContext], summary: str, urgency: str = "HIGH", ) -> str: """Escalate the conversation to a human Account Executive.""" deal = ctx.context mark_session_pending(deal.session_id) # your Redis helper (see below) dispatch_human_routing_pipeline({ # your event-bus publisher "session_id": deal.session_id, "lead_profile": { "crm_lead_id": deal.crm_lead_id, "company_name": deal.company_name, }, "qualification_data": {"estimated_contract_value": deal.estimated_arr}, "conversation_digest": {"summary": summary}, "urgency": urgency, }) return "HANDOFF_REQUESTED" scoping_agent = Agent[DealContext]( name="Technical Scoping Agent", instructions=( "Confirm technical and security requirements. When the prospect is ready " "for a quote, contract or demo, call escalate_to_human_ae." ), tools=[escalate_to_human_ae], tool_use_behavior=StopAtTools(stop_at_tool_names=["escalate_to_human_ae"]), ) discovery_agent = Agent[DealContext]( name="Discovery Agent", instructions=( "Qualify budget, authority, need and timeline. When budget is confirmed, " "hand off to the Technical Scoping Agent." ), handoffs=[scoping_agent], ) # result = await Runner.run(discovery_agent, user_message, context=deal_context) # if result.final_output == "HANDOFF_REQUESTED": the human path is already in motion. Check the current Agents SDK documentation for exact signatures before shipping, as the API has evolved quickly.
Using the SDK’s Human-in-the-Loop Flow
The Agents SDK also has a built-in approval flow. A tool can declare needs_approval (either True or a function that decides per call). When the model calls such a tool, the run pauses and returns pending approvals as interruptions. You can serialize the run to a RunState, store it, and resume later after a person approves or rejects the call. The documentation says this flow is designed to be interruptible for long periods without keeping a server running.
This suits a different problem than lead routing. Use it when a human should approve a sensitive action by the agent, such as sending a contract or issuing a discount. Use the escalation-tool pattern above when the human should take over the conversation.
Voice Agents
For phone and voice channels, the Agents SDK also provides realtime agents (for example, RealtimeAgent and RealtimeSession in the JavaScript package). The docs describe an approval flow for realtime and voice sessions as well.
Real-Time Context Synthesis: The Sales Handoff Brief
Before you alert a rep, condense the conversation into a brief. A summarizer step (a separate LLM call, or a dedicated final agent) can map the transcript into a BANT (Budget, Authority, Need, Timeline) or MEDDPICC structure.
+-----------------------------------+ | Multi-Agent Execution History | | (dozens of raw turns) | +-----------------------------------+ | v +-----------------------------------+ | Context Transformation Layer | | (LLM summarizer / structurer) | +-----------------------------------+ | v +-----------------------------------------------------------+ | Structured Sales Brief (example) | | | | * Qualification score: 94/100 (internal model score) | | * Budget: $75k-$100k ARR (stated by prospect) | | * Pain points: legacy infrastructure latency, SOC 2 need | | * Decision maker: VP Engineering (in conversation) | | * Suggested next step: enterprise SLA, security review | +-----------------------------------------------------------+ The score, figures and names in these examples are illustrative. Define your own scoring rubric, and have the summarizer mark which facts the prospect stated versus which the model inferred, so a rep knows what to verify on the first call.
JSON Schema for the Handoff Payload
A strict schema lets Slack, CRM and telephony consumers share one contract:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "SwarmHumanSalesHandoff", "type": "object", "properties": { "session_id": { "type": "string", "format": "uuid" }, "timestamp": { "type": "string", "format": "date-time" }, "lead_profile": { "type": "object", "properties": { "crm_lead_id": { "type": "string" }, "first_name": { "type": "string" }, "last_name": { "type": "string" }, "email": { "type": "string", "format": "email" }, "company_name": { "type": "string" }, "employee_count": { "type": "integer" } }, "required": ["crm_lead_id", "email", "company_name"] }, "qualification_data": { "type": "object", "properties": { "bant_score": { "type": "number", "minimum": 0, "maximum": 100 }, "budget_confirmed": { "type": "boolean" }, "estimated_contract_value": { "type": "number" }, "buying_timeline": { "type": "string" }, "identified_pain_points": { "type": "array", "items": { "type": "string" } } }, "required": ["bant_score", "estimated_contract_value"] }, "conversation_digest": { "type": "object", "properties": { "summary": { "type": "string" }, "key_objections_raised": { "type": "array", "items": { "type": "string" } }, "last_prospect_message": { "type": "string" } } } }, "required": ["session_id", "timestamp", "lead_profile", "qualification_data", "conversation_digest"] } Validate every payload against the schema before dispatch (for example, with the jsonschema library in Python). If validation fails, use the fallback described in the production checklist.
Routing Blueprints
Once a handoff is intercepted, publish it once to an event bus (Redis Pub/Sub, Kafka, SNS/SQS or similar) and let independent consumers handle each channel. A failure in one consumer, such as a CRM outage, should not block the Slack alert.
+-----------------------------------+ | Intercepted Handoff Payload | +-----------------------------------+ | v +-----------------------------------+ | Event Bus (Redis Pub/Sub, etc.) | +-----------------------------------+ | +--------------------------+--------------------------+ | | | v v v +----------------+ +----------------+ +------------------+ | Slack / Teams | | CRM sync | | Voice / chat | | alert consumer | | consumer | | takeover bridge | +----------------+ +----------------+ +------------------+ Route 1: Slack and Microsoft Teams
Slack. Use Block Kit to send an actionable message with a claim button. Keep in mind:
- Slack expects an acknowledgement of interactive payloads (button clicks) within three seconds. Acknowledge immediately and do the claim logic asynchronously.
- Header blocks accept plain text only and are limited to 150 characters.
{ "blocks": [ { "type": "header", "text": { "type": "plain_text", "text": "AI handoff: high-intent lead", "emoji": true } }, { "type": "section", "fields": [ { "type": "mrkdwn", "text": "*Company:*\nAcme Corp (~$75k est. ARR)" }, { "type": "mrkdwn", "text": "*Lead:*\nJane Doe (VP Engineering)" }, { "type": "mrkdwn", "text": "*Qualification score:*\n94/100 (internal)" }, { "type": "mrkdwn", "text": "*Urgency:*\nContract requested" } ] }, { "type": "section", "text": { "type": "mrkdwn", "text": "*AI summary:*\nProspect completed technical scoping and confirmed budget. Requests a custom security addendum before end of day." } }, { "type": "actions", "block_id": "handoff_actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "Claim & open live chat" }, "style":...

