Skip to content

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 ​

  1. Agent (Python) - backend/Agent/

    • src/core/tools.py - Transfer tool implementation
    • src/services/transfer_notify.py - Pre-transfer summary / alert / email helpers
    • agent.py - Disconnect handler and session management
    • src/utils/agent_utils.py - Utility functions
  2. Client Backend (Node.js) - backend/client_backend/

    • src/controllers/telephony.controller.ts - SIP trunk resolution for outbound dialing
    • src/services/transferAlert.service.ts - In-memory SSE hub for pushing alerts to browsers
    • src/controllers/transferAlert.controller.ts - Internal ingest + per-user SSE endpoints
    • src/routes/transferAlert.routes.ts - POST /internal/transfer-alert, GET /transfer-alerts
  3. 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 → toast
    • src/components/features/WebTestCall.tsx - Shows the alert during a web test call
  4. Database

    • Agent collection: tools array (contains "transfer_to_human"), transferToHumanNumber, transferToHumanEmail, and transferSummaryEnabled fields

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)
transferToHumanNumbertransfer_to_human_number
transferToHumanEmailtransfer_to_human_email
transferSummaryEnabledtransfer_summary_enabled
transferStartTimetransfer_start_time
transferEndTimetransfer_end_time

Mapping happens in:

  • backend/Agent/src/services/agent_settings.py - Fast path, DB update, and local cache
  • backend/Agent/src/handlers/inbound_call_handler.py - Inbound call resolution
  • backend/Agent/src/handlers/call_handler.py - Outbound call handling

Note: because transferSummaryEnabled is a boolean, mapping uses an in-check (key presence) rather than a truthiness check, so an explicit False is 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 ​

FlagTypeDescription
_transfer_dialingboolTrue while dialing supervisor (hasn't answered yet)
_transfer_in_progressboolTrue after supervisor joins (caller & supervisor talking)
_supervisor_identitystrSupervisor's LiveKit participant identity (e.g., human_agent_919876543210)
_transfer_attemptsintHow 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 ​

ScenarioBehavior
User requests transferTool returns immediately with "transferring..." message, dials in background
Agent can still talk during dialYes, the dial runs in background via asyncio.create_task()
Supervisor answersAgent 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 callSame as timeout - agent continues conversation
Caller hangs up during dialAgent shuts down, background dial task cleans up
Summary/alert/email enabledRuns 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 disabledSkipped entirely; call transfers directly
Caller hangs up after transferSupervisor is kicked from room, room is deleted
Supervisor hangs up after transferCaller is kicked from room, room is deleted
Both hang up simultaneouslyFirst disconnect triggers cleanup, second is ignored
Caller requests transfer a 3rd time in one callBlocked 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 joinedNo-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.

  1. 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.
  2. 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.
  3. Email if transferToHumanEmail is 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 by customerUID. Team members share their account owner's scope (the req.userId that verifyJwt resolves).
  • The browser opens the stream once at app root via TransferAlertListener. EventSource cannot set an Authorization header, so the JWT is passed as ?token= (supported by verifyJwt).
  • 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..." message

Block-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 ​

PieceLocation
Atomic check + reserveAgent/src/core/tools.py → try_reserve_transfer(job_ctx, max_attempts)
Reservation rollbackAgent/src/core/tools.py → release_transfer_reservation(job_ctx)
Default capAgent/src/core/tools.py → DEFAULT_MAX_TRANSFER_ATTEMPTS = 2
Enforcement wiringAgent/src/core/tools.py → create_transfer_to_human_tool()._handler
Unit testsAgent/tests/test_transfer_spam_guard.py

This limit applies only to the conversational transfer_to_human tool. 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:

python
# 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:

  1. PhoneNumberAssignment.phoneUID → PhoneNumber.trunkUID
  2. PhoneNumber.trunkUID → SipTrunk(type="outbound").livekitTrunkId
  3. 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:

python
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:

python
@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_* legDefault (naive) behaviorWhat the handler does instead
participant_joinedRebuilds s3RecordingKey (new timestamp) and resets participantJoinedAt / callStartedAtSkips entirely. The original caller's recording key + call-start clock are preserved, so egress_ended can still resolve the file.
participant_leftStops egress and finalizes the Contact (status: done, duration) on the human agent's hangupSkips 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_joined fixes 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 — but egress_ended was looking up the wrong (overwritten) key and never set recordingUrl. See RECORDING-FLOW.md Step 1.

Configuration ​

Agent Document Fields ​

FieldTypeDescription
toolsstring[]Array containing "transfer_to_human" when enabled
transferToHumanNumberstringE.164 phone number (e.g., +919876543210)
transferSummaryEnabledbooleanWhen true, summarize + inform the supervisor before transfer. Default false.
transferToHumanEmailstring | nullOptional supervisor email for the pre-transfer summary email. Only used when transferSummaryEnabled is true.
transferStartTimestringTransfer window start (IST), "HH:mm". Default "10:00".
transferEndTimestringTransfer window end (IST), "HH:mm". Default "19:00".

Environment Variables ​

The transfer uses the same SIP trunk as outbound calls:

  • SIP_OUTBOUND_Trunk_ID or SIP_Outbound_Trunk_ID or SIP_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:

python
"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 ​

FileChanges
agent.pyDisconnect handler for transfer states, inactivity guards
src/core/tools.pycreate_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.pyNew summary builder, room-alert publish, backend SSE push, supervisor email
src/utils/agent_utils.pyUpdated get_inbound_contact() to exclude egress/transfer participants
src/services/agent_settings.pyField mapping for transferToHumanNumber, transferToHumanEmail, transferSummaryEnabled
src/handlers/inbound_call_handler.pyField mapping, SIP trunk storage
src/handlers/call_handler.pyField mapping (incl. email + summary toggle)
src/schemas/agent_meta_schema.pyAdded transfer_to_human_number, transfer_to_human_email, transfer_summary_enabled
src/core/voice_assistant.pyTool registration passes email + summary toggle

Node.js Backend ​

FileChanges
controllers/telephony.controller.tsOutbound SIP trunk fallback resolution
controllers/webhook.controller.tsTransfer-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.tsAdded transferToHumanNumber, transferToHumanEmail, transferSummaryEnabled fields
types/index.tsAdded transfer email + summary-toggle fields to IAgent
utils/dispatch.utils.tsSends transfer_to_human_email + transfer_summary_enabled in dispatch metadata
services/transferAlert.service.tsNew in-memory SSE hub (registerClient, publishTransferAlert)
controllers/transferAlert.controller.tsNew internal ingest + per-user SSE endpoints
routes/transferAlert.routes.tsNew POST /internal/transfer-alert, GET /transfer-alerts
app.tsMounts the transfer-alert routes

Frontend ​

FileChanges
pages/customer/AgentEditorPage.tsxSupervisor email input + "Summarize & inform before transfer" toggle
types/campaign.ts, lib/agentFormUtils.tsNew form fields + defaults
services/transferAlert.service.tsNew builds the SSE URL with ?token=
components/shared/TransferAlertListener.tsxNew app-wide SSE listener → 30s dismissible toast
components/features/WebTestCall.tsxListens on the transfer_alert room data channel → toast
App.tsxMounts TransferAlertListener

Services ​

FileChanges
post_call.pyFixed contactUID resolution for transfers

Testing ​

Manual Test Cases ​

  1. 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
  2. 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
  3. Supervisor Rejects

    • Call the agent, request transfer
    • Reject the incoming call on supervisor phone
    • Verify: Agent says "couldn't complete transfer" and continues
  4. 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)
  5. Summary + Notification enabled (no email)

    • Enable the toggle but leave supervisor email blank
    • Verify: toast appears, no email is sent, transfer still completes
  6. Summary + Notification disabled (default)

    • Leave the toggle off
    • Verify: no summary/toast/email; the call transfers directly as before
  7. 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
  8. 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 fileCovers
backend/Agent/tests/test_transfer_notify.pyTranscript flattening, context extraction, summary fallback, network-guard short-circuits
backend/Agent/tests/test_transfer_spam_guard.pyAttempt-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.pyDefaults + custom values for transfer_to_human_email / transfer_summary_enabled
backend/client_backend/src/services/__tests__/transferAlert.service.test.tsSSE hub registration, fan-out, customer scoping, cleanup, broken-pipe handling
backend/client_backend/src/controllers/__tests__/webhook.transfer-recording.test.tsWebhook transfer-awareness: human_agent_ participant_joined/participant_left are skipped, s3RecordingKey is never regenerated on a later join, and egress is not stopped early
  1. Caller Hangs Up During Dial

    • Call the agent, request transfer
    • Hang up before supervisor answers
    • Verify: Agent shuts down cleanly
  2. Recording Continuity

    • Complete a successful transfer
    • Verify: Single recording contains AI conversation + human conversation

Troubleshooting ​

Transfer Not Working ​

  1. Check if transfer_to_human is in the agent's tools array
  2. Check if transferToHumanNumber has a valid E.164 number
  3. Check if outbound SIP trunk is configured
  4. Check logs for Transfer to human initiated message

Supervisor Can't Hear Caller ​

  1. Verify supervisor joined the same room (check logs for room name)
  2. Verify caller is still in the room when supervisor joins
  3. Check for any audio track issues in LiveKit dashboard

Agent Still Talking After Transfer ​

  1. Verify _transfer_in_progress flag is set
  2. Check if _disable_ai_pipeline() completed successfully
  3. Check for errors in session audio disable logs

Room Not Deleted After Hangup ​

  1. Verify disconnect handler is triggered (check logs)
  2. Verify cleanup_transfer() async task is created
  3. Check for errors in room deletion API call

Last updated: