wide-flower-24692
02/18/2026, 5:14 AMtall-airline-16756
02/18/2026, 5:15 AMtall-airline-16756
02/18/2026, 5:15 AMtransfer_sip_participant.
• Warm (attended) transfer (agent calls the target, then bridges) — use the WarmTransferTask.
If you’re ok with cold transfer, here’s a simple @function_tool example to add to your agent. It finds the SIP participant in the active job context and calls the transfer API:
from livekit.agents import Agent, function_tool, get_job_context
from livekit import api
from livekit.rtc import ParticipantKind
class Assistant(Agent):
@function_tool()
async def transfer_call(self, transfer_to: str):
"""
Cold-transfer the active SIP participant to `transfer_to`.
`transfer_to` should be E.164 like +15105550123 or a SIP URI like sip:user@host.
"""
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{transfer_to}" if transfer_to.startswith("+") else transfer_to,
play_dialtone=True,
)
)
return f"Call is being transferred to {transfer_to}."
except Exception as e:
return f"Failed to transfer call: {e}"
Key points and tips:
• Use tel:+{E.164} for PSTN numbers (e.g., tel:+15105550123) or a SIP URI (sip:user@host) for SIP destinations.
• Your SIP trunk must support SIP REFER (PSTN transfer). If using providers like Twilio, enable PSTN transfer / SIP REFER on the trunk.
• Cold transfer will hand off the call and typically disconnect the agent from the call. If you want the agent to remain connected until the other party answers, use a warm transfer.
Warm transfer option
• For a warm/attended flow, use WarmTransferTask (requires the agents beta workflows). It handles calling the target, letting the agent consult, and then bridging both parties. I can share a short example for that if you want.
Next steps
• Tell me whether you want cold or warm transfer.
• If cold, confirm whether you’ll pass PSTN numbers (E.164) or SIP URIs.
• If warm, I’ll provide a sample WarmTransferTask usage and config notes.
I can also help with trunk-specific details once you tell me your SIP provider or if you prefer a SIP URI vs PSTN number.
Sources: Agents telephony integration | LiveKit Documentation | Pass UUI data to Genesys Cloud during SIP transfers | LiveKit | Tasks and task groups | LiveKit Documentationwide-flower-24692
02/18/2026, 5:24 AMtall-airline-16756
02/18/2026, 5:24 AMtransfer_sip_participant.
from livekit.agents import Agent, function_tool, get_job_context
from livekit import api
from livekit.rtc import ParticipantKind
class Assistant(Agent):
# Static transfer number (E.164)
TRANSFER_NUMBER = "+15105550123"
@function_tool()
async def transfer_call(self):
"""
Cold-transfer the active SIP participant to the static TRANSFER_NUMBER.
"""
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant in the room
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{self.TRANSFER_NUMBER}",
play_dialtone=True,
)
)
return f"Call is being transferred to {self.TRANSFER_NUMBER}."
except Exception as e:
return f"Failed to transfer call: {e}"
Notes:
• The number is hard-coded as TRANSFER_NUMBER — change that string if you want a different static destination.
• The code uses tel:+E.164 format for PSTN transfers. If you prefer a SIP URI, replace f"tel:{self.TRANSFER_NUMBER}" with the SIP URI string (e.g., "sip:user@host").
• Cold transfer hands off the call and typically disconnects the agent from the call.
Would you like me to:
• Replace the static number with a config/env variable for easier changes?
• Provide a short warm-transfer example instead?wide-flower-24692
02/18/2026, 5:26 AMtall-airline-16756
02/18/2026, 5:27 AM@function_tool method as you requested. Drop this into your agent class; it cold-transfers the active SIP participant to the hard-coded E.164 number.
from livekit.agents import Agent, function_tool, get_job_context
from livekit import api
from livekit.rtc import ParticipantKind
class Assistant(Agent):
# Static transfer number (E.164) — change this to your desired destination
TRANSFER_NUMBER = "+15105550123"
@function_tool()
async def transfer_call(self):
"""
Cold-transfer the active SIP participant to the static TRANSFER_NUMBER.
"""
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant in the room
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{self.TRANSFER_NUMBER}",
play_dialtone=True,
)
)
return f"Call is being transferred to {self.TRANSFER_NUMBER}."
except Exception as e:
return f"Failed to transfer call: {e}"
Quick reminders:
• The number is in E.164 format (starts with +). If you need a SIP URI instead, replace f"tel:{self.TRANSFER_NUMBER}" with the SIP URI string.
• Cold transfer hands off the call and typically disconnects the agent.
• If you’d prefer the number to come from an environment variable or config (so you can change it without editing code), I can show that variant next.
Would you like the env/config version or a warm-transfer example as well?wide-flower-24692
02/18/2026, 5:27 AMtall-airline-16756
02/18/2026, 5:28 AM@function_tool method. Below is a minimal, ready-to-use snippet you can drop into your agent. The transfer number is declared inside the function as a static variable and used for a cold SIP transfer.
from livekit.agents import Agent, function_tool, get_job_context
from livekit import api
from livekit.rtc import ParticipantKind
class Assistant(Agent):
@function_tool()
async def transfer_call(self):
"""
Cold-transfer the active SIP participant to a static number defined
as a local variable inside this function.
"""
# Static transfer number declared inside the function (E.164)
TRANSFER_NUMBER = "+15105550123"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant in the room
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}",
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
return f"Failed to transfer call: {e}"
Notes:
• The number is hard-coded in TRANSFER_NUMBER inside the function per your request.
• Use E.164 (+1510...) with tel: for PSTN. For a SIP URI, replace f"tel:{TRANSFER_NUMBER}" with the SIP URI string.
• This performs a cold transfer (agent is typically disconnected after transfer).
If you want, I can:
• Show the same with the number read from an environment variable instead, or
• Provide a warm-transfer example that calls and then bridges the target. Which would you prefer?wide-flower-24692
02/18/2026, 5:30 AMtransfer_to.
transfer_to should be E.164 like +15105550123 or a SIP URI like sip:user@host.
"""
TRANSFER_NUMBER = "+15105550123"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
logger.info(f"Transferring call to {TRANSFER_NUMBER}")
await ctx.session.generate_reply(
instructions="Let the user know you'll be transferring them in the current language."
)
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
return f"Failed to transfer call: {e}"
this is corrcet?tall-airline-16756
02/18/2026, 5:30 AMTRANSFER_NUMBER.startswith("+") check should run on the plain number (no tel: prefix).
• Use get_job_context() (you already do) and pass ctx: RunContext only if your agent runtime requires it — you don’t need both ctx and get_job_context() to locate the job, but keeping ctx for session replies is fine.
• Make sure logger is imported and available.
• Use await ctx.session.generate_reply(...) to speak to the user before initiating transfer, which you already do — good.
• Return messages should use the plain number (not the markdown link).
Corrected, minimal version
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
@function_tool()
async def transfer_call(self, transfer_to: str, ctx: RunContext):
"""
Cold-transfer the active SIP participant to `transfer_to`.
`transfer_to` should be E.164 like +15105550123 or a SIP URI like sip:user@host.
"""
# Static transfer number as a plain string
TRANSFER_NUMBER = "+15105550123"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
# Inform the user in-call
<http://logger.info|logger.info>(f"Transferring call to {TRANSFER_NUMBER}")
await ctx.session.generate_reply(
instructions="Let the user know you'll be transferring them in the current language."
)
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
return f"Failed to transfer call: {e}"
Runtime notes
• This performs a cold transfer (SIP REFER). The agent will typically be disconnected after the transfer.
• Ensure your SIP trunk/provider supports SIP REFER / PSTN transfer for cold transfers to succeed.
• If you want the function to accept different numbers at runtime, you can replace the hard-coded TRANSFER_NUMBER with the transfer_to parameter and validate/normalize it.
If you want, I can:
• Provide a version that uses the transfer_to parameter instead of the hard-coded number.
• Show an env-config variant so you don’t edit code to change the static number.
• Provide a warm-transfer example if you prefer the agent to remain connected until the destination answers.
Which would you like next?wide-flower-24692
02/18/2026, 5:41 AMtall-airline-16756
02/18/2026, 5:42 AMParticipantKind wasn't imported in your module, so Python can't find it when the function runs.
Fix: add the missing import and a minimal, complete function. Below is a corrected snippet you can drop into your agent file — it includes the required imports, defines the static transfer number inside the function (as you requested), logs the transfer, generates an in-call reply, and performs the cold SIP transfer.
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind # <-- make sure this is imported
import logging
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, transfer_to: str = None, ctx: RunContext = None):
"""
Cold-transfer the active SIP participant to a static number defined
inside this function. `transfer_to` parameter is unused here because
the function uses the local TRANSFER_NUMBER per your request.
"""
# Static transfer number declared inside the function (E.164)
TRANSFER_NUMBER = "+15105550123"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
# Inform the caller agent-side before transferring
<http://logger.info|logger.info>(f"Transferring call to {TRANSFER_NUMBER}")
if ctx and getattr(ctx, "session", None):
await ctx.session.generate_reply(
instructions="Let the user know you'll be transferring them in the current language."
)
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
Notes and checks:
• Confirm the file imports ParticipantKind from livekit.rtc as shown.
• If you prefer to accept the destination number at runtime, replace the local TRANSFER_NUMBER with the transfer_to parameter and validate it.
• Ensure your SIP trunk/provider supports SIP REFER/PSTN transfer; otherwise transfers will fail even when the code runs.
• The function assumes ctx.session.generate_reply exists in your runtime; keeping ctx is fine for in-call messages.
If you want, I can:
• Provide the variant that uses the transfer_to parameter instead of the hard-coded value.
• Show an env/config approach so changing the number doesn't require code edits.
• Help inspect further logs if the transfer still fails after this import fix.wide-flower-24692
02/18/2026, 5:45 AMtall-airline-16756
02/18/2026, 5:45 AMgenerate_reply timed out waiting for generation_created event. This happened inside the realtime reply flow (_realtime_reply_task) when calling generate_reply.
2) Why the timeout happens
• generate_reply is a realtime LLM operation that expects a generation event back from the LLM/RT session. A timeout means the realtime session didn’t produce the expected event in time (network, model, or session negotiation problem), or the session object you called isn't available or ready.
• This is orthogonal to the SIP transfer itself — the transfer call may still have been sent to the SIP trunk even though the realtime reply timed out.
3) Immediate fixes you can apply
• Guard the generate_reply call so a failure there doesn’t crash the tool execution flow and prevents the transfer.
• Use a try/except with a short timeout for generate_reply. If it fails, log it and continue to perform the transfer anyway.
• If you don’t need an in-call TTS reply before transfer, skip generate_reply entirely for this tool to avoid realtime complexity.
4) Safe, corrected function snippet
• This snippet keeps the static number in the function and calls generate_reply only if the realtime session exists. It wraps the call in try/except with a short timeout to avoid blocking the transfer.
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import logging
import asyncio
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
# Static transfer number inside function (E.164)
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
<http://logger.info|logger.info>(f"Transferring call to {TRANSFER_NUMBER}")
# Try to notify user, but don't fail the transfer if realtime generation times out
if ctx and getattr(ctx, "session", None):
try:
# use asyncio.wait_for to enforce a short timeout on generate_reply
await asyncio.wait_for(
ctx.session.generate_reply(
instructions="I will transfer you now."
),
timeout=3.0, # seconds — tune as needed
)
except asyncio.TimeoutError:
logger.warning("generate_reply timed out; proceeding with transfer")
except Exception as e:
logger.exception("generate_reply failed; proceeding with transfer")
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
5) Additional troubleshooting steps if problems continue
• Check your realtime/LLM connectivity and logs around the time of timeout — network flaps or LLM service issues can cause generate_reply timeouts.
• Increase the timeout value temporarily to see if the generation completes with more time.
• If you consistently don’t need spoken pre-transfer messages, remove generate_reply from this tool to simplify the flow.
• Confirm the SIP transfer succeeded by checking SIP/trunk logs and call session state in LiveKit after the transfer attempt.
If you want, I can:
• Provide a version that entirely omits generate_reply.
• Help you add a retry/backoff around the realtime generation.
• Walk through checking your realtime session and LLM health logs — share any relevant logs and I’ll help interpret them.wide-flower-24692
02/18/2026, 5:47 AMTRANSFER_NUMBER.
"""
# Static transfer number as a plain string
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
# Inform the user in-call
logger.info(f"Transferring call to {TRANSFER_NUMBER}")
await ctx.session.generate_reply(
instructions="Let the user know you'll be transferring them in the current language."
)
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
return f"Failed to transfer call: {e}"
this is my function show
Feb 18 054454 ip-172-31-23-35.ec2.internal python3[1677756]: {"message": "Transferring call to +918956521667", "level": "INFO", "name": "outbound-caller", "pid": 1677769, "job_id": "AJ_SQEVd6igicDo", "room_id": "RM_YAr6CCBhM3nw", "timestamp": "2026-02-18T054454.325606+00:00"}
Feb 18 054459 ip-172-31-23-35.ec2.internal python3[1677756]: {"message": "Error in _realtime_reply_task\nTraceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/utils/log.py\", line 16, in async_fn_logs\n return await fn(*args, **kwargs)\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/voice/agent_activity.py\", line 2189, in _realtime_reply_task\n generation_ev = await self._rt_session.generate_reply(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nlivekit.agents.llm.realtime.RealtimeError: generate_reply timed out waiting for generation_created event.", "level": "ERROR", "name": "livekit.agents", "exc_info": "Traceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/utils/log.py\", line 16, in async_fn_logs\n return await fn(*args, **kwargs)\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/voice/agent_activity.py\", line 2189, in _realtime_reply_task\n generation_ev = await self._rt_session.generate_reply(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\nlivekit.agents.llm.realtime.RealtimeError: generate_reply timed out waiting for generation_created event.", "pid": 1677769, "job_id": "AJ_SQEVd6igicDo", "room_id": "RM_YAr6CCBhM3nw", "timestamp": "2026-02-18T054459.331304+00:00"}tall-airline-16756
02/18/2026, 5:47 AMgenerate_reply call so a timeout or other realtime error won’t stop the transfer.
Below is a corrected, ready-to-drop-in function that:
• Declares the static transfer number inside the function (E.164 plain string).
• Imports ParticipantKind.
• Wraps generate_reply in a safe try/except with a short timeout so the transfer proceeds even if realtime generation fails.
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import logging
import asyncio
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
"""
Cold-transfer the active SIP participant to the static TRANSFER_NUMBER.
The number is defined inside this function as requested.
"""
# Static transfer number — plain E.164 string
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
<http://logger.info|logger.info>(f"Transferring call to {TRANSFER_NUMBER}")
# Try to notify caller in-call, but do not block the transfer if realtime fails.
if ctx and getattr(ctx, "session", None):
try:
await asyncio.wait_for(
ctx.session.generate_reply(
instructions="I will transfer you now."
),
timeout=3.0, # tune as needed
)
except asyncio.TimeoutError:
logger.warning("generate_reply timed out; proceeding with transfer")
except Exception:
logger.exception("generate_reply failed; proceeding with transfer")
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
If you still see the realtime timeout error after this change:
• Increase the timeout in asyncio.wait_for temporarily to verify whether the generation just needs more time.
• If you don’t need an in-call TTS message before transfer, remove the generate_reply block entirely to avoid realtime dependency.
• Check LLM/realtime connectivity and RT session logs for intermittent failures.
If you’d like, I can:
• Provide a variant that accepts a runtime transfer_to parameter instead of the hard-coded number.
• Show how to move the number to an environment variable for easier changes.
• Help interpret any further logs you paste here. Which would you prefer?wide-flower-24692
02/18/2026, 5:51 AMtall-airline-16756
02/18/2026, 5:51 AMsip_status: Forbidden and sip_status_code: 403.
◦ That means LiveKit attempted the SIP REFER (transfer) but the SIP endpoint or trunk rejected it.
How to handle each problem
1) Realtime generate_reply timeout (safe to handle)
• Cause: The realtime LLM session didn’t produce a generation_created event in time (network, LLM availability, or session state).
• Quick fix: Keep the existing safe pattern (wrap in try/except and use a short timeout) so a failed TTS doesn’t block transfer.
• Alternative: If you don’t need the spoken pre-transfer message, remove the generate_reply call entirely to simplify flow and avoid realtime failures.
2) SIP transfer 403 Forbidden (needs configuration checks)
• Cause: A permission/authorization issue at the SIP trunk or SIP endpoint. The SIP transfer request reached the SIP stack but was refused (common reasons below).
• What to check:
◦ SIP trunk/provider settings: Ensure PSTN transfer or SIP REFER is allowed on the trunk. Some providers (e.g., Twilio) require enabling "PSTN Transfer" or SIP REFER explicitly.
◦ SIP credentials and routing: Confirm the SIP credentials used by LiveKit are authorized to initiate transfers to that destination (some carriers block transfers to certain numbers or require extra permissions).
◦ SIP URI vs tel: If the trunk expects a SIP URI, send sip:user@host instead of tel:+....
◦ LiveKit API permissions: Confirm your LiveKit project/API key has the required access to call SIP transfer APIs (if you have scoped keys or role restrictions).
◦ Provider error details: Check your SIP provider or trunk logs for the 403 reason code — it will often show whether the request was rejected due to authentication, denied transfer to that destination, or policy.
• Typical remediation:
◦ Enable SIP REFER / PSTN transfer in the trunk/provider dashboard.
◦ If the provider needs a parameter or header for REFER, configure your trunk accordingly.
◦ If using Twilio or similar, enable transfer features on the trunk and ensure the From/Caller ID and credentials match allowed values.
Safe code to keep transfer working even if generate_reply fails
• You already use a guarded generate_reply — keep that. Below is the same robust function for clarity (static number inside the function). It continues with transfer even if notify times out and logs the SIP error for diagnosis.
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import logging
import asyncio
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
# Static transfer number — plain E.164 string
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
<http://logger.info|logger.info>(f"Transferring call to {TRANSFER_NUMBER}")
# Try to notify caller in-call, but do not block the transfer if realtime fails.
if ctx and getattr(ctx, "session", None):
try:
await asyncio.wait_for(
ctx.session.generate_reply(instructions="I will transfer you now."),
timeout=3.0,
)
except asyncio.TimeoutError:
logger.warning("generate_reply timed out; proceeding with transfer")
except Exception:
logger.exception("generate_reply failed; proceeding with transfer")
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=f"tel:{TRANSFER_NUMBER}" if TRANSFER_NUMBER.startswith("+") else TRANSFER_NUMBER,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
# Log the error details for troubleshooting provider/trunk issues
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
Next steps I recommend (in order)
1. Re-run a transfer attempt and capture SIP provider/trunk logs for the exact 403 reason (these logs are the most helpful).
2. Check your SIP trunk/provider:
◦ Enable SIP REFER / PSTN transfer.
◦ Confirm your account/trunk allows transfers to the destination number.
1. Verify the transfer format:
◦ Try using a SIP URI if your provider requires it, e.g., transfer_to="sip:user@host".
1. If trunk settings look correct and you still see 403:
◦ Check LiveKit project/API key scopes if you use scoped keys or restrictions.
◦ Share the trunk/provider name and any provider-side 403 details (you can paste provider logs) and I’ll point to the exact setting to change.
1. If you prefer to avoid realtime complexity, remove generate_reply from the tool — transfer logic will remain the same.
If you want, I can:
• Provide a quick checklist for common providers (Twilio, etc.) to enable PSTN transfer.
• Show the variant that sends a SIP URI instead of tel: so you can test both.
• Help interpret provider/trunk logs if you paste them here.
Which would you like me to help with next — SIP provider checks, testing with a SIP URI, or removing generate_reply entirely?wide-flower-24692
02/18/2026, 5:54 AMtall-airline-16756
02/18/2026, 5:54 AMcreate_sip_participant using sip_trunk_id, then later attempting a cold transfer. The 403 Forbidden during transfer means the SIP stack rejected the REFER request. Here are the most likely causes and exact checks to run, with small fixes you can try.
What most commonly causes a SIP 403 on transfer
• The SIP trunk does not allow PSTN transfers (SIP REFER) or it’s not enabled for that trunk.
• The transfer request is sent on a different trunk or with credentials that don’t have permission to REFER to the destination.
• The provider blocks transfers to that destination number (destination blocked, require pre-authorization, or restricted by policy).
• The transfer format is not what the trunk expects (some trunks require a SIP URI instead of tel:+...).
• Caller ID / From header or authentication used in the transfer doesn’t match what the trunk allows.
Step-by-step checks to run (in this order)
1. Confirm the same sip_trunk_id is used
• Verify the sip_trunk_id you used for create_sip_participant is the same trunk/context being used when LiveKit issues the transfer. Some providers enforce permissions per trunk.
1. Enable PSTN transfer / SIP REFER in the trunk settings
• In your SIP provider dashboard (e.g., Twilio, other carriers) enable PSTN transfer or SIP REFER. Many providers block REFER by default and require explicit enablement.
1. Check provider logs for the 403 reason
• Pull the SIP/trunk logs for the time of the transfer. Provider logs usually show a clearer reason for 403 (e.g., permission denied, blocked destination, or auth mismatch). Use that message to adjust settings.
1. Try using a SIP URI instead of tel: (test)
• Some trunks prefer sip:user@host for transfers. Try:
◦ transfer_to = "sip:1234@sip.provider.com"
◦ or test both tel:+918956521667 and sip:... to see which the provider accepts.
1. Verify caller identity and allowed destinations
• Check the From/caller-id used on outbound calls and transfers. Some providers only allow transfers when the caller ID or credentials are in an allowlist for the destination country or number range.
1. Confirm API/LiveKit permissions (if using scoped keys)
• If your LiveKit project uses scoped API keys or role restrictions, ensure the key the agent uses can call the SIP transfer API.
Small code/tests to try
• Log the sip_trunk_id and what you pass to transfer_sip_participant to confirm they match.
• Test a transfer using a known-working internal SIP URI (if available) to see if the trunk accepts it. This isolates whether the issue is PSTN routing vs. transfer capability.
Example: log and test both formats before calling transfer
TRANSFER_NUMBER = "+918956521667"
<http://logger.info|logger.info>(f"Using trunk_id={sip_trunk_id} to transfer to tel:{TRANSFER_NUMBER}")
# also try SIP URI variant for testing
sip_uri = "<sip:destination@sip.exampl>e.com"
<http://logger.info|logger.info>(f"Also trying sip uri: {sip_uri}")
If you share (paste) any provider/trunk log lines for the same timestamp that show the 403, I can help decode the exact reason and tell you which trunk setting to change.
Quick next actions I recommend
• Check your SIP provider dashboard and enable PSTN Transfer / SIP REFER for the trunk used.
• Pull provider SIP logs for the failure timestamp to see the detailed rejection reason.
• Test a transfer using a SIP URI and compare results.
• Verify the same sip_trunk_id and credentials are used for both the outbound call and the transfer.
If you want, I can:
• Give a short checklist for common providers (Twilio, etc.) on where to enable PSTN transfer.
• Provide a small snippet that logs trunk id, participant identity, and transfer payload right before calling transfer_sip_participant so you can paste those logs here.
Which would be most helpful next?wide-flower-24692
02/18/2026, 6:01 AMtall-airline-16756
02/18/2026, 6:02 AMtransfer_sip_participant means the SIP endpoint/trunk refused the REFER transfer. This is a configuration/permission issue on the SIP trunk or provider, not a Python code bug.
What to check (step-by-step)
1. Confirm trunk used for outbound call and transfer
◦ Ensure the same sip_trunk_id you used in create_sip_participant is the trunk LiveKit uses for transfer.
◦ Log the trunk id right before calling transfer to verify it’s identical.
1. Enable SIP REFER / PSTN transfer on the trunk
◦ Your trunk/provider must allow call transfers (SIP REFER).
◦ If you manage the trunk in LiveKit: enable transfers with the lk CLI:
▪︎ List trunks: lk sip outbound list
▪︎ Update trunk:
lk sip outbound update ST_your_trunk_id --allow-transfers true --transfer-mode enable-all
• If your provider is external (e.g., Twilio), enable "PSTN Transfer" / "SIP Refer" in provider dashboard.
1. Check provider-side logs for the 403 reason
◦ Provider logs usually give a clear rejection reason (blocked destination, auth mismatch, policy).
◦ Paste the provider log lines here if you want help decoding them.
1. Verify transfer format
◦ Try both formats to test provider acceptance:
▪︎ PSTN: tel:+918956521667
▪︎ SIP URI: sip:+918956521667@provider-domain (some trunks require a SIP URI)
◦ Log which string you send to transfer_sip_participant for debugging.
1. Caller ID / credentials and allowed destinations
◦ Some providers block transfers based on From/CallerID or destination country. Verify your account/trunk permits transfers to the destination number.
1. LiveKit API permissions (if using scoped keys)
◦ If you use scoped API keys or role restrictions, ensure the agent’s key can perform SIP transfers.
Quick tests to run now
• Add logging just before transfer showing:
◦ room name, participant identity, sip_trunk_id, and the exact transfer_to string.
• Try a transfer to a known-working internal SIP URI (if available) to see whether the trunk accepts transfers at all.
Code reminder — robust transfer function
• Keep the guarded generate_reply (or remove it) so realtime timeouts won’t block transfer.
• Use plain E.164 string (not markdown) and ensure ParticipantKind is imported.
Example recap of the transfer call (safe, static number inside function):
# imports: ensure these exist
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import asyncio, logging
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
TRANSFER_NUMBER = "+918956521667" # plain E.164
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
# log trunk/transfer payload for debugging
# if job_ctx.room has trunk metadata, log it here (or log the sip_trunk_id available elsewhere)
<http://logger.info|logger.info>(f"Transferring participant={sip_participant.identity} to {TRANSFER_NUMBER}")
# optional: attempt an in-call message but do not block transfer
if ctx and getattr(ctx, "session", None):
try:
await asyncio.wait_for(ctx.session.generate_reply(instructions="I will transfer you now."), timeout=3.0)
except asyncio.TimeoutError:
logger.warning("generate_reply timed out; proceeding with transfer")
except Exception:
logger.exception("generate_reply failed; proceeding with transfer")
try:
# Try tel: format first; try SIP URI if provider needs it
transfer_to = f"tel:{TRANSFER_NUMBER}"
<http://logger.info|logger.info>(f"Calling transfer_sip_participant with transfer_to={transfer_to}")
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=transfer_to,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
Next recommended actions (priority)
1. Run a transfer attempt while capturing provider/trunk logs for the same timestamp; paste provider logs here and I’ll interpret them.
2. Enable transfers on the trunk (LiveKit lk CLI or provider dashboard) per the commands above.
3. Test with a SIP URI if enabling transfers doesn’t help or provider requires it.
If you want, I can:
• Provide the exact lk CLI commands again for your trunk id.
• Help parse provider logs if you paste them.
• Show a variant that tries both tel: and sip: automatically and logs responses for easier debugging.
Which of those would you like me to do next?
Sources: Agents telephony integration | LiveKit Documentation | Agent speech and audio | LiveKit Documentationwide-flower-24692
02/18/2026, 6:08 AMtall-airline-16756
02/18/2026, 6:09 AMwide-flower-24692
02/18/2026, 6:11 AMtall-airline-16756
02/18/2026, 6:11 AMtel:+918956521667
◦ SIP: sip:+918956521667@provider-domain
• If your LiveKit setup uses scoped API keys or restricted roles, make sure the agent has permission to call the SIP transfer API.
• If you preserve caller ID on transfer, validate the Transfer Caller ID setting if you need original caller ID preserved.
Recommended next steps
1. Call the Twilio trunk update API (or use Console) to set TransferMode:
◦ POST https://trunking.twilio.com/v1/Trunks/{TrunkSid} with form field TransferMode=enable-all
1. Re-run a transfer attempt and capture logs:
◦ LiveKit logs (you already have) and Twilio Trunking logs for the transfer timestamp.
1. If it still fails, share the Twilio trunk log lines around the transfer attempt (redact secrets). I’ll help interpret the exact 403 reason (blocked destination, auth, policy, etc.).
Optional robustness in code
• Keep generate_reply wrapped so TTS timeouts don’t block transfers.
• Log the transfer payload before calling transfer_sip_participant:
◦ room name, participant identity, sip_trunk_id, transfer_to string
• Optionally test both tel: and sip: transfer_to formats to see which provider accepts.
If you want, I can:
• Provide the exact PHP snippet you can add to your trunk-creation flow to set TransferMode=enable-all.
• Walk through how to read the Twilio Trunking logs for the 403 details.
• Help test both tel: and sip: formats in your transfer function and show what to log.
Which would you like next?wide-flower-24692
02/18/2026, 6:13 AMTransferMode=enable-all then it works?tall-airline-16756
02/18/2026, 6:14 AMTransferMode=enable-all when you update the Twilio trunk is the key setting that enables SIP REFER / PSTN transfers and will very likely fix the 403 you saw — provided the trunk you update is the same one LiveKit is using and the account is otherwise allowed to transfer to that destination.
A few important details and checks so it actually works:
• Where to add it
◦ Add TransferMode=enable-all in the same POST you already do to set DomainName (or call a separate POST to update the trunk). Example form payload keys include DomainName and TransferMode.
• Example PHP snippet (add to your existing update call)
$response2 = Http::withBasicAuth($accountSid, $authToken)
->asForm()
->post("<https://trunking.twilio.com/v1/Trunks/$trunkSid>", [
'DomainName' => $domainName,
'TransferMode' => 'enable-all', // <-- enable SIP REFER / PSTN transfer
]);
• Verify you updated the correct trunk
◦ Make sure $trunkSid is the exact trunk LiveKit is using for the outbound call. If LiveKit uses a different trunk, enabling transfers on an unrelated trunk won’t help.
• After updating, check:
a. Twilio trunk shows Transfer enabled in the Console (Trunk → Features → Transfers).
b. Re-run a transfer attempt and capture both LiveKit logs and Twilio Trunking logs for that timestamp.
c. If transfer still fails, inspect the Twilio log entry for the 403 reason (it usually explains whether it’s a blocked destination, auth, or policy issue).
• Other things to confirm if still failing:
◦ The account/trunk is authorized to place transfers to the target country/number range.
◦ If your provider requires a SIP URI, test sip:...@domain as well as tel:+....
◦ LiveKit API key or scoped permissions aren’t restricting SIP transfer calls.
If you want, I can:
• Provide the exact PHP snippet to call after trunk creation (I included one above).
• Walk through how to find and read the Twilio Trunking logs for the 403 entry.
• Help parse the Twilio log lines if you paste them here after you try the update.
Would you like the separate PHP snippet to call specifically for enabling TransferMode after trunk creation, or help checking the Twilio logs next?wide-flower-24692
02/18/2026, 6:20 AMOK
) of the new call from Twilio to the new SIP endpoint.
Once the new call is answered, the Transferor will terminate the existing, held call to the original SIP endpoint.
No*te*
: Early media with Call Transfers is not supported.
Set CallerID for call transfer
Twilio allows you to set the caller ID for call transfer use cases using the console. Select the
Trunk
of your choice, in the "General section" under "Call Transfer (SIP Refer)" you can see "Caller ID for Transfer Target". The default value is
Transferee and you can change to Transferor based on your use cases. You can also configure the same using the API.
Call Transfer to the PSTN
In order to transfer a call to the PSTN you must include your Trunking Termination Domain for example
sip:+14152908007@{my-trunk}.<http://pstn.twilio.com|pstn.twilio.com> or alternatively use a Tel-URI for example tel:+14152909007 in the Refer-To header.
Call Transfers to Emergency Services (911/933) are not supported.
Call Logs
If you look at your Elastic SIP Trunking
call logs
, you'll notice that the Original Call has a "Child Call" representing the new call established as a result of the requested Call Transfer.
Pricing
Initiating a Call Transfer via your Elastic SIP Trunk is free, however you'll continue to be responsible for the per-minute Trunking charges to the referred-to destination on your account.
Please refer to the following tables to understand the details around Billing and Call Logs:
Trunking Termination Billing/Logs with Call Transfers
Tr*ansfer ScenarioParent Call BillingParent Call LogChild Call BillingChild Call Log*
Trunking Termination (from:A to:B)
Transfer to SIP (to:C)Trunking Termination (from:A to:B) x Parent call durationfrom:A to:BTrunking Termination (from:C to:B) x Child call durationfrom:B to:CTrunking Termination (from:A to:B)
Transfer to PSTN (to:C)Trunking Termination (from:A to:B) x Parent call durationfrom:A to:BTrunking Termination (from:A to:B) x Child call duration +
Trunking Termination (from:A to:C) x Child call durationfrom:B to:C
Trunking Origination Billing/Logs with Call Transfers
Tr*ansfer ScenarioParent Call BillingParent Call LogChild Call BillingChild Call Log*
Trunking Origination (from:A to:B)
Transfer to Public SIP (to:C)Trunking Origination (from:A to:B) x Parent call durationfrom:A to:BTrunking Origination (from:A to:C) x Child call durationfrom:A to:CTrunking Origination (from:A to:B)
Transfer to PSTN (to:C)Trunking Origination (from:A to:B) x Parent call durationfrom:A to:BTrunking Origination (from:A to:B) x Child call duration +
Trunking Termination (from:B to:C) x Child call durationfrom:A to:C
this is twilio doctall-airline-16756
02/18/2026, 6:20 AM$response2 = Http::withBasicAuth($accountSid, $authToken)
->asForm()
->post("<https://trunking.twilio.com/v1/Trunks/$trunkSid>", [
'DomainName' => $domainName,
'TransferMode' => 'enable-all', // enable SIP REFER / PSTN transfers
'TransferCallerId' => 'Transferee', // or 'Transferor' if you want the trunk/agent number
]);
if (!$response2->successful()) {
throw new Exception("Update trunk (enable transfers) failed: " . $response2->body());
}
Notes and tips
• TransferMode values: use enable-all to allow call transfers. This enables transfers to SIP destinations and PSTN (if allowed by account).
• TransferCallerId: Transferee (default) preserves the transferred party’s caller ID; Transferor uses the transferor’s ID. Choose per your needs.
• Make sure you update the exact Trunk SID ($trunkSid) that LiveKit uses for the outbound call. If LiveKit is using a different trunk, enable transfers on that trunk instead.
• After updating, confirm in the Twilio Console: Trunk → Features → Call Transfers shows enabled.
• Re-run a transfer test and collect logs from both LiveKit and Twilio Trunking call logs (they’ll show the child/parent call and any errors).
If you still see 403 after this
• Check Twilio Trunking call logs for the transfer timestamp — the provider log will give the precise reason (e.g., blocked destination, authentication, or policy).
• Test both formats for the Refer-To target:
◦ PSTN Tel-URI: tel:+918956521667
◦ SIP URI: <sip:+918956521667@your-trunk-domain.pstn>.twilio.com
• Verify your Twilio account/trunk allows calls/transfers to that destination country/number range.
If you want, I can:
• Provide the exact code to set TransferMode during trunk creation (instead of update).
• Help parse the Twilio Trunking log entry if you paste it (redact secrets).
• Show a LiveKit transfer function that logs trunk id and transfer payload right before calling transfer_sip_participant.
Which would you like next?wide-flower-24692
02/18/2026, 6:22 AMtall-airline-16756
02/18/2026, 6:23 AM// After you have $trunkSid from trunk creation:
$response2 = Http::withBasicAuth($accountSid, $authToken)
->asForm()
->post("<https://trunking.twilio.com/v1/Trunks/$trunkSid>", [
'DomainName' => $domainName,
'TransferMode' => 'enable-all', // enable SIP REFER and PSTN transfer
'TransferCallerId' => 'Transferee', // or 'Transferor' depending on desired caller ID
]);
if (!$response2->successful()) {
// Inspect response body for Twilio error details
throw new Exception("Update trunk (enable transfers) failed: " . $response2->body());
}
Notes:
• Use enable-all to allow SIP REFER and PSTN transfers for that trunk.
• TransferCallerId controls caller ID for the transferred call (choose Transferee or Transferor).
• Confirm this $trunkSid is the same trunk LiveKit uses for outbound calls. Enabling transfers on a different trunk won’t help.
Python: safe LiveKit tool function to perform a cold transfer to a static number
• This function:
◦ Declares the static transfer number inside the function (as you requested).
◦ Imports ParticipantKind and logs key values (participant identity, transfer payload).
◦ Wraps ctx.session.generate_reply in a short timeout so realtime failures don’t block the transfer.
◦ Tries tel: format; you can swap to a SIP URI if your provider requires it.
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import asyncio
import logging
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
"""
Cold-transfer the active SIP participant to a static number defined inside this function.
"""
# Static transfer number (plain E.164)
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# Find the SIP participant in the room
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
# Log the transfer payload for debugging
transfer_to_tel = f"tel:{TRANSFER_NUMBER}"
<http://logger.info|logger.info>(
"Initiating transfer",
extra={
"room_name": job_ctx.room.name,
"participant_identity": sip_participant.identity,
"transfer_to": transfer_to_tel,
},
)
# Optional: notify the caller in-call, but don't block the transfer if it times out.
if ctx and getattr(ctx, "session", None):
try:
await asyncio.wait_for(
ctx.session.generate_reply(instructions=f"I will transfer you now to {TRANSFER_NUMBER}."),
timeout=3.0,
)
except asyncio.TimeoutError:
logger.warning("generate_reply timed out; proceeding with transfer")
except Exception:
logger.exception("generate_reply failed; proceeding with transfer")
# Attempt transfer (tel: format); if your provider needs SIP URI, change transfer_to accordingly
transfer_to = transfer_to_tel
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=transfer_to,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
# Return the error message so caller can see the issue; in logs you'll have full stack
return f"Failed to transfer call: {e}"
Testing checklist after making the PHP change
1. Confirm the trunk update request succeeded (no errors from Twilio).
2. In Twilio Console, open the Trunk → Features → Call Transfers to verify transfers are enabled.
3. Confirm LiveKit is using that trunk SID for the outbound call (log or inspect the sip_trunk_id you passed at create_sip_participant).
4. Run an outbound call, invoke the transfer tool, and capture:
◦ LiveKit logs (tool logs and any Twirp/TwirpError text)
◦ Twilio Trunking call logs at the transfer timestamp
1. If transfer still returns 403:
◦ Paste the Twilio trunking log entry for the transfer attempt (redact secrets). I’ll help decode the precise Twilio rejection reason (blocked destination, auth, policy, etc.).
◦ Try a SIP URI transfer format (e.g., "sip:+918956521667@{your-trunk}.pstn.twilio.com") to see if Twilio accepts that instead.
If you want, I can now:
• Provide the exact PHP code to include this setting during trunk creation (instead of updating after), or
• Show a variant of the Python tool that attempts tel: first and falls back to a sip: URI automatically.
Which would you like next?wide-flower-24692
02/18/2026, 6:26 AMtall-airline-16756
02/18/2026, 6:26 AM@function_tool transfer function that will perform the cold transfer to your static number.
Summary (quick)
• You create a Twilio trunk and store its info in your DB (your existing PHP code). Good.
• Make sure you enable call transfers on that Twilio trunk (TransferMode=enable-all) — otherwise LiveKit’s SIP REFER will get 403 Forbidden.
• Create the LiveKit outbound trunk from the DB entry (you already do in create_sip_trunk_from_db) and pass the sip_trunk_id into the agent via dispatch metadata.
• Use the Python @function_tool below inside your agent to transfer the call to the static number. It logs relevant fields, avoids blocking on realtime TTS, and reports useful errors.
1) Ensure Twilio trunk allows transfers (what to add in PHP) Add TransferMode (and optional TransferCallerId) when you update the trunk. Add this to your existing trunk update step (you already POST DomainName — include TransferMode there):
// After you have $trunkSid from trunk creation:
$response2 = Http::withBasicAuth($accountSid, $authToken)
->asForm()
->post("<https://trunking.twilio.com/v1/Trunks/$trunkSid>", [
'DomainName' => $domainName,
'TransferMode' => 'enable-all', // enable SIP REFER / PSTN transfers
'TransferCallerId' => 'Transferee', // or 'Transferor' as needed
]);
if (!$response2->successful()) {
throw new Exception("Update trunk (enable transfers) failed: " . $response2->body());
}
Notes:
• Use the exact $trunkSid that LiveKit will use. Enabling transfers on a different trunk will not fix the 403.
• Confirm in Twilio Console: Trunk → Features → Call Transfers shows enabled.
2) Create the LiveKit outbound trunk from DB and pass the trunk id to the agent You already have this function pattern. After creating the LiveKit outbound trunk, include the sip_trunk_id in the agent dispatch metadata so the agent can read it. Example dispatch snippet:
# create_sip_trunk_from_db(trunk_data, number) returns created_trunk with .sip_trunk_id
created_trunk = await create_sip_trunk_from_db(trunk_data, number)
sip_trunk_id = created_trunk.sip_trunk_id
dispatch_req = api.CreateAgentDispatchRequest(
agent_name="outbound-agent",
room=f"call-{uuid.uuid4().hex[:8]}",
metadata=json.dumps({
"phone_number": number,
"sip_trunk_id": sip_trunk_id
})
)
dispatch = await lk_api.agent_dispatch.create_dispatch(dispatch_req)
3) Robust agent transfer tool function (drop this in your agent)
• This function uses a local static number inside the function (as you requested).
• It logs room, participant, and transfer payload.
• It wraps generate_reply in a short timeout so TTS/realtime failures won’t block the transfer.
• It attempts a tel: transfer (change to a SIP URI if your provider requires it).
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import asyncio
import logging
import json
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
"""
Cold-transfer the active SIP participant to a static number defined inside this function.
"""
# Static transfer number (plain E.164)
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# Extract sip_trunk_id from room metadata if you need to verify it
try:
meta = json.loads(job_ctx.room.metadata or "{}")
sip_trunk_id = meta.get("sip_trunk_id")
except Exception:
sip_trunk_id = None
# Find the SIP participant in the room
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
transfer_to_tel = f"tel:{TRANSFER_NUMBER}"
<http://logger.info|logger.info>(
"Initiating transfer",
extra={
"room_name": job_ctx.room.name,
"participant_identity": sip_participant.identity,
"sip_trunk_id": sip_trunk_id,
"transfer_to": transfer_to_tel,
},
)
# Optional: notify caller in-call, but do not block transfer if realtime fails.
if ctx and getattr(ctx, "session", None):
try:
await asyncio.wait_for(
ctx.session.generate_reply(instructions=f"I will transfer you now to {TRANSFER_NUMBER}."),
timeout=3.0,
)
except asyncio.TimeoutError:
logger.warning("generate_reply timed out; proceeding with transfer")
except Exception:
logger.exception("generate_reply failed; proceeding with transfer")
# Attempt transfer (tel: format). If Twilio requires a SIP URI, change transfer_to accordingly.
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity,
transfer_to=transfer_to_tel,
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
4) If you still see 403 after enabling TransferMode
• Capture the Twilio Trunking call log entry at the transfer timestamp and paste here (redact secrets). Twilio’s log will say why it rejected the REFER (blocked destination, auth, policy, etc.).
• Try a SIP-URI Refer-To as a test:
◦ transfer_to = "sip:+918956521667@{your-trunk}.<http://pstn.twilio.com|pstn.twilio.com>"
• Verify the trunk allows transfers to the destination country/number (some account restrictions exist).
• Confirm LiveKit is using the same Twilio trunk you updated (log sip_trunk_id right before transfer — the agent code above logs it).
5) Quick checklist for success
• [ ] POST to Twilio trunk with TransferMode=enable-all (and optionally TransferCallerId).
• [ ] Confirm trunk in Twilio Console shows Call Transfers enabled.
• [ ] Ensure LiveKit uses same trunk (pass trunk SID in metadata).
• [ ] Invoke transfer_call tool; check LiveKit & Twilio logs.
• [ ] If still failing, share Twilio log lines and I’ll decode the exact 403 reason.
If you want, I can now:
• Give the exact PHP snippet to set TransferMode during trunk creation (instead of update).
• Provide a fallback agent tool that tries tel: then sip: automatically and logs provider responses.
• Help parse Twilio trunking log lines if you paste them here.
Which would you like next?
Sources: Agents telephony integration | LiveKit Documentation | Handling payments with PCI compliance | LiveKit | Preserve caller ID during SIP transfers with Twilio | LiveKit | Outbound Calling Script | LiveKit Documentationwide-flower-24692
02/18/2026, 6:31 AMtall-airline-16756
02/18/2026, 6:32 AMTRANSFER_NUMBER — it must be a plain string like "+918956521667".
• Use the found sip_participant.identity (not self.participant.identity) when calling transfer_sip_participant.
• Keep your Twilio trunk update with `TransferMode=enable-all`; that enables SIP REFER and is required for the transfer to succeed.
Corrected transfer function (drop-in)
from livekit.agents import function_tool, get_job_context, RunContext
from livekit import api
from livekit.rtc import ParticipantKind
import logging
logger = logging.getLogger(__name__)
@function_tool()
async def transfer_call(self, ctx: RunContext = None):
"""
Cold-transfer the active SIP participant to the static TRANSFER_NUMBER.
"""
# Static transfer number — plain E.164 (no markdown)
TRANSFER_NUMBER = "+918956521667"
job_ctx = get_job_context()
if not job_ctx:
return "No active job context — cannot locate the call."
# find the SIP participant
sip_participant = None
for p in job_ctx.room.remote_participants.values():
if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:
sip_participant = p
break
if not sip_participant:
return "No active SIP participant found to transfer."
<http://logger.info|logger.info>(f"Transferring call to {TRANSFER_NUMBER}")
# Attempt transfer (tel: format). If Twilio requires SIP URI, change accordingly.
try:
await job_ctx.api.sip.transfer_sip_participant(
api.TransferSIPParticipantRequest(
room_name=job_ctx.room.name,
participant_identity=sip_participant.identity, # use found participant
transfer_to=f"tel:{TRANSFER_NUMBER}",
play_dialtone=True,
)
)
return f"Call is being transferred to {TRANSFER_NUMBER}."
except Exception as e:
logger.exception("transfer_sip_participant failed")
return f"Failed to transfer call: {e}"
Why these fixes matter
• If TRANSFER_NUMBER contains markdown (e.g., "+918..."), transfer_to will be malformed and Twilio may reject it.
• Using self.participant.identity may be None or reference a different participant; using the sip_participant you discovered ensures you transfer the correct call leg.
• TransferMode=enable-all on the Twilio trunk is required; since you added it in the domain update POST, Twilio should now accept SIP REFER for that trunk (confirm in Console).
After making these changes — test and check logs
1. Re-run an outbound call and invoke transfer_call.
2. Watch LiveKit logs and Twilio Trunking call logs at the transfer timestamp.
3. If you still get a 403:
◦ Verify the trunk SID that LiveKit used matches the Twilio trunk you updated.
◦ Inspect the Twilio trunking log entry for the exact rejection reason (paste it here if you’d like help interpreting it).
◦ Try a fallback SIP URI format if needed: <sip:+918956521667@your-trunk.pstn>.twilio.com.
If you want, I can:
• Provide a small snippet that logs the sip_trunk_id, participant.identity, and the transfer_to payload right before calling the transfer (helps debugging).
• Show a fallback version that tries tel: first and falls back to a sip: URI automatically. Which would you prefer?