early-butcher-66889
03/12/2026, 3:33 PMfrom __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",
)
)tall-airline-16756
03/12/2026, 3:34 PMtall-airline-16756
03/12/2026, 3:34 PMcreate_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