Transfer to Human Feature
Overview
The Transfer to Human feature allows an AI voice agent to transfer an ongoing call to a human agent (supervisor) when the caller explicitly requests to speak with a real person. This is a warm transfer - the human agent joins the same room, and the complete conversation (including the AI interaction and the human conversation) is captured in a single recording.
Optionally, the agent can summarize the conversation and inform the supervisor before transferring (see Pre-Transfer Summary & Notification). This is off by default and, when enabled, applies only to AI conversation transfers (outbound/inbound). IVR/DTMF transfers are unaffected.
To stop a caller from repeatedly ringing a busy human agent, the tool caps the number of dials per call (see Transfer Attempt Limit (Anti-Spam)).
Architecture
Components Involved
Agent (Python) -
backend/Agent/src/core/tools.py- Transfer tool implementationsrc/services/transfer_notify.py- Pre-transfer summary / alert / email helpersagent.py- Disconnect handler and session managementsrc/utils/agent_utils.py- Utility functions
Client Backend (Node.js) -
backend/client_backend/src/controllers/telephony.controller.ts- SIP trunk resolution for outbound dialingsrc/services/transferAlert.service.ts- In-memory SSE hub for pushing alerts to browserssrc/controllers/transferAlert.controller.ts- Internal ingest + per-user SSE endpointssrc/routes/transferAlert.routes.ts-POST /internal/transfer-alert,GET /transfer-alerts
Frontend -
app/- Agent settings form with "Transfer to Human" toggle, phone number, "Summarize & inform before transfer" toggle, and supervisor email
src/components/shared/TransferAlertListener.tsx- App-wide SSE listener → toastsrc/components/features/WebTestCall.tsx- Shows the alert during a web test call
Database
Agentcollection:toolsarray (contains"transfer_to_human"),transferToHumanNumber,transferToHumanEmail, andtransferSummaryEnabledfields
Data Flow
Configuration
Agent Document:
{
"agent_id": "a7wEGwOXeKNB",
"tools": ["transfer_to_human"], // Toggle enables this
"transferToHumanNumber": "+919876543210", // Phone number to dial
"transferSummaryEnabled": true, // Optional: summarize + inform before transfer
"transferToHumanEmail": "[email protected]" // Optional: supervisor email (only used when summary is on)
}Field Mapping (camelCase → snake_case)
The transfer fields are mapped to their snake_case equivalents in the Python agent config:
| DB field (camelCase) | Agent field (snake_case) |
|---|---|
transferToHumanNumber | transfer_to_human_number |
transferToHumanEmail | transfer_to_human_email |
transferSummaryEnabled | transfer_summary_enabled |
transferStartTime | transfer_start_time |
transferEndTime | transfer_end_time |
Mapping happens in:
backend/Agent/src/services/agent_settings.py- Fast path, DB update, and local cachebackend/Agent/src/handlers/inbound_call_handler.py- Inbound call resolutionbackend/Agent/src/handlers/call_handler.py- Outbound call handling
Note: because
transferSummaryEnabledis a boolean, mapping uses anin-check (key presence) rather than a truthiness check, so an explicitFalseis preserved instead of being dropped.
Transfer Flow
Sequence Diagram
Caller AI Agent Supervisor LiveKit Room
| | | |
|--- "I want to talk to human" -->| |
| | | |
|<-- "I'm transferring you..." ---| |
| | | |
| |-- (bg, if enabled) summary → alert → email
| |------- Dial (background) ------->|
| | | |
|<-- Agent continues talking ----->| |
| | | |
| |<----- Supervisor answers --------|
| | | |
|<-- "Human agent joined, goodbye!"| |
| | | |
| |--- Disable AI pipeline ---------->|
| | | |
|<============== Caller & Supervisor talk ==========>|
| | | |
|--- Hang up --->| | |
| |------- Kick supervisor ---------->|
| |------- Delete room -------------->|State Flags
| Flag | Type | Description |
|---|---|---|
_transfer_dialing | bool | True while dialing supervisor (hasn't answered yet) |
_transfer_in_progress | bool | True after supervisor joins (caller & supervisor talking) |
_supervisor_identity | str | Supervisor's LiveKit participant identity (e.g., human_agent_919876543210) |
_transfer_attempts | int | How many dials have been reserved this call. Used by the attempt limit. Bumped atomically at reservation time (before validation); rolled back if validation fails; never reset on a failed/declined dial. |
Edge Cases Handled
| Scenario | Behavior |
|---|---|
| User requests transfer | Tool returns immediately with "transferring..." message, dials in background |
| Agent can still talk during dial | Yes, the dial runs in background via asyncio.create_task() |
| Supervisor answers | Agent says goodbye, disables STT/LLM/TTS pipeline, stays as silent observer |
| Supervisor doesn't answer (60s timeout) | Agent tells user "human agent didn't answer", continues conversation |
| Supervisor rejects call | Same as timeout - agent continues conversation |
| Caller hangs up during dial | Agent shuts down, background dial task cleans up |
| Summary/alert/email enabled | Runs in background concurrently with the dial never delays the "transferring..." message or the dial |
| A notification step fails (LLM/email/SSE) | Logged and skipped; the transfer still proceeds |
| Summary/alert/email disabled | Skipped entirely; call transfers directly |
| Caller hangs up after transfer | Supervisor is kicked from room, room is deleted |
| Supervisor hangs up after transfer | Caller is kicked from room, room is deleted |
| Both hang up simultaneously | First disconnect triggers cleanup, second is ignored |
| Caller requests transfer a 3rd time in one call | Blocked by the attempt limit (max 2 dials/call); agent offers to take a message |
| LLM fires several transfer calls in one turn (concurrent) | Atomic reservation lets only the first succeed; the rest are rejected ("already trying to connect you") so the supervisor's phone rings once, not N times |
| Transfer requested after supervisor already joined | No-op; tool reports a human is already connected |
| Validation fails (no SIP participant / no trunk) | Reservation is rolled back via release_transfer_reservation, so a transient setup issue does not consume one of the 2 attempts |
Pre-Transfer Summary & Notification
When transferSummaryEnabled is on for the agent, then after the business-hours check passes and before the caller and supervisor start talking — the agent runs a three-step notification sequence. When it is off, the call transfers directly with no summary, alert, or email (original behaviour).
The three steps are independent and best-effort: a failure in any one step is logged and never blocks the transfer.
- Summary an LLM summary of the conversation so far is generated from the live session history (
session.history), enriched with caller / agent / campaign / account / organization context. If the LLM call fails or times out (8s cap), a generic fallback summary is used. - Alert the summary is published on two channels so it can surface in the UI as a 30-second, dismissible toast:
- the LiveKit room data channel (topic
transfer_alert) reaches a web test call UI that is in the room; - the client_backend SSE hub (
POST /internal/transfer-alert) reaches the logged-in customer's browser on any page. This is the path that works for real phone (SIP) calls, where there is no in-room browser.
- the LiveKit room data channel (topic
- Email if
transferToHumanEmailis set, a summary email (caller/agent/ campaign context + conversation summary) is sent to the supervisor via Resend.
Concurrency (important)
The notification sequence runs in a background task, concurrently with the dial it does not block the tool from returning. This is deliberate: generating the summary and sending the email take a few seconds, and if they ran before the tool returned, the agent would stay silent and the caller might hang up before hearing "I'm transferring you...". The tool therefore returns immediately (agent speaks the hold message), while the summary/alert/email and the supervisor dial proceed in parallel.
Caller AI Agent Supervisor / UI
| | |
|-- "connect me to a supervisor" -------------> |
| |-- tool returns immediately --> |
|<-- "I'm transferring you, please hold" ------ |
| | |
| |== background: summary → alert (room + SSE) → email
| |== background: dial supervisor into the room
| | |
|<============ caller & supervisor connected ==============>|Alert delivery (SSE hub)
- The hub (
transferAlert.service.ts) is in-memory, keyed bycustomerUID. Team members share their account owner's scope (thereq.userIdthatverifyJwtresolves). - The browser opens the stream once at app root via
TransferAlertListener.EventSourcecannot set anAuthorizationheader, so the JWT is passed as?token=(supported byverifyJwt). - Scaling caveat: because the hub is in-memory (single process), an alert only reaches browsers connected to the same backend instance that received the agent's POST. For a multi-instance deployment, replace the hub's internal map with a Redis pub/sub fan-out the exported functions (
registerClient,publishTransferAlert) are the only integration surface.
IVR / DTMF transfers are excluded
DTMF-based transfers (ivr_flow_handler._handle_transfer, ivr_tools) have no agent↔user conversation to summarise and do not use transfer_to_human, so they are intentionally left untouched by this feature.
Transfer Attempt Limit (Anti-Spam)
The transfer_to_human tool caps how many times it will dial the human agent within a single call. This stops a caller from repeatedly ringing a supervisor who is busy or not answering.
Behavior
- Max 2 dials per call. On the third request the tool refuses and the agent tells the caller it can take a message instead.
- A failed/declined dial still counts. If the supervisor doesn't answer, the caller may ask again and a second dial is placed but never a third.
- A dial already in progress is never duplicated. If the LLM fires the tool twice in quick succession, the second call is rejected while the first dial is still ringing, so the supervisor's phone rings once, not twice.
- Once connected, further requests are no-ops. After the supervisor joins, the tool reports that a human is already on the line.
- Validation-only failures don't consume an attempt. If there's no active SIP participant or no SIP trunk configured, the tool returns early before counting, so a transient setup issue doesn't burn the caller's allowance.
- Off-hours requests don't consume an attempt. The business-hours check runs before the guard and returns early, so probing during off-hours doesn't eat into the allowance once business hours begin.
Scope
The limit is per call / per contact for the duration of that call. State is held on the LiveKit JobContext, which is a single object for the life of the room (one room = one caller = one contact in this architecture). When the call ends the JobContext is destroyed, so a brand-new call starts fresh at zero.
The limit does not persist across separate calls. A caller who hangs up and dials back in gets a fresh allowance. Cross-call redial throttling is not implemented; it would require persisting a per-contact counter (keyed by
customer_id) in the database with a time window.
How it works
1st request → try_reserve_transfer → reserve (0 < 2) → _transfer_attempts = 1, _transfer_dialing = True → dial
supervisor busy; dial task clears _transfer_dialing (attempts stays 1)
2nd request → try_reserve_transfer → reserve (1 < 2) → _transfer_attempts = 2 → dial
3rd request → try_reserve_transfer → BLOCKED (2 >= 2) → "...tried twice..." messageBlock-reason precedence is: already-connected → dial-in-progress → attempt cap reached.
Concurrency: why check-and-reserve is atomic
This is the crux of why a naive counter fails. LiveKit executes tool calls concurrently when a caller insists ("connect me to a human, I want an agent, get me a person"), the LLM often emits several transfer_to_human calls in a single turn, and the runtime runs them as sibling asyncio tasks (asyncio.gather in livekit/agents/voice/generation.py _execute_tools_task).
A check-only guard that reads the counter and increments it later (after an await or after SIP validation) is defeated by this: every concurrent call observes _transfer_attempts == 0 / not-dialing, all pass, and all dial producing 6+ simultaneous dials despite a cap of 2.
The fix is try_reserve_transfer(job_ctx, max_attempts), which checks and reserves in one synchronous block with no await. Because asyncio is single-threaded and cooperative, that block cannot be interleaved: the first task to enter wins the slot (sets _transfer_dialing = True and bumps _transfer_attempts); every other concurrent task then sees the flag (or the cap) and is rejected. Validation (SIP participant, trunk) happens after the reservation; if it fails, release_transfer_reservation(job_ctx) rolls the reservation back so a transient setup issue doesn't consume an attempt.
Because the attempt counter is bumped at reservation time and never reset on a failed/declined dial (only _transfer_dialing is cleared), the cap also survives the failure-reopen path: after a failed dial the caller can retry, but only up to the total of 2.
Implementation
| Piece | Location |
|---|---|
| Atomic check + reserve | Agent/src/core/tools.py → try_reserve_transfer(job_ctx, max_attempts) |
| Reservation rollback | Agent/src/core/tools.py → release_transfer_reservation(job_ctx) |
| Default cap | Agent/src/core/tools.py → DEFAULT_MAX_TRANSFER_ATTEMPTS = 2 |
| Enforcement wiring | Agent/src/core/tools.py → create_transfer_to_human_tool()._handler |
| Unit tests | Agent/tests/test_transfer_spam_guard.py |
This limit applies only to the conversational
transfer_to_humantool. IVR / DTMF transfers (ivr_tools.py,ivr_menu_engine.py) are a separate path and are unaffected.
Implementation Details
Tool Registration
The transfer tool is registered in voice_assistant.py:
# Check for transfer_to_human in tools array
if "transfer_to_human" in agent_tools:
transfer_number = agent_meta.transfer_to_human_number
if transfer_number:
# Get SIP trunk ID for outbound dialing
sip_trunk_id = getattr(meta, "sip_trunk_id", "") or ""
# Create the tool with configured number
transfer_tool = create_transfer_to_human_tool(
transfer_number=transfer_number,
room_name=ctx.room.name if ctx.room else "",
sip_trunk_id=sip_trunk_id
)
tools_list.append(transfer_tool)SIP Trunk Resolution
For inbound calls, the outbound SIP trunk is resolved via:
PhoneNumberAssignment.phoneUID→PhoneNumber.trunkUIDPhoneNumber.trunkUID→SipTrunk(type="outbound").livekitTrunkId- Fallback: Find ANY outbound trunk for the same organization
AI Pipeline Disable
After the supervisor joins and agent says goodbye, the AI pipeline is completely disabled:
async def _disable_ai_pipeline(job_ctx, room_name):
# 1. Disable audio input (stops STT → no LLM → no TTS)
session.input.set_audio_enabled(False)
# 2. Interrupt any pending speech
session.current_speech.interrupt(force=True)
# 3. Unpublish agent's audio tracks
for pub in local.track_publications.values():
if pub.track.kind == rtc.TrackKind.KIND_AUDIO:
await local.unpublish_track(pub.sid)This ensures:
- No STT/LLM/TTS token consumption during the human conversation
- Agent is completely silent (no audio output)
- Recording continues (agent stays in room)
Disconnect Handler
The participant_disconnected handler in agent.py manages all disconnect scenarios:
@ctx.room.on("participant_disconnected")
def _on_participant_disconnected(participant):
# CASE 1: Transfer complete - kick other party, delete room
if ctx._transfer_in_progress:
cleanup_transfer()
return
# CASE 2: Transfer dialing
if ctx._transfer_dialing:
if participant.identity.startswith("human_agent_"):
# Supervisor rejected - continue conversation
return
else:
# Caller hung up - shut down
ctx.shutdown()
return
# CASE 3: Normal call - existing behavior
...Backend Webhook: transfer-aware participant handling
Because the warm transfer dials the human agent into the same LiveKit room, the client_backend receives extra participant_joined / participant_left webhooks for the human_agent_<number> leg mid-call. The outbound-call branch of livekitWebhook (controllers/webhook.controller.ts) treats that leg specially so it never corrupts the call record or the recording:
Event for human_agent_* leg | Default (naive) behavior | What the handler does instead |
|---|---|---|
participant_joined | Rebuilds s3RecordingKey (new timestamp) and resets participantJoinedAt / callStartedAt | Skips entirely. The original caller's recording key + call-start clock are preserved, so egress_ended can still resolve the file. |
participant_left | Stops egress and finalizes the Contact (status: done, duration) on the human agent's hangup | Skips entirely. Only the original caller leg leaving (or room_finished) finalizes the call, so egress keeps capturing until the real end. |
Both guards key off participant.identity.startsWith("human_agent_"). This mirrors the agent-side convention (_supervisor_identity = human_agent_<number>) and the agent's own get_inbound_contact() which already excludes transfer participants.
Why skipping
participant_joinedfixes the "no recording after transfer" bug:buildRecordingS3Key()embeds a timestamp, so regenerating it on the human-agent join produced a second key that pointed at no S3 object. Egress was still writing to the first key, so the file existed — butegress_endedwas looking up the wrong (overwritten) key and never setrecordingUrl. See RECORDING-FLOW.md Step 1.
Configuration
Agent Document Fields
| Field | Type | Description |
|---|---|---|
tools | string[] | Array containing "transfer_to_human" when enabled |
transferToHumanNumber | string | E.164 phone number (e.g., +919876543210) |
transferSummaryEnabled | boolean | When true, summarize + inform the supervisor before transfer. Default false. |
transferToHumanEmail | string | null | Optional supervisor email for the pre-transfer summary email. Only used when transferSummaryEnabled is true. |
transferStartTime | string | Transfer window start (IST), "HH:mm". Default "10:00". |
transferEndTime | string | Transfer window end (IST), "HH:mm". Default "19:00". |
Environment Variables
The transfer uses the same SIP trunk as outbound calls:
SIP_OUTBOUND_Trunk_IDorSIP_Outbound_Trunk_IDorSIP_TRUNK_ID
For inbound calls, the trunk is resolved dynamically via the telephony controller.
The pre-transfer summary email uses Resend:
RESEND_API_KEY- required for the supervisor email step (the summary/alert steps still run if this is missing; only the email is skipped).BACKEND_URL,WEBHOOK_SECRET- used by the agent to push the alert to the client_backend SSE hub (POST /internal/transfer-alert).
Tool Description
The tool description is designed to prevent false triggers:
"description": (
"Transfer the call to a human agent. ONLY use this tool when the caller EXPLICITLY "
"requests to speak to a human, real person, agent, supervisor, or manager. "
"Examples of when to use: 'I want to talk to a human', 'Connect me to a real person', "
"'Transfer me to an agent', 'I need to speak with someone'. "
"Do NOT use this tool for normal conversation endings like 'thank you', 'bye', 'no thanks', etc."
)Files Modified
Python Agent
| File | Changes |
|---|---|
agent.py | Disconnect handler for transfer states, inactivity guards |
src/core/tools.py | create_transfer_to_human_tool() factory, background notification launch, _run_transfer_notifications() + _disable_ai_pipeline() helpers, try_reserve_transfer() / release_transfer_reservation() atomic attempt-limit guard + DEFAULT_MAX_TRANSFER_ATTEMPTS |
src/services/transfer_notify.py | New summary builder, room-alert publish, backend SSE push, supervisor email |
src/utils/agent_utils.py | Updated get_inbound_contact() to exclude egress/transfer participants |
src/services/agent_settings.py | Field mapping for transferToHumanNumber, transferToHumanEmail, transferSummaryEnabled |
src/handlers/inbound_call_handler.py | Field mapping, SIP trunk storage |
src/handlers/call_handler.py | Field mapping (incl. email + summary toggle) |
src/schemas/agent_meta_schema.py | Added transfer_to_human_number, transfer_to_human_email, transfer_summary_enabled |
src/core/voice_assistant.py | Tool registration passes email + summary toggle |
Node.js Backend
| File | Changes |
|---|---|
controllers/telephony.controller.ts | Outbound SIP trunk fallback resolution |
controllers/webhook.controller.ts | Transfer-aware participant_joined / participant_left: skip the human_agent_ leg so the recording key + call-start clock survive and egress isn't stopped early (fixes missing recording after transfer) |
models/Agent.ts | Added transferToHumanNumber, transferToHumanEmail, transferSummaryEnabled fields |
types/index.ts | Added transfer email + summary-toggle fields to IAgent |
utils/dispatch.utils.ts | Sends transfer_to_human_email + transfer_summary_enabled in dispatch metadata |
services/transferAlert.service.ts | New in-memory SSE hub (registerClient, publishTransferAlert) |
controllers/transferAlert.controller.ts | New internal ingest + per-user SSE endpoints |
routes/transferAlert.routes.ts | New POST /internal/transfer-alert, GET /transfer-alerts |
app.ts | Mounts the transfer-alert routes |
Frontend
| File | Changes |
|---|---|
pages/customer/AgentEditorPage.tsx | Supervisor email input + "Summarize & inform before transfer" toggle |
types/campaign.ts, lib/agentFormUtils.ts | New form fields + defaults |
services/transferAlert.service.ts | New builds the SSE URL with ?token= |
components/shared/TransferAlertListener.tsx | New app-wide SSE listener → 30s dismissible toast |
components/features/WebTestCall.tsx | Listens on the transfer_alert room data channel → toast |
App.tsx | Mounts TransferAlertListener |
Services
| File | Changes |
|---|---|
post_call.py | Fixed contactUID resolution for transfers |
Testing
Manual Test Cases
Basic Transfer
- Call the agent, say "I want to talk to a human"
- Verify: Agent says "transferring...", dials supervisor
- Answer supervisor phone, verify both can talk
- Hang up from either side, verify other is disconnected
Supervisor Doesn't Answer
- Call the agent, request transfer
- Don't answer the supervisor phone
- Verify: After 60s, agent says "didn't answer" and continues
Supervisor Rejects
- Call the agent, request transfer
- Reject the incoming call on supervisor phone
- Verify: Agent says "couldn't complete transfer" and continues
Summary + Notification enabled (with email)
- Enable "Summarize & inform before transfer" and set a supervisor email
- Have a short conversation, then ask for a human
- Verify: agent says "I'm transferring you..." immediately (no silent gap)
- Verify: a 30s dismissible toast with the summary appears in the dashboard
- Verify: the supervisor receives a summary email
- Verify: the supervisor is dialed and joins (summary/email may land a moment before or after the dial both run in the background)
Summary + Notification enabled (no email)
- Enable the toggle but leave supervisor email blank
- Verify: toast appears, no email is sent, transfer still completes
Summary + Notification disabled (default)
- Leave the toggle off
- Verify: no summary/toast/email; the call transfers directly as before
Off-hours with summary enabled
- Set the transfer window to exclude the current time
- Verify: the off-hours message is returned and no summary/alert/email runs
Attempt limit (anti-spam)
- Call the agent, ask for a human; let the supervisor not answer (dial 1)
- Ask again; let the supervisor not answer again (dial 2)
- Ask a third time
- Verify: no third dial is placed; the agent says it already tried twice and offers to take a message
- Hang up and call again verify the new call allows transfers again (counter reset)
Automated Tests
| Test file | Covers |
|---|---|
backend/Agent/tests/test_transfer_notify.py | Transcript flattening, context extraction, summary fallback, network-guard short-circuits |
backend/Agent/tests/test_transfer_spam_guard.py | Attempt-limit guard (try_reserve_transfer / release_transfer_reservation): max-2 cap, atomic reserve, concurrent-burst reserves once, block-reason precedence, reservation rollback, full per-call lifecycle |
backend/Agent/tests/test_agent_meta_schema.py | Defaults + custom values for transfer_to_human_email / transfer_summary_enabled |
backend/client_backend/src/services/__tests__/transferAlert.service.test.ts | SSE hub registration, fan-out, customer scoping, cleanup, broken-pipe handling |
backend/client_backend/src/controllers/__tests__/webhook.transfer-recording.test.ts | Webhook transfer-awareness: human_agent_ participant_joined/participant_left are skipped, s3RecordingKey is never regenerated on a later join, and egress is not stopped early |
Caller Hangs Up During Dial
- Call the agent, request transfer
- Hang up before supervisor answers
- Verify: Agent shuts down cleanly
Recording Continuity
- Complete a successful transfer
- Verify: Single recording contains AI conversation + human conversation
Troubleshooting
Transfer Not Working
- Check if
transfer_to_humanis in the agent'stoolsarray - Check if
transferToHumanNumberhas a valid E.164 number - Check if outbound SIP trunk is configured
- Check logs for
Transfer to human initiatedmessage
Supervisor Can't Hear Caller
- Verify supervisor joined the same room (check logs for room name)
- Verify caller is still in the room when supervisor joins
- Check for any audio track issues in LiveKit dashboard
Agent Still Talking After Transfer
- Verify
_transfer_in_progressflag is set - Check if
_disable_ai_pipeline()completed successfully - Check for errors in session audio disable logs
Room Not Deleted After Hangup
- Verify disconnect handler is triggered (check logs)
- Verify
cleanup_transfer()async task is created - Check for errors in room deletion API call