in outbound call i want tool function for transfer...
# ask-ai
w
in outbound call i want tool function for transfer call how i do that
t
Thanks for reaching out, Sujata. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I understand you want a tool function to transfer an outbound call. Below I’ll show a concise, ready-to-use approach you can add to your agent that performs a cold transfer (SIP REFER) and point out the warm-transfer alternative. Which transfer type do you want? • Cold transfer (immediately hand off the call and disconnect the agent) — use SIP REFER via
transfer_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:
Copy code
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 Documentation
w
for now add static number of transfer number
t
Great — since you want a static transfer number, below is a concise example you can drop into your agent. This version hard-codes the destination number (E.164 format) and performs a cold transfer using
transfer_sip_participant
.
Copy code
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?
w
in function tool add that number static in variable
t
Thanks — I’ve added the static transfer number as a class-level variable and placed it inside the
@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.
Copy code
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?
w
in tool function add static number means in that function in variable add static number
t
Thanks — I understand you want the static transfer number defined as a local variable inside the
@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.
Copy code
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?
w
@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. """ 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?
t
Thanks — I reviewed your snippet and you’re very close. I’ll point out the small fixes and provide a corrected version you can drop in. I’ll also call out one runtime behavior to expect. Main fixes and suggestions • The TRANSFER_NUMBER should be a plain string like "+15105550123" (not markdown/link format). •
TRANSFER_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
Copy code
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?
w
Feb 18 054121 ip-172-31-23-35.ec2.internal python3[1677545]: {"message": "exception occurred while executing tool\nTraceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/agents/voice/generation.py\", line 597, in _traceable_fnc_tool\n val = await function_callable()\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/agent.py\", line 1021, in transfer_call\n if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:\n ^^^^^^^^^^^^^^^\nNameError: name 'ParticipantKind' is not defined", "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/voice/generation.py\", line 597, in _traceable_fnc_tool\n val = await function_callable()\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/agent.py\", line 1021, in transfer_call\n if p.kind == ParticipantKind.PARTICIPANT_KIND_SIP:\n ^^^^^^^^^^^^^^^\nNameError: name 'ParticipantKind' is not defined", "function": "transfer_call", "speech_id": "speech_fc31424c8a91", "pid": 1677559, "job_id": "AJ_Um8k5tdfo6RQ", "room_id": "RM_VZ3ZeY6EAuJM", "timestamp": "2026-02-18T054121.707916+00:00"}
t
Thanks — that error message makes the problem clear. The NameError means
ParticipantKind
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.
Copy code
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.
w
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"} this show
t
Thanks — I see two issues in your logs and what to do next. 1) What the logs show • Your function successfully started the transfer and logged: "Transferring call to +918956521667". • Shortly after, the agent runtime raised a RealtimeError:
generate_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.
Copy code
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.
w
@function_tool() async def transfer_call(self, ctx: RunContext): """ Cold-transfer the active SIP participant to
TRANSFER_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"}
t
Thanks — you’re very close. Two things to fix so this runs reliably: 1) Use a plain string for the static number (not markdown/link text). 2) Protect the realtime
generate_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.
Copy code
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?
w
Feb 18 055029 ip-172-31-23-35.ec2.internal python3[1678009]: {"message": "Transferring call to +918956521667", "level": "INFO", "name": "outbound-caller", "pid": 1678022, "job_id": "AJ_wGgUmZVwmA2g", "room_id": "RM_ykHXTwfJDhmJ", "timestamp": "2026-02-18T055029.320794+00:00"} Feb 18 055032 ip-172-31-23-35.ec2.internal python3[1678009]: {"message": "generate_reply timed out; proceeding with transfer", "level": "WARNING", "name": "outbound-caller", "pid": 1678022, "job_id": "AJ_wGgUmZVwmA2g", "room_id": "RM_ykHXTwfJDhmJ", "timestamp": "2026-02-18T055032.323077+00:00"} Feb 18 055032 ip-172-31-23-35.ec2.internal python3[1678009]: {"message": "transfer_sip_participant failed\nTraceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/agent.py\", line 1047, in transfer_call\n await job_ctx.api.sip.transfer_sip_participant(\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/sip_service.py\", line 815, in transfer_sip_participant\n return await self._client.request(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/twirp_client.py\", line 129, in request\n raise TwirpError(\nlivekit.api.twirp_client.TwirpError: TwirpError(code=permission_denied, message=twirp error unknown: sip status: 403: Forbidden, status=403, metadata={'error_details': 'CAcSGnNpcCBzdGF0dXM6IDQwMzogRm9yYmlkZGVuGjcKJXR5cGUuZ29vZ2xlYXBpcy5jb20vbGl2ZWtpdC5TSVBTdGF0dXMSDgiTAxIJRm9yYmlkZGVu', 'sip_status': 'Forbidden', 'sip_status_code': '403'})", "level": "ERROR", "name": "outbound-caller", "exc_info": "Traceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/agent.py\", line 1047, in transfer_call\n await job_ctx.api.sip.transfer_sip_participant(\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/sip_service.py\", line 815, in transfer_sip_participant\n return await self._client.request(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/twirp_client.py\", line 129, in request\n raise TwirpError(\nlivekit.api.twirp_client.TwirpError: TwirpError(code=permission_denied, message=twirp error unknown: sip status: 403: Forbidden, status=403, metadata={'error_details': 'CAcSGnNpcCBzdGF0dXM6IDQwMzogRm9yYmlkZGVuGjcKJXR5cGUuZ29vZ2xlYXBpcy5jb20vbGl2ZWtpdC5TSVBTdGF0dXMSDgiTAxIJRm9yYmlkZGVu', 'sip_status': 'Forbidden', 'sip_status_code': '403'})", "pid": 1678022, "job_id": "AJ_wGgUmZVwmA2g", "room_id": "RM_ykHXTwfJDhmJ", "timestamp": "2026-02-18T055032.393385+00:00"} Feb 18 055034 ip-172-31-23-35.ec2.internal python3[1678009]: {"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": 1678022, "job_id": "AJ_wGgUmZVwmA2g", "room_id": "RM_ykHXTwfJDhmJ", "timestamp": "2026-02-18T055034.325804+00:00"} now show this error for generate reply or transffer call not work
t
Thanks — this shows two separate failures happening during the same transfer attempt. I’ll walk through what each error means and give clear, actionable steps and a safe code snippet so transfers proceed even when realtime TTS fails. What your logs show • The in-call notify step timed out: ◦ "generate_reply timed out; proceeding with transfer" — your code hit the realtime generation timeout (we wrapped it), so the TTS reply didn’t complete. • The SIP transfer itself failed with a 403 Forbidden: ◦ TwirpError code=permission_denied, metadata includes
sip_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.
Copy code
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?
w
sip_trunk_id = metadata.get("sip_trunk_id") logging.info(f"📞 Initiating SIP call to {phone_number}") await ctx.api.sip.create_sip_participant( api.CreateSIPParticipantRequest( room_name=ctx.room.name, sip_trunk_id=sip_trunk_id, sip_call_to=phone_number, participant_identity=participant_identity, participant_name="Outbound Call", play_dialtone=True, wait_until_answered=True ) ) # await session.generate_reply( # instructions="You must begin the conversation immediately and deliver the full first line of the script. Do not wait for the user to speak." # ) participant = await ctx.wait_for_participant(identity=participant_identity) logger.info(f"Participant joined: {participant.identity}") agent.set_participant(participant) this is how i trigger outbound call then in transffer call why get SIP transfer 403 Forbidden this errro
t
Thanks — this is helpful. You’re creating the outbound call with
create_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
Copy code
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?
w
Feb 18 060101 ip-172-31-23-35.ec2.internal python3[1678365]: {"message": "Transferring call to +918956521667", "level": "INFO", "name": "outbound-caller", "pid": 1678378, "job_id": "AJ_Ep56wxPGHujv", "room_id": "RM_52Q99iwPTdFx", "timestamp": "2026-02-18T060101.975653+00:00"} Feb 18 060102 ip-172-31-23-35.ec2.internal python3[1678365]: {"message": "transfer_sip_participant failed\nTraceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/agent.py\", line 1035, in transfer_call\n await job_ctx.api.sip.transfer_sip_participant(\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/sip_service.py\", line 815, in transfer_sip_participant\n return await self._client.request(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/twirp_client.py\", line 129, in request\n raise TwirpError(\nlivekit.api.twirp_client.TwirpError: TwirpError(code=permission_denied, message=twirp error unknown: sip status: 403: Forbidden, status=403, metadata={'error_details': 'CAcSGnNpcCBzdGF0dXM6IDQwMzogRm9yYmlkZGVuGjcKJXR5cGUuZ29vZ2xlYXBpcy5jb20vbGl2ZWtpdC5TSVBTdGF0dXMSDgiTAxIJRm9yYmlkZGVu', 'sip_status': 'Forbidden', 'sip_status_code': '403'})", "level": "ERROR", "name": "outbound-caller", "exc_info": "Traceback (most recent call last):\n File \"/home/ec2-user/livekit-agent/agent.py\", line 1035, in transfer_call\n await job_ctx.api.sip.transfer_sip_participant(\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/sip_service.py\", line 815, in transfer_sip_participant\n return await self._client.request(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/home/ec2-user/livekit-agent/venv/lib64/python3.11/site-packages/livekit/api/twirp_client.py\", line 129, in request\n raise TwirpError(\nlivekit.api.twirp_client.TwirpError: TwirpError(code=permission_denied, message=twirp error unknown: sip status: 403: Forbidden, status=403, metadata={'error_details': 'CAcSGnNpcCBzdGF0dXM6IDQwMzogRm9yYmlkZGVuGjcKJXR5cGUuZ29vZ2xlYXBpcy5jb20vbGl2ZWtpdC5TSVBTdGF0dXMSDgiTAxIJRm9yYmlkZGVu', 'sip_status': 'Forbidden', 'sip_status_code': '403'})", "pid": 1678378, "job_id": "AJ_Ep56wxPGHujv", "room_id": "RM_52Q99iwPTdFx", "timestamp": "2026-02-18T060102.039637+00:00"} this error show
t
Thanks — I see the latest logs and what’s happening. Two things are happening during your transfer attempt: • Your function logs the transfer attempt: "Transferring call to +918956521667". • The SIP transfer call fails with a TwirpError showing SIP status 403 Forbidden — this means the transfer request reached the SIP layer but was rejected (permission/authorization issue). The generate_reply timeouts you saw earlier are separate and we've already mitigated them by wrapping the call. What the 403 means (short) • A 403 response during
transfer_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):
Copy code
# 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 Documentation
w
$ts = TwilioSetting::where('user_id', $userId)->first(); if ($ts) { $accountSid = $ts->account_sid; $authToken = $ts->auth_token; } else { // Fallback to environment variables $accountSid = env('TWILLIO_ACCOUNT_SID'); $authToken = env('TWILLIO_ACCOUNT_SECRET'); } $phoneSid = $purchase->sid; $trunkName = 'MyTrunk_' . uniqid(); $domainName = strtolower(Str::random(8)) . '.pstn.twilio.com'; $credentialListName = 'AuthList_' . uniqid(); $username = Str::random(8); $password = $this->generateSecurePassword(); $authHeader = 'Authorization: Basic ' . base64_encode("$accountSid:$authToken"); try { // 1. Create Trunk $response1 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks", [ 'FriendlyName' => $trunkName, ]); if (!$response1->successful()) { throw new Exception("Create Trunk failed!"); } $trunkSid = $response1['sid']; // 2. Set Domain Name $response2 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks/$trunkSid", [ 'DomainName' => $domainName, ]); if (!$response2->successful()) throw new Exception("Add SIP URL in trunk failed!"); // 3. Enable Recording $responseRecording = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks/$trunkSid/Recording", [ 'Mode' => 'record-from-answer' ]); if (!$responseRecording->successful()) throw new Exception("Enable recording failed!"); // 4. Create Credential List $response3 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://api.twilio.com/2010-04-01/Accounts/$accountSid/SIP/CredentialLists.json", [ 'FriendlyName' => $credentialListName, ]); if (!$response3->successful()) throw new Exception("Create CredentialList failed!"); $credListSid = $response3['sid']; // 5. Add Credential $response4 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://api.twilio.com/2010-04-01/Accounts/$accountSid/SIP/CredentialLists/$credListSid/Credentials.json", [ 'Username' => $username, 'Password' => $password, ]); if (!$response4->successful()) throw new Exception("Add Credential failed!"); // 6. Attach Credential List to Trunk $response5 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks/$trunkSid/CredentialLists", [ 'CredentialListSid' => $credListSid, ]); if (!$response5->successful()) throw new Exception("Attach CredentialList failed!"); // 7. Attach Phone Number to Trunk $response6 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks/$trunkSid/PhoneNumbers", [ 'PhoneNumberSid' => $phoneSid, ]); if (!$response6->successful()) { throw new Exception("Attach Phone Number failed!"); } TwilioTrunks::updateOrCreate( ['twilio_number' => $number], [ 'user_id' => $userId, 'sip_url' => $domainName, 'auth_username' => $username, 'auth_password' => $password, 'twilio_trunk_id' => $trunkSid, ] ); this is how i create sip trunk from twilio
t
Thanks — this latest log shows the transfer attempt reached the SIP layer but was rejected with SIP 403 Forbidden. That confirms the problem is a SIP/trunk configuration or permission issue rather than your agent code. What the log means (short) • Your code logged the transfer attempt. • LiveKit called the SIP transfer API, but the SIP endpoint returned a 403 Forbidden. • A 403 during a REFER transfer means the trunk or provider refused the transfer (most commonly because SIP REFER / PSTN transfer is not enabled or allowed). Exact checks and fixes (do these in order) 1) Ensure Transfer is enabled on the Twilio trunk you created • When you create a Twilio trunk via API, you must enable transfer support. Add/confirm TransferMode or the Transfers feature in the trunk settings. • Example API update you can call after creating the trunk: ◦ POST to https://trunking.twilio.com/v1/Trunks/{TrunkSid} with TransferMode set (e.g., "enable-all" or appropriate value). • In the Twilio Console: Trunk → Features → Transfers — enable PSTN Transfer / SIP Refer. 2) Confirm the trunk you enabled is the same trunk LiveKit is using • Log the trunk SID (or sip_trunk_id) you pass to create_sip_participant and the one LiveKit uses at transfer time. • If they differ, LiveKit’s transfer request may route through a trunk that disallows REFER. 3) Try SIP URI vs tel: format (quick test) • Some providers require a SIP URI format for REFER. Try both: ◦ tel:+918956521667 ◦ sip:+918956521667@your-provider-domain • Log the exact transfer_to you send and the response. 4) Check provider/trunk logs for the 403 reason • Twilio or your SIP provider’s logs will have the precise rejection reason (blocked destination, auth failure, policy). • Share those provider log entries (redact secrets) and I’ll interpret them. 5) Verify caller ID/credentials and destination permission • Some trunks block transfers to certain countries or numbers unless allowed. • Ensure your trunk/account is authorized to place calls or transfers to the destination number and that the caller ID or credentials match allowed values. 6) LiveKit API / key permissions • If you use scoped API keys or role restrictions, make sure the key used by your agent can call SIP transfer APIs. Quick code-side safety (you already have this) • Keep the guarded generate_reply (or remove it) so realtime timeouts won’t block the transfer. • Continue to log the transfer payload before calling transfer_sip_participant: ◦ room name, participant identity, sip_trunk_id, transfer_to string. If you want exact API commands, here are concrete steps you can run for Twilio (examples you can adapt in PHP): • Verify trunk detail (replace {TrunkSid}): GET https://trunking.twilio.com/v1/Trunks/{TrunkSid} • Update trunk to enable transfers: POST https://trunking.twilio.com/v1/Trunks/{TrunkSid} Form fields: TransferMode=enable-all If you’d like, I can: • Provide the exact PHP HTTP snippet to set TransferMode after trunk creation. • Help decode Twilio logs if you paste the provider-side 403 lines. • Show a variant of your agent function that tries both tel: and sip: automatically and logs the provider response. Which would you like next — the PHP snippet to enable TransferMode, help reading Twilio logs, or the dual-format transfer test function? Sources: Handling payments with PCI compliance | LiveKit | Agents telephony integration | LiveKit Documentation | Company directory phone assistant | LiveKit Documentation | Preserve caller ID during SIP transfers with Twilio | LiveKit
w
If you want exact API commands, here are concrete steps you can run for Twilio (examples you can adapt in PHP): • Verify trunk detail (replace {TrunkSid}): GET https://trunking.twilio.com/v1/Trunks/{TrunkSid} • Update trunk to enable transfers: POST https://trunking.twilio.com/v1/Trunks/{TrunkSid} Form fields: TransferMode=enable-all when i do this then transfercall works?
t
Short answer: Most likely — yes. Enabling TransferMode (allowing SIP REFER / PSTN transfer) on the Twilio trunk is the common and necessary fix for the SIP 403 Forbidden you saw. Once TransferMode is enabled on the same trunk LiveKit is using, the SIP REFER request from LiveKit should no longer be rejected — provided the trunk/provider and account are otherwise permitted to transfer to that destination. What enabling TransferMode fixes • The 403 you received means the SIP REFER was rejected by the trunk/provider. • Twilio (and many providers) block REFER/PSTN transfer by default. Setting TransferMode=enable-all (or enabling Transfers in the console) allows REFER requests to succeed. Other requirements you must verify (so the transfer actually works after enabling TransferMode) • Make sure you enable TransferMode on the exact trunk SID LiveKit uses for the call (same trunk created/attached to the number). • Confirm your Twilio account/trunk is allowed to transfer to the destination number (some carriers restrict countries/ranges). • If your provider expects SIP URIs for transfers, test both formats: ◦ PSTN:
tel:+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?
w
// 2. Set Domain Name $response2 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks/$trunkSid", [ 'DomainName' => $domainName, ]); in this only add
TransferMode=enable-all then it works?
t
Short answer: Yes — adding
TransferMode=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)
Copy code
$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?
w
Call Transfer via SIP REFER Call transfer enables you to move an active call from one endpoint to another. Twilio's Elastic SIP Trunking product supports "blind" call transfers. This means you're now able to request a call be transferred by sending Twilio a SIP REFER message from your SIP communications infrastructure. Twilio will serve as the pivot-point and handle the call redirect thereby allowing you to free up resources in your IP communications infrastructure that are no longer needed. Call transfers are enabled on a per-Trunk basis, this enables transfers to any SIP destination. Optionally, you may also enable call Transfers to the PSTN. To start transferring calls, you can configure call transfers using Twilio Console or API. For Console, log into Twilio Console , and navigate to an existing Elastic SIP Trunk (or create a new Elastic SIP Trunk): 1. Enable Call Transfers on that Trunk under General Settings 2. Optionally enable call transfers to the PSTN via your Trunk. This is optional as you may not want to allow calls to be transferred to the PSTN given the associated billing implications of such a transfer. Place a call via your Elastic SIP Trunk; once established, initiate a call transfer from your phone. Twilio will receive that request & transfer your call. Check your Twilio Call Log in the Console to confirm the transfer worked as requested and to see all relevant call details. Call Transfer call flow Call Transfer to another SIP endpoint Let's start with an active Elastic SIP Trunking Call established from your PBX/SBC via Twilio to the PSTN. The players are: • Transferor (your PBX/SBC) - The party initiating the transfer of the Transferee to the Transfer-target. • Transferee (Twilio/PSTN) - The party being transferred to the Transfer-target. • Transfer-target (your PBX/SBC) - The new party being introduced to the Transferee. Everything kicks off with the SIP REFER message from your PBX/SBC towards Twilio. Within the SIP REFER is a Refer-to header (designating a new SIP endpoint as the Transfer-target). Upon receiving the SIP REFER, Twilio returns a 202 Accepted response to your PBX/SBC. This informs you that Twilio is willing to carry out the transfer. The original call is placed on hold (not shown in the call flow). The SIP REFER creates a quasi-subscription between the Transferor (your PBX/SBC) and Twilio. Even though there wasn't a SIP SUBSCRIBE message sent, for the duration of the transfer, Twilio will act as if such a subscription exists. Twilio sends a SIP INVITE to the new SIP endpoint which processes the SIP INVITE as a normal, incoming call. At this time, Twilio will send SIP NOTIFY messages to inform the Transferor of the status (100 Trying, 200
OK
) 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 doc
t
Thanks — Twilio’s doc confirms the behavior: call transfers (SIP REFER) are enabled per Trunk and must be turned on for transfers (and optionally PSTN transfers). Based on everything you’ve shared, the missing piece is enabling transfers on the exact Trunk LiveKit is using. Do this in your trunk update call: include TransferMode (and optionally Caller ID behavior). Below is a clear PHP example you can add to your existing trunk update step (the same request where you set DomainName). This will enable SIP REFER transfers and set the caller ID policy for transfer targets. PHP snippet to update trunk (add to your existing update request)
Copy code
$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?
w
give me correct what i write in api then give me correct tool function of transfer call
t
Thanks — below are two concise, ready-to-use pieces you can drop into your project: 1) PHP API call to enable transfers on the Twilio trunk (add this to your trunk-creation/update flow). 2) A robust LiveKit @function_tool transfer function (Python) that uses a static number, logs important fields, avoids blocking on realtime TTS, and handles errors. PHP: enable TransferMode when creating/updating trunk • Add the TransferMode and TransferCallerId fields when you POST the trunk update. This example updates the trunk after creating it (you can add these fields in your original trunk creation step too).
Copy code
// 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.
Copy code
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?
w
$trunkSid i use this in livekit async def create_sip_trunk_from_db(trunk_data, number): lk_api = api.LiveKitAPI( api_key=LIVEKIT_API_KEY, api_secret=LIVEKIT_API_SECRET, url=LIVEKIT_URL, ) trunk = api.SIPOutboundTrunkInfo( name=f"callerbot-{uuid.uuid4().hex[:6]}", address=trunk_data["sip_url"], numbers=[number], auth_username=trunk_data["auth_username"], auth_password=trunk_data["auth_password"] ) request = api.CreateSIPOutboundTrunkRequest(trunk=trunk) created_trunk = await lk_api.sip.create_sip_outbound_trunk(request) await lk_api.aclose() return created_trunk do this using that trunk and pass to agent
t
Thanks — I’ve put together exactly what you need: a small, clear flow that (1) uses the trunk you create from the DB, (2) passes that trunk id into the agent, and (3) gives a correct, robust
@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):
Copy code
// 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:
Copy code
# 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).
Copy code
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 Documentation
w
// 2. Set Domain Name $response2 = Http::withBasicAuth($accountSid, $authToken) ->asForm()->post("https://trunking.twilio.com/v1/Trunks/$trunkSid", [ 'DomainName' => $domainName, 'TransferMode' => 'enable-all', ]); i do this and @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" # transfer_to = self.dial_info.get("transfer_to") 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}") try: await job_ctx.api.sip.transfer_sip_participant( api.TransferSIPParticipantRequest( room_name=job_ctx.room.name, participant_identity=self.participant.identity, 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}" here need to change anything or its working?
t
Thanks — you’re almost there. Two small fixes are needed in your Python tool plus one important reminder about the Twilio change you already made. Summary of required changes • Remove the markdown/link formatting from
TRANSFER_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)
Copy code
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?