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.
1. Agents bundle instructions with a set of tools (plain Python functions).
2. 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_variables` dictionary. If a tool function declares a `context_variables` parameter, 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:
1. Asynchronous availability. An AE’s availability depends on calendars, routing rules and presence, while the agent loop expects an immediate tool result.
2. Context fragmentation. A human cannot read 40 turns of raw transcript in real time. They need a structured brief.
3. 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],
)
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": "primary",
"value": "claim_session_LEAD-99823",
"action_id": "claim_lead_action"
},
{
"type": "button",
"text": { "type": "plain_text", "text": "Schedule AE demo" },
"value": "schedule_demo_LEAD-99823",
"action_id": "schedule_demo_action"
}
]
}
]
}
```
Microsoft Teams. Microsoft has been retiring Office 365 Connectors (the old incoming webhooks) in Teams in favor of the Workflows app (Power Automate). According to Microsoft’s Message Center notice MC1181996, the final deprecation rollout was scheduled for May 18–22, 2026, and connector-based webhooks had to be migrated to Workflows before then. Workflows webhooks accept Adaptive Cards. Interactive elements such as buttons and inputs are not supported for legacy MessageCard payloads, so build your alert as an Adaptive Card. Custom bot names and icons are not supported for Adaptive Card payloads posted this way. If you need richer interactivity, such as claim buttons that call your service, look into a Teams bot rather than a simple webhook.
Route 2: CRM Ingestion
Salesforce. Using the `simple-salesforce` library, update the lead and create a high-priority task for the owner. Two details commonly trip people up:
- `Lead.AnnualRevenue` is the *prospect company’s* annual revenue, not the expected contract value. Store deal size in a custom field (for example, `Estimated_Contract_Value__c`) or on an Opportunity after conversion.
- `Status` must be an existing value in your org’s Lead Status picklist. Add a value for AI-qualified leads, or use an existing one, rather than writing arbitrary text.
```
import logging
import os
from simple_salesforce import Salesforce
logger = logging.getLogger("SalesHandoffLogger")
def sync_handoff_to_salesforce(payload: dict) -> None:
sf = Salesforce(
username=os.environ["SF_USERNAME"],
password=os.environ["SF_PASSWORD"],
security_token=os.environ["SF_TOKEN"],
domain="login",
)
lead_id = payload["lead_profile"]["crm_lead_id"]
qual = payload["qualification_data"]
summary = payload["conversation_digest"]["summary"]
sf.Lead.update(lead_id, {
"Status": "AI Qualified - Awaiting AE", # must exist in your picklist
"Rating": "Hot", # standard Lead field
"Estimated_Contract_Value__c": qual["estimated_contract_value"], # custom field
})
sf.Task.create({
"WhoId": lead_id,
"Subject": f"AI handoff: follow up now ({payload['lead_profile']['company_name']})",
"Priority": "High",
"Status": "Not Started",
"Description": f"Qualification score: {qual['bant_score']}\n\nSummary:\n{summary}",
})
logger.info("Synced handoff for %s to Salesforce.", lead_id)
```
For production, prefer OAuth (for example, a connected app with the JWT bearer flow) over storing a username, password and security token. You will also need to assign the task to the right owner via `OwnerId`, which comes from your routing rules.
HubSpot. HubSpot’s CRM API exposes tasks and deals as objects you can create and associate with contacts and companies, so the same pattern applies: update the contact or deal stage, then create a task assigned to the owning rep.
Route 3: Voice Handoffs with Twilio
For phone calls, Twilio’s `<Dial>` verb supports a call whisper (also called call screening). Your service provides a URL on the `<Number>` noun. Twilio fetches it after the AE answers, and the AE hears a message while the caller continues to hear ringing. Per Twilio’s documentation, this requires `answerOnBridge` to be set to `true`. The whisper TwiML can contain `<Say>`, `<Play>`, `<Gather>` and `<Hangup>`, so the AE can accept or reject by pressing a key. It cannot contain another `<Dial>`.
The Node.js example below makes three practical choices:
- It looks up the AI summary by session ID instead of putting it in a URL query string, which avoids length limits and keeps customer data out of URLs and logs.
- It uses absolute URLs, which Twilio needs for webhooks.
- It accepts form-encoded bodies, the format Twilio uses for its webhook requests.
```
const express = require('express');
const VoiceResponse = require('twilio').twiml.VoiceResponse;
const app = express();
app.use(express.urlencoded({ extended: false })); // Twilio webhooks
app.use(express.json());
const BASE_URL = process.env.PUBLIC_BASE_URL; // e.g. https://routing.example.com
const handoffs = new Map(); // replace with Redis in production
// Called by your Python engine to register a handoff before redirecting the live call
app.post('/api/handoffs', (req, res) => {
const { sessionId, aePhone, aiSummary } = req.body;
handoffs.set(sessionId, { aePhone, aiSummary });
res.sendStatus(204);
});
// TwiML for the live call. Redirect the call here with Twilio's REST API
// (calls(callSid).update({ url: ... })).
app.post('/api/voice-handoff', (req, res) => {
const handoff = handoffs.get(req.query.session);
const twiml = new VoiceResponse();
if (!handoff) {
twiml.say('Sorry, we could not reach an account executive. We will call you back shortly.');
twiml.hangup();
} else {
twiml.say('Thanks for those details. Connecting you with an account executive now.');
const dial = twiml.dial({
callerId: process.env.TWILIO_COMPANY_NUMBER,
answerOnBridge: true, // required for whisper
action: `${BASE_URL}/api/voice-handoff-complete`,
});
dial.number({ url: `${BASE_URL}/api/ae-whisper?session=${encodeURIComponent(req.query.session)}` }, handoff.aePhone);
}
res.type('text/xml').send(twiml.toString());
});
// Whisper: heard only by the AE after answering, before the call is bridged
app.post('/api/ae-whisper', (req, res) => {
const handoff = handoffs.get(req.query.session);
const twiml = new VoiceResponse();
twiml.say(`Incoming AI handoff. ${handoff ? handoff.aiSummary : 'Qualified lead.'}`);
res.type('text/xml').send(twiml.toString());
});
app.listen(3000, () => console.log('Voice routing bridge on port 3000'));
```
You should also validate Twilio’s request signature on every webhook, handle the case where the AE does not answer (the `action` URL receives the dial result), and consider a fallback queue.
State Management and Preventing Race Conditions
When a conversation moves from AI to human, two things can go wrong: two reps claim the same deal, or the AI keeps answering after a human has taken over. Treat session ownership as explicit state, and make the claim operation atomic.
```
[ Prospect sends message ]
|
v
+-----------------------------+
| Look up session ownership |
+-----------------------------+
|
+-----------------+-----------------+
| | |
AI_ACTIVE HUMAN_PENDING HUMAN_OWNED
| | |
v v v
Run the agent loop Queue message, Forward to the
alert the rep owning AE's console
```
Atomic Claims with Redis
Redis’s `SET` command with the `NX` option sets a key only if it does not already exist, which makes it a simple building block for “first claim wins.” The plain `SET` calls in many tutorials are not safe for this purpose, because two reps can overwrite each other.
```
import json
import redis
r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
PENDING_TTL_SECONDS = 120 # example claim window
def mark_session_pending(session_id: str) -> None:
r.set(f"session:{session_id}:pending", "1", ex=PENDING_TTL_SECONDS)
def claim_session(session_id: str, ae_id: str) -> bool:
"""Atomically claim a session. Returns True only for the first caller."""
claimed = r.set(f"session:{session_id}:owner", ae_id, nx=True)
if claimed:
r.delete(f"session:{session_id}:pending")
return bool(claimed)
def handle_incoming_prospect_message(session_id: str, text: str, context: dict):
owner = r.get(f"session:{session_id}:owner")
if owner: # HUMAN_OWNED
r.publish(f"channel:ae:{owner}", json.dumps({"session_id": session_id, "text": text}))
return {"action": "FORWARDED_TO_HUMAN"}
if r.exists(f"session:{session_id}:pending"): # HUMAN_PENDING
r.rpush(f"queue:session:{session_id}", text)
return {"action": "QUEUED_FOR_HUMAN"}
AI_ACTIVE: load stored history, append the new message, run the agents
return run_sales_pipeline([{"role": "user", "content": text}], context)
```
Design notes:
- Ownership lives in the `owner` key, so the claim itself is the lock. For multi-step transitions that must be all-or-nothing, use a Lua script or a transaction.
- The `pending` key has a TTL, so an unclaimed lead expires into your fallback logic instead of waiting indefinitely. Make the expiry trigger an action (for example, via a scheduler or keyspace notifications). A silent expiry only removes the state.
- Persist ownership changes to a durable store as well, because Redis alone may not meet your audit needs.
Production Readiness Checklist
1. Latency and Timeouts
The following are example targets to tune against your own data, not industry standards:
- Alert latency: aim for the Slack or Teams alert to go out within a second or two of the handoff event.
- Claim timeout: if no AE claims within a window you choose (for example, 120 seconds), re-route to a fallback queue or have the AI offer to schedule a call.
Whatever targets you pick, measure them. The speed-to-lead research cited above is about how quickly a human reaches the lead, so track time from handoff trigger to the AE’s first contact, not only the alert.
2. Observability and Tracing
- The Agents SDK includes built-in tracing for agent runs (handoffs, tool calls, guardrails) and supports custom trace processors. Swarm has no built-in observability, so add your own.
- Common additions are OpenTelemetry, LangSmith and Weights & Biases Weave.
- Record spans or events for `handoff_trigger_time`, `routing_latency` and `human_claim_time`, and log every agent-to-agent transfer.
3. Guardrails and Safety
- The Agents SDK supports input and output guardrails. Use them to catch PII leakage and policy violations before they reach the prospect or your CRM.
- Add a handoff hop counter so agents cannot bounce a conversation between themselves indefinitely.
- Tell prospects when they are talking to an AI and when a human joins. Depending on your jurisdiction, rules on disclosure, call recording and automated calling may apply (for example, consent requirements for recording and restrictions on automated outbound calls), so consult counsel.
- Treat the prospect’s messages as untrusted input. A prospect could try to prompt-inject the agent into escalating, discounting or leaking internal notes, so keep decisions about pricing and contract terms in deterministic code or with the human.
4. Graceful Failure Recovery
- Summarization failure: if the brief cannot be generated or fails schema validation, send the last several raw turns so the AE still has context.
- Prospect disconnects while pending: notify the rep, and write the AI transcript and brief to the CRM record so the follow-up has context.
- Dependency outages: because each consumer reads from the event bus independently, a CRM or Slack outage should only delay that channel, not drop the handoff. Add retries with backoff and a dead-letter queue.
Conclusion
Whether you start from Swarm’s educational patterns or the Agents SDK’s production features, the human handoff is a routing problem as much as an AI problem. Four practices cover most of it:
1. Treat the handoff as an explicit event. Halt the agent loop with a dedicated escalation tool, using `StopAtTools` in the Agents SDK or a controlled exception in Swarm.
2. Synthesize before routing. Give reps a structured brief that separates stated facts from model inferences, instead of a raw transcript.
3. Lock ownership atomically. Use explicit session states and an atomic claim so only one rep owns a conversation and the AI stops replying.
4. Define and measure fallbacks. Set a claim timeout and an alternate route, and track time-to-human end to end.
Build these behaviors with explicit tests, such as simulated double-claims, dropped connections and summarizer failures, before putting real leads through the pipeline.
Sources and Further Reading
- OpenAI Swarm repository (README notes it is replaced by the Agents SDK)
- OpenAI Agents SDK (Python) documentation
- Agents SDK (JavaScript/TypeScript): Handoffs
- Agents SDK (Python): Human-in-the-loop
- Twilio: What is Call Whisper?
- Microsoft Message Center MC1181996: Office 365 connectors retirement in Teams
- Oldroyd, McElheran & Elkington, “The Short Life of Online Sales Leads,” *Harvard Business Review*, March 2011
