I see that there was an example provided for outbo...
# ask-ai
e
I see that there was an example provided for outbound dialing - is the preferred way of handling agent outbound dialing that we start a WebRTC Room with an agent but then pass in the SIP participant details and have it dialed within the agent itself? This is different to the example given in the documents where it has a call to create a room with a dispatch agent + a follow up call to create a SIP participant.
Copy code
from __future__ import annotations

import asyncio
import logging
from dotenv import load_dotenv
import json
import os
from typing import Any

from livekit import rtc, api
from livekit.agents import (
    AgentSession,
    Agent,
    JobContext,
    function_tool,
    RunContext,
    get_job_context,
    cli,
    WorkerOptions,
    RoomInputOptions,
)
from livekit.plugins import (
    deepgram,
    openai,
    cartesia,
    silero,
    noise_cancellation,  # noqa: F401
)
from livekit.plugins.turn_detector.english import EnglishModel


# load environment variables, this is optional, only used for local development
load_dotenv(dotenv_path=".env.local")
logger = logging.getLogger("outbound-caller")
logger.setLevel(<http://logging.INFO|logging.INFO>)

outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID")


class OutboundCaller(Agent):
    def __init__(
        self,
        *,
        name: str,
        appointment_time: str,
        dial_info: dict[str, Any],
    ):
        super().__init__(
            instructions=f"""
            You are a scheduling assistant for a dental practice. Your interface with user will be voice.
            You will be on a call with a patient who has an upcoming appointment. Your goal is to confirm the appointment details.
            As a customer service representative, you will be polite and professional at all times. Allow user to end the conversation.

            When the user would like to be transferred to a human agent, first confirm with them. upon confirmation, use the transfer_call tool.
            The customer's name is {name}. His appointment is on {appointment_time}.
            """
        )
        # keep reference to the participant for transfers
        self.participant: rtc.RemoteParticipant | None = None

        self.dial_info = dial_info

    def set_participant(self, participant: rtc.RemoteParticipant):
        self.participant = participant

    async def hangup(self):
        """Helper function to hang up the call by deleting the room"""

        job_ctx = get_job_context()
        await job_ctx.api.room.delete_room(
            api.DeleteRoomRequest(
                room=job_ctx.room.name,
            )
        )

    @function_tool()
    async def transfer_call(self, ctx: RunContext):
        """Transfer the call to a human agent, called after confirming with the user"""

        transfer_to = self.dial_info["transfer_to"]
        if not transfer_to:
            return "cannot transfer call"

        <http://logger.info|logger.info>(f"transferring call to {transfer_to}")

        # let the message play fully before transferring
        await ctx.session.generate_reply(
            instructions="let the user know you'll be transferring them"
        )

        job_ctx = get_job_context()
        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_to}",
                )
            )

            <http://logger.info|logger.info>(f"transferred call to {transfer_to}")
        except Exception as e:
            logger.error(f"error transferring call: {e}")
            await ctx.session.generate_reply(
                instructions="there was an error transferring the call."
            )
            await self.hangup()

    @function_tool()
    async def end_call(self, ctx: RunContext):
        """Called when the user wants to end the call"""
        <http://logger.info|logger.info>(f"ending the call for {self.participant.identity}")

        # let the agent finish speaking
        current_speech = ctx.session.current_speech
        if current_speech:
            await current_speech.wait_for_playout()

        await self.hangup()

    @function_tool()
    async def look_up_availability(
        self,
        ctx: RunContext,
        date: str,
    ):
        """Called when the user asks about alternative appointment availability

        Args:
            date: The date of the appointment to check availability for
        """
        <http://logger.info|logger.info>(
            f"looking up availability for {self.participant.identity} on {date}"
        )
        await asyncio.sleep(3)
        return {
            "available_times": ["1pm", "2pm", "3pm"],
        }

    @function_tool()
    async def confirm_appointment(
        self,
        ctx: RunContext,
        date: str,
        time: str,
    ):
        """Called when the user confirms their appointment on a specific date.
        Use this tool only when they are certain about the date and time.

        Args:
            date: The date of the appointment
            time: The time of the appointment
        """
        <http://logger.info|logger.info>(
            f"confirming appointment for {self.participant.identity} on {date} at {time}"
        )
        return "reservation confirmed"

    @function_tool()
    async def detected_answering_machine(self, ctx: RunContext):
        """Called when the call reaches voicemail. Use this tool AFTER you hear the voicemail greeting"""
        <http://logger.info|logger.info>(f"detected answering machine for {self.participant.identity}")
        await self.hangup()


async def entrypoint(ctx: JobContext):
    <http://logger.info|logger.info>(f"connecting to room {ctx.room.name}")
    await ctx.connect()

    # when dispatching the agent, we'll pass it the approriate info to dial the user
    # dial_info is a dict with the following keys:
    # - phone_number: the phone number to dial
    # - transfer_to: the phone number to transfer the call to when requested
    dial_info = json.loads(ctx.job.metadata)
    participant_identity = phone_number = dial_info["phone_number"]

    # look up the user's phone number and appointment details
    agent = OutboundCaller(
        name="Jayden",
        appointment_time="next Tuesday at 3pm",
        dial_info=dial_info,
    )

    # the following uses GPT-4o, Deepgram and Cartesia
    session = AgentSession(
        turn_detection=EnglishModel(),
        vad=silero.VAD.load(),
        stt=deepgram.STT(),
        # you can also use OpenAI's TTS with openai.TTS()
        tts=cartesia.TTS(),
        llm=openai.LLM(model="gpt-4o"),
        # you can also use a speech-to-speech model like OpenAI's Realtime API
        # llm=openai.realtime.RealtimeModel()
    )

    # start the session first before dialing, to ensure that when the user picks up
    # the agent does not miss anything the user says
    session_started = asyncio.create_task(
        session.start(
            agent=agent,
            room=ctx.room,
            room_input_options=RoomInputOptions(
                # enable Krisp background voice and noise removal
                noise_cancellation=noise_cancellation.BVCTelephony(),
            ),
        )
    )

    # `create_sip_participant` starts dialing the user
    try:
        await ctx.api.sip.create_sip_participant(
            api.CreateSIPParticipantRequest(
                room_name=ctx.room.name,
                sip_trunk_id=outbound_trunk_id,
                sip_call_to=phone_number,
                participant_identity=participant_identity,
                # function blocks until user answers the call, or if the call fails
                wait_until_answered=True,
            )
        )

        # wait for the agent session start and participant join
        await session_started
        participant = await ctx.wait_for_participant(identity=participant_identity)
        <http://logger.info|logger.info>(f"participant joined: {participant.identity}")

        agent.set_participant(participant)

    except api.TwirpError as e:
        logger.error(
            f"error creating SIP participant: {e.message}, "
            f"SIP status: {e.metadata.get('sip_status_code')} "
            f"{e.metadata.get('sip_status')}"
        )
        ctx.shutdown()


if __name__ == "__main__":
    cli.run_app(
        WorkerOptions(
            entrypoint_fnc=entrypoint,
            agent_name="outbound-caller",
        )
    )
t
Thanks for reaching out, Arthur. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — this is a great question and your code shows the pattern clearly. Short answer: Yes. Starting the agent in a room and then having the agent initiate the outbound SIP call with
create_sip_participant
(like your example) is the preferred pattern for agent-driven outbound dialing. It gives the agent full control of the call lifecycle, real-time audio handling, transfers, and error handling. Why this is preferred (and how your code already follows best practices) • Agent control: The agent can decide when to dial, manage retries, detect voicemail/answering machines, and call transfers using
transfer_sip_participant
. • No missed audio: You correctly start the AgentSession before dialing (
session.start
as an async task). That ensures the agent’s audio stack (STT, VAD, TTS) is ready as soon as the callee answers so nothing is missed. • Lifecycle control: The agent can terminate the call cleanly (e.g.,
delete_room
in your
hangup
) and handle failures from
create_sip_participant
(you already catch
TwirpError
and check SIP metadata). • Rich interaction: Using function tools inside the agent (e.g.,
transfer_call
,
detected_answering_machine
,
end_call
) lets the agent perform contextual actions based on the live conversation. When the utility-script pattern (dispatch + external SIP call) makes sense • Use it when dialing is purely orchestration/batch work and the agent does not need to control the dialing logic or immediate in-call decisions. • It’s simpler for systems that just need to spin up calls and don’t require low-latency agent-side processing, answering-machine detection, or live transfers. Practical tips and considerations (from your code and patterns) • Start the agent session before creating the SIP participant (you already do this). This avoids losing early audio from the callee. • Handle
TwirpError
and check
sip_status_code
/
sip_status
in metadata to detect no-answer, busy, or failed calls. • For transfer and hangup flows, keep a reference to the SIP participant identity (you do this with
self.participant
) so you can call
transfer_sip_participant
and later delete the room. • If you need noise removal, enable it in
RoomInputOptions
(you're already using
noise_cancellation.BVCTelephony()
). • If you need specific caller ID behavior, set the appropriate fields on
CreateSIPParticipantRequest
and verify your SIP trunk/provider allows it. Conclusion Your current approach — dispatch agent to a room, start the AgentSession, then call
ctx.api.sip.create_sip_participant(...)
from within the agent — is the correct and recommended approach for interactive outbound agents. The utility-script pattern is valid too but is best suited to simpler, non-interactive dialing workflows. If you'd like, I can: • Review any part of your code for edge cases (e.g., retries, timeouts, or transfer error handling). • Suggest concrete error-handling snippets for common SIP statuses. • Walk through a transfer or voicemail-detection flow in more detail. Which would be most helpful? Sources: SIP Lifecycle Management Agent | LiveKit Documentation | Outbound Calling Script | LiveKit Documentation | Agents (Python) | livekit/livekit_composite | DeepWiki | Agents telephony integration | LiveKit Documentation | Preserving Caller ID During SIP Transfers in LiveKit Agents