A cold transfer refers to forwarding a caller to another phone number or SIP endpoint. Performing a cold transfer closes the caller's LiveKit session.
For transfers that include an AI agent to provide context, see the Agent-assisted transfer guide.
How it works
To transfer a caller out of a LiveKit room to another phone number, use the following steps:
- Call the
TransferSIPParticipantAPI. - LiveKit sends a SIP REFER through your trunk, instructing the provider to connect the caller to the new number or SIP endpoint.
- The caller leaves the LiveKit room, ending the session.
When your provider reports that the transfer succeeded, the request returns a TransferSIPParticipantResponse and LiveKit ends its side of the call. The caller stays connected to the destination through your provider, and LiveKit has no visibility into the call after that. A success usually means the destination answered. A voicemail box, an IVR, or a conference that answers the call also counts as a success.
Transferring a SIP participant using SIP REFER
REFER is a SIP method that allows you to move an active session to another endpoint (that is, transfer a call). For LiveKit telephony apps, you can use the TransferSIPParticipant server API to transfer a caller to another phone number or SIP endpoint.
In order to successfully transfer calls, you must configure your provider trunks to allow call transfers.
The call must also be answered before you transfer it. LiveKit answers an inbound call after another participant in the room, such as your agent, publishes an audio track. An AgentSession publishes one when it starts. Transferring a call that hasn't been answered fails with a failed_precondition error and STR_UNSPECIFIED.
Enable call transfers for your Twilio SIP trunk
Enable call transfer and PSTN transfers for your Twilio SIP trunk. To learn more, see Twilio's Call Transfer via SIP REFER documentation.
When you transfer a call, you have the option to set the caller ID to display the phone number of the transferee (the caller) or the transferrer (the phone number associated with your LiveKit trunk). Caller ID is configured on the trunk and can't be set per-transfer through the TransferSIPParticipant API.
The following command enables call transfers and sets the caller ID to display the number of the transferee:
- To list trunks, execute
twilio api trunking v1 trunks list. - To set the caller ID to the transferor, set
transfer-caller-idtofrom-transferor.
twilio api trunking v1 trunks update --sid <twilio-trunk-sid> \--transfer-mode enable-all \--transfer-caller-id from-transferee
- Sign in to the Twilio console .
- Navigate to Elastic SIP Trunking » Manage » Trunks, and select a trunk.
- In the Features » Call Transfer (SIP REFER) section, select Enabled.
- In the Caller ID for Transfer Target field, select an option.
- Select Enable PSTN Transfer.
- Save your changes.
Enable call transfers for your Plivo SIP trunk
Plivo SIP trunks support SIP REFER by default for both inbound and outbound calls. No additional configuration is required. To learn more, see Plivo's SIP REFER documentation.
The transfer_to field in the TransferSIPParticipant request becomes the Refer-To URI in the outgoing SIP REFER request.
For transfers through a Plivo trunk, set transfer_to to a SIP URI with the following format:
- User set to the transfer destination (that is, a valid E.164 phone number).
- Host set to your Plivo SIP trunk domain ending in
.zt.plivo.com.
For example, if the transfer destination is +14155551234, the transfer_to value should be set to the following:
sip:+14155551234@<trunk-id>.zt.plivo.com
Transfers to external SIP domains and private IP addresses are blocked.
When you transfer a call, the caller ID displayed to the transfer target is set automatically. For inbound calls, the transfer target sees the phone number of the transferee (the caller). For outbound calls, the transfer target sees the Plivo number used as the caller ID on the original call. Caller ID can't be set per-transfer through the TransferSIPParticipant API.
Control transfer timeout
When LiveKit starts a transfer with a SIP REFER, the REFER doesn't complete until the destination answers. Because SIP doesn't impose a timeout if the destination keeps ringing, LiveKit uses ringing_timeout to cap how long it waits. This field defaults to 30 seconds. If the destination doesn't answer before the timeout, the request returns an error and the caller stays in the room, letting you decide how to handle the failed transfer.
You can control this timeout by setting the ringing_timeout field on the TransferSIPParticipant request.
Some providers stop sending audio to LiveKit as soon as they accept the REFER. When that happens, the call's media timeout ends the call after 15 seconds by default, before a longer ringing_timeout expires. The request then fails with STR_CALL_ENDED, even if the transfer connects afterward. To use a ringing_timeout longer than the media timeout, set media_timeout higher than ringing_timeout in the media configuration for the call. To learn more, see Media timeout.
Handle transfer failures
When a transfer doesn't complete, the TransferSIPParticipant request returns an error. The error reports why the transfer failed, so your app can decide what to do next, such as retrying or transferring the caller to a different destination.
The error metadata can include sip_transfer_reason, set to one of the following SIPTransferReason values:
STR_REJECTED: The transfer destination, or its provider, rejected the transfer. The error also includes the SIP response code and reason phrase insip_status_codeandsip_status.STR_RINGING_TIMEOUT: The transfer destination didn't answer beforeringing_timeoutexpired.STR_CALL_ENDED: The call ended before the transfer completed. For example, the caller hung up, the room was deleted, or the call's media timeout ended it. To learn more, see Control transfer timeout.STR_SUBSCRIPTION_TERMINATED: The provider stopped reporting the transfer status without a final response.STR_UNSPECIFIED: No specific reason reported. Check the error code and message for details.
If ringing_timeout expires, the request can instead fail with a canceled error (HTTP 408) that has no sip_transfer_reason. In most cases, this error means the destination didn't answer in time.
LiveKit reports the transfer status that your provider sends. If your provider doesn't send status updates during a transfer, LiveKit can't provide them, and the request fails when ringing_timeout expires, or sooner if the call's media timeout ends the call first. Some providers have settings for transfer status updates. For example, Telnyx has a Confirm blind transfer setting on the SIP connection (Connection settings » Advanced).
To learn more about troubleshooting transfer failures, see Call transfer issues.
Usage
Set up the following environment variables:
export LIVEKIT_URL=<your LiveKit server URL>export LIVEKIT_API_KEY=<YOUR_API_KEY>export LIVEKIT_API_SECRET=<YOUR_API_SECRET>
This example reads the LiveKit URL, API key, and secret from environment variables.
import { LiveKitAPI, ServerError, SipCallError } from 'livekit-server-sdk';// ...async function transferParticipant(participant) {console.log('transfer participant initiated');const api = new LiveKitAPI();const transferTo = 'tel:+15105550100';try {await api.sip.transferSipParticipant('open-room', participant.identity, transferTo, {playDialtone: false,});console.log('SIP participant transferred successfully');} catch (error) {if (error instanceof ServerError) {// Why the transfer failed, for example STR_REJECTEDconst transferReason = error.metadata?.sip_transfer_reason;if (transferReason) {console.error('Transfer failure reason: ', transferReason);}if (error instanceof SipCallError) {// Only set when the destination or its provider reports a SIP statusconsole.error('SIP error code: ', error.sipStatusCode);console.error('SIP error message: ', error.sipStatus);} else if (!transferReason) {console.error('Error transferring SIP participant: ', error.code, error.message);}} else {console.error('Error transferring SIP participant: ', error);}}}
import loggingfrom livekit import apilogger = logging.getLogger("transfer-logger")logger.setLevel(logging.INFO)async def transfer_call(participant_identity: str, room_name: str) -> None:async with api.LiveKitAPI() as livekit_api:transfer_to = 'tel:+14155550100'try:await livekit_api.sip.transfer_sip_participant(api.TransferSIPParticipantRequest(participant_identity=participant_identity,room_name=room_name,transfer_to=transfer_to,play_dialtone=False,))print("SIP participant transferred successfully")except api.SipCallError as error:# Why the transfer failed, for example STR_REJECTEDif error.sip_transfer_reason is not None:print(f"Transfer failure reason: {error.sip_transfer_reason}")# Only set when the destination or its provider reports a SIP statusif error.sip_status_code is not None:print(f"SIP error code: {error.sip_status_code}")print(f"SIP error message: {error.sip_status}")except api.ServerError as error:print(f"Error transferring SIP participant: {error.code} - {error.message}")
For a full example using a voice agent, DTMF, and SIP REFER, see the phone assistant example .
require 'livekit'room_name = 'open-room'participant_identity = 'participant_identity'def transferParticipant(room_name, participant_identity)lkapi = LiveKit::LiveKitAPI.newtransfer_to = 'tel:+14155550100'beginlkapi.sip.transfer_sip_participant(room_name,participant_identity,transfer_to,play_dialtone: false)puts "SIP participant transferred successfully"rescue LiveKit::ServerError => e# Why the transfer failed, for example STR_REJECTEDtransfer_reason = e.metadata["sip_transfer_reason"]puts "Transfer failure reason: #{transfer_reason}" if transfer_reasonif e.is_a?(LiveKit::SipCallError)# Only set when the destination or its provider reports a SIP statusputs "SIP error code: #{e.sip_status_code}"puts "SIP error message: #{e.sip_status}"elsif transfer_reason.nil?puts "Error transferring SIP participant: #{e.code} #{e.message}"endendend
import ("context""errors""fmt""github.com/livekit/protocol/livekit"lksdk "github.com/livekit/server-sdk-go/v2")func transferParticipant(ctx context.Context, participantIdentity string) {roomName := "open-room"transferTo := "tel:+14155550100"// Reads LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET from the environment.api, err := lksdk.NewLiveKitAPI()if err != nil {fmt.Println("Error:", err)return}transferRequest := &livekit.TransferSIPParticipantRequest{RoomName: roomName,ParticipantIdentity: participantIdentity,TransferTo: transferTo,PlayDialtone: false,}_, err = api.SIP().TransferSIPParticipant(ctx, transferRequest)if err != nil {// Why the transfer failed, for example STR_REJECTEDtransferErr := lksdk.SIPTransferErrorFrom(err)if transferErr != nil {fmt.Println("Transfer failure reason:", transferErr.Reason)}// Only set when the destination or its provider reports a SIP statusif s := lksdk.SIPStatusFrom(err); s != nil {fmt.Printf("SIP error: %d %s\n", s.Code, s.Status)} else if transferErr == nil {var se lksdk.ServerErrorif errors.As(err, &se) {fmt.Printf("Error transferring SIP participant: %s %s\n", se.Code(), se.Msg())} else {fmt.Println("Error:", err)}}return}fmt.Println("SIP participant transferred successfully")}
import io.livekit.server.LiveKitAPIimport io.livekit.server.SipCallErrorimport io.livekit.server.ServerErrorfun transferParticipant(roomName: String, participantIdentity: String) {// Reads LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET from the environment.val api = LiveKitAPI.createClient()val transferTo = "tel:+14155550100"val response = api.sip.transferSipParticipant(roomName,participantIdentity,transferTo,io.livekit.server.TransferSipParticipantOptions(playDialtone = false),).execute()if (response.isSuccessful) {println("SIP participant transferred successfully")} else {// Decode the error once: reading the response body consumes itval error = ServerError.from(response)// Why the transfer failed, for example STR_REJECTEDval transferReason = error?.metadata?.get("sip_transfer_reason")if (transferReason != null) {println("Transfer failure reason: $transferReason")}if (error is SipCallError) {// Only set when the destination or its provider reports a SIP statusprintln("SIP error code: ${error.sipStatusCode}")println("SIP error message: ${error.sipStatus}")} else if (transferReason == null) {println("Error transferring SIP participant: ${error?.code} ${error?.message}")}}}
use livekit_api::services::sip::TransferSIPParticipantOptions;use livekit_api::services::{LiveKitApi, ServerError, ServiceError, SipCallError};#[tokio::main]async fn main() {let api = LiveKitApi::new("https://my-project.livekit.cloud").unwrap();let room_name = "open-room".to_owned();let participant_identity = "<participant-identity>".to_owned();let transfer_to = "tel:+14155550100".to_owned();let result = api.sip().transfer_sip_participant(room_name,participant_identity,transfer_to,TransferSIPParticipantOptions { play_dialtone: Some(false), ..Default::default() },).await;match result {Ok(()) => println!("SIP participant transferred successfully"),Err(e) => {// Why the transfer failed, for example STR_REJECTEDlet transfer_reason = match &e {ServiceError::Twirp(ServerError::Twirp(code)) => {code.meta.get("sip_transfer_reason").cloned()}_ => None,};if let Some(reason) = &transfer_reason {println!("Transfer failure reason: {reason}");}if let Some(sip) = SipCallError::from_error(&e) {// Only set when the destination or its provider reports a SIP statusprintln!("SIP error code: {}", sip.sip_status_code().unwrap_or_default());println!("SIP error message: {}", sip.sip_status().unwrap_or_default());} else if transfer_reason.is_none() {eprintln!("Error transferring SIP participant: {e}");}}}}
lk sip participant transfer --room <CURRENT_ROOM> \--identity <PARTICIPANT_ID> \--to "<SIP_ENDPOINT>"
Where <SIP_ENDPOINT> is a valid SIP endpoint or telephone number. The following examples are valid formats:
tel:+15105550100sip:+15105550100@sip.telnyx.comsip:+15105550100@my-livekit-demo.pstn.twilio.comsip:+15105550100@<trunk-id>.zt.plivo.com
Forward calls with an agent tool
Your agent can use the TransferSIPParticipant API to transfer calls without staying on the line. The current session ends after the transfer is complete. The following example shows how to define a tool in your agent class that calls TransferSIPParticipant.
TransferSIPParticipant requires the participant_identity of the SIP caller in the room, which is assigned at dispatch time and might differ from the caller's phone number. To reliably find the active SIP caller, look up the participant in the remote_participants list and filter on ParticipantKind.SIP. To learn more, see Identifying SIP callers.
The following examples assume a single SIP caller per room, which is the typical inbound-agent setup. If your room can contain multiple SIP participants (for example, during a warm transfer or conference), track the target caller's identity explicitly instead of picking the first SIP participant.
from livekit import api, rtcfrom livekit.agents import Agent, RunContext, function_tool, get_job_contextclass Assistant(Agent):## ... existing init code ...@function_tool()async def transfer_call(self, ctx: RunContext):"""Transfer the call to a human agent, called after confirming with the user"""transfer_to = "+15105550123"job_ctx = get_job_context()# Find the active SIP caller in the room. The identity is set at# dispatch time and might not match the caller's phone number.# Assumes a single SIP caller per room.sip_participant = next((p for p in job_ctx.room.remote_participants.values()if p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP),None,)if sip_participant is None:return "no active SIP caller to transfer"# let the message play fully before transferringawait ctx.session.generate_reply(instructions="Inform the user that you're transferring them to a different agent.")try:await job_ctx.api.sip.transfer_sip_participant(api.TransferSIPParticipantRequest(room_name=job_ctx.room.name,participant_identity=sip_participant.identity,# to use a sip destination, use `sip:user@host` formattransfer_to=f"tel:{transfer_to}",))except Exception as e:print(f"error transferring call: {e}")# give the LLM that contextreturn "could not transfer call"
To use the Node.js example, install the livekit-server-sdk package:
pnpm add livekit-server-sdk
Define the transfer tool on your agent class using llm.tool. The following example shows a complete Agent with a transferCall tool. Replace the src/agent.ts file in the agent-starter-node project with the following code:
import { voice, llm, getJobContext } from '@livekit/agents';import { LiveKitAPI } from 'livekit-server-sdk';import { ParticipantKind } from '@livekit/rtc-node';import { z } from 'zod';export class Agent extends voice.Agent {constructor() {super({instructions: 'You are a helpful assistant.',tools: {transferCall: llm.tool({description:'Transfer the call to a human agent, called after confirming with the user.',parameters: z.object({}),execute: async (_, { ctx }) => {const transferTo = 'tel:+15105550123';const jobCtx = getJobContext();const room = jobCtx.room;// Find the active SIP caller in the room. The identity is set at// dispatch time and might not match the caller's phone number.// Assumes a single SIP caller per room.const sipParticipant = Array.from(room.remoteParticipants.values()).find((p) => p.kind === ParticipantKind.SIP,);if (!sipParticipant) {return 'no active SIP caller to transfer';}// Let the message play fully before transferringctx.session.generateReply({instructions: "Inform the user that you're transferring them to a different agent.",});await ctx.waitForPlayout();const api = new LiveKitAPI();try {await api.sip.transferSipParticipant(room.name!,sipParticipant.identity,transferTo,{ playDialtone: false },);} catch (e) {console.log(`error transferring call: ${e}`);return 'could not transfer call';}},}),},});}}
Additional resources
The following guides provide more information on building voice agents for telephony.