├── .env.example ├── .github └── assets │ └── livekit-mark.png ├── .gitignore ├── LICENSE ├── README.md ├── agent.py ├── renovate.json ├── requirements.txt └── taskfile.yaml /.env.example: -------------------------------------------------------------------------------- 1 | LIVEKIT_URL= 2 | LIVEKIT_API_KEY= 3 | LIVEKIT_API_SECRET= 4 | DEEPGRAM_API_KEY= 5 | CARTESIA_API_KEY= 6 | OPENAI_API_KEY= 7 | SIP_OUTBOUND_TRUNK_ID= -------------------------------------------------------------------------------- /.github/assets/livekit-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit-examples/outbound-caller-python/9c3106924fd36d7bd35eaef3afdd65830476e4da/.github/assets/livekit-mark.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env.local 2 | venv/ 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 LiveKit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | LiveKit logo 3 | 4 | 5 | # Python Outbound Call Agent 6 | 7 |

8 | LiveKit Agents Docs 9 | • 10 | LiveKit Cloud 11 | • 12 | Blog 13 |

14 | 15 | This example demonstrates an full workflow of an AI agent that makes outbound calls. It uses LiveKit SIP and Python [Agents Framework](https://github.com/livekit/agents). 16 | 17 | It can use a pipeline of STT, LLM, and TTS models, or a realtime speech-to-speech model. (such as ones from OpenAI and Gemini). 18 | 19 | This example builds on concepts from the [Outbound Calls](https://docs.livekit.io/agents/start/telephony/#outbound-calls) section of the docs. Ensure that a SIP outbound trunk is configured before proceeding. 20 | 21 | ## Features 22 | 23 | This example demonstrates the following features: 24 | 25 | - Making outbound calls 26 | - Detecting voicemail 27 | - Looking up availability via function calling 28 | - Transferring to a human operator 29 | - Detecting intent to end the call 30 | - Uses Krisp background voice cancellation to handle noisy environments 31 | 32 | ## Dev Setup 33 | 34 | Clone the repository and install dependencies to a virtual environment: 35 | 36 | ```shell 37 | git clone https://github.com/livekit-examples/outbound-caller-python.git 38 | cd outbound-caller-python 39 | python3 -m venv venv 40 | source venv/bin/activate 41 | pip install -r requirements.txt 42 | python agent.py download-files 43 | ``` 44 | 45 | Set up the environment by copying `.env.example` to `.env.local` and filling in the required values: 46 | 47 | - `LIVEKIT_URL` 48 | - `LIVEKIT_API_KEY` 49 | - `LIVEKIT_API_SECRET` 50 | - `OPENAI_API_KEY` 51 | - `SIP_OUTBOUND_TRUNK_ID` 52 | - `DEEPGRAM_API_KEY` - optional, only needed when using pipelined models 53 | - `CARTESIA_API_KEY` - optional, only needed when using pipelined models 54 | 55 | Run the agent: 56 | 57 | ```shell 58 | python3 agent.py dev 59 | ``` 60 | 61 | Now, your worker is running, and waiting for dispatches in order to make outbound calls. 62 | 63 | ### Making a call 64 | 65 | You can dispatch an agent to make a call by using the `lk` CLI: 66 | 67 | ```shell 68 | lk dispatch create \ 69 | --new-room \ 70 | --agent-name outbound-caller \ 71 | --metadata '{"phone_number": "+1234567890", "transfer_to": "+9876543210}' 72 | ``` 73 | -------------------------------------------------------------------------------- /agent.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from dotenv import load_dotenv 6 | import json 7 | import os 8 | from typing import Any 9 | 10 | from livekit import rtc, api 11 | from livekit.agents import ( 12 | AgentSession, 13 | Agent, 14 | JobContext, 15 | function_tool, 16 | RunContext, 17 | get_job_context, 18 | cli, 19 | WorkerOptions, 20 | RoomInputOptions, 21 | ) 22 | from livekit.plugins import ( 23 | deepgram, 24 | openai, 25 | cartesia, 26 | silero, 27 | noise_cancellation, # noqa: F401 28 | ) 29 | from livekit.plugins.turn_detector.english import EnglishModel 30 | 31 | 32 | # load environment variables, this is optional, only used for local development 33 | load_dotenv(dotenv_path=".env.local") 34 | logger = logging.getLogger("outbound-caller") 35 | logger.setLevel(logging.INFO) 36 | 37 | outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID") 38 | 39 | 40 | class OutboundCaller(Agent): 41 | def __init__( 42 | self, 43 | *, 44 | name: str, 45 | appointment_time: str, 46 | dial_info: dict[str, Any], 47 | ): 48 | super().__init__( 49 | instructions=f""" 50 | You are a scheduling assistant for a dental practice. Your interface with user will be voice. 51 | You will be on a call with a patient who has an upcoming appointment. Your goal is to confirm the appointment details. 52 | As a customer service representative, you will be polite and professional at all times. Allow user to end the conversation. 53 | 54 | When the user would like to be transferred to a human agent, first confirm with them. upon confirmation, use the transfer_call tool. 55 | The customer's name is {name}. His appointment is on {appointment_time}. 56 | """ 57 | ) 58 | # keep reference to the participant for transfers 59 | self.participant: rtc.RemoteParticipant | None = None 60 | 61 | self.dial_info = dial_info 62 | 63 | def set_participant(self, participant: rtc.RemoteParticipant): 64 | self.participant = participant 65 | 66 | async def hangup(self): 67 | """Helper function to hang up the call by deleting the room""" 68 | 69 | job_ctx = get_job_context() 70 | await job_ctx.api.room.delete_room( 71 | api.DeleteRoomRequest( 72 | room=job_ctx.room.name, 73 | ) 74 | ) 75 | 76 | @function_tool() 77 | async def transfer_call(self, ctx: RunContext): 78 | """Transfer the call to a human agent, called after confirming with the user""" 79 | 80 | transfer_to = self.dial_info["transfer_to"] 81 | if not transfer_to: 82 | return "cannot transfer call" 83 | 84 | logger.info(f"transferring call to {transfer_to}") 85 | 86 | # let the message play fully before transferring 87 | await ctx.session.generate_reply( 88 | instructions="let the user know you'll be transferring them" 89 | ) 90 | 91 | job_ctx = get_job_context() 92 | try: 93 | await job_ctx.api.sip.transfer_sip_participant( 94 | api.TransferSIPParticipantRequest( 95 | room_name=job_ctx.room.name, 96 | participant_identity=self.participant.identity, 97 | transfer_to=f"tel:{transfer_to}", 98 | ) 99 | ) 100 | 101 | logger.info(f"transferred call to {transfer_to}") 102 | except Exception as e: 103 | logger.error(f"error transferring call: {e}") 104 | await ctx.session.generate_reply( 105 | instructions="there was an error transferring the call." 106 | ) 107 | await self.hangup() 108 | 109 | @function_tool() 110 | async def end_call(self, ctx: RunContext): 111 | """Called when the user wants to end the call""" 112 | logger.info(f"ending the call for {self.participant.identity}") 113 | 114 | # let the agent finish speaking 115 | current_speech = ctx.session.current_speech 116 | if current_speech: 117 | await current_speech.wait_for_playout() 118 | 119 | await self.hangup() 120 | 121 | @function_tool() 122 | async def look_up_availability( 123 | self, 124 | ctx: RunContext, 125 | date: str, 126 | ): 127 | """Called when the user asks about alternative appointment availability 128 | 129 | Args: 130 | date: The date of the appointment to check availability for 131 | """ 132 | logger.info( 133 | f"looking up availability for {self.participant.identity} on {date}" 134 | ) 135 | await asyncio.sleep(3) 136 | return { 137 | "available_times": ["1pm", "2pm", "3pm"], 138 | } 139 | 140 | @function_tool() 141 | async def confirm_appointment( 142 | self, 143 | ctx: RunContext, 144 | date: str, 145 | time: str, 146 | ): 147 | """Called when the user confirms their appointment on a specific date. 148 | Use this tool only when they are certain about the date and time. 149 | 150 | Args: 151 | date: The date of the appointment 152 | time: The time of the appointment 153 | """ 154 | logger.info( 155 | f"confirming appointment for {self.participant.identity} on {date} at {time}" 156 | ) 157 | return "reservation confirmed" 158 | 159 | @function_tool() 160 | async def detected_answering_machine(self, ctx: RunContext): 161 | """Called when the call reaches voicemail. Use this tool AFTER you hear the voicemail greeting""" 162 | logger.info(f"detected answering machine for {self.participant.identity}") 163 | await self.hangup() 164 | 165 | 166 | async def entrypoint(ctx: JobContext): 167 | logger.info(f"connecting to room {ctx.room.name}") 168 | await ctx.connect() 169 | 170 | # when dispatching the agent, we'll pass it the approriate info to dial the user 171 | # dial_info is a dict with the following keys: 172 | # - phone_number: the phone number to dial 173 | # - transfer_to: the phone number to transfer the call to when requested 174 | dial_info = json.loads(ctx.job.metadata) 175 | participant_identity = phone_number = dial_info["phone_number"] 176 | 177 | # look up the user's phone number and appointment details 178 | agent = OutboundCaller( 179 | name="Jayden", 180 | appointment_time="next Tuesday at 3pm", 181 | dial_info=dial_info, 182 | ) 183 | 184 | # the following uses GPT-4o, Deepgram and Cartesia 185 | session = AgentSession( 186 | turn_detection=EnglishModel(), 187 | vad=silero.VAD.load(), 188 | stt=deepgram.STT(), 189 | # you can also use OpenAI's TTS with openai.TTS() 190 | tts=cartesia.TTS(), 191 | llm=openai.LLM(model="gpt-4o"), 192 | # you can also use a speech-to-speech model like OpenAI's Realtime API 193 | # llm=openai.realtime.RealtimeModel() 194 | ) 195 | 196 | # start the session first before dialing, to ensure that when the user picks up 197 | # the agent does not miss anything the user says 198 | session_started = asyncio.create_task( 199 | session.start( 200 | agent=agent, 201 | room=ctx.room, 202 | room_input_options=RoomInputOptions( 203 | # enable Krisp background voice and noise removal 204 | noise_cancellation=noise_cancellation.BVCTelephony(), 205 | ), 206 | ) 207 | ) 208 | 209 | # `create_sip_participant` starts dialing the user 210 | try: 211 | await ctx.api.sip.create_sip_participant( 212 | api.CreateSIPParticipantRequest( 213 | room_name=ctx.room.name, 214 | sip_trunk_id=outbound_trunk_id, 215 | sip_call_to=phone_number, 216 | participant_identity=participant_identity, 217 | # function blocks until user answers the call, or if the call fails 218 | wait_until_answered=True, 219 | ) 220 | ) 221 | 222 | # wait for the agent session start and participant join 223 | await session_started 224 | participant = await ctx.wait_for_participant(identity=participant_identity) 225 | logger.info(f"participant joined: {participant.identity}") 226 | 227 | agent.set_participant(participant) 228 | 229 | except api.TwirpError as e: 230 | logger.error( 231 | f"error creating SIP participant: {e.message}, " 232 | f"SIP status: {e.metadata.get('sip_status_code')} " 233 | f"{e.metadata.get('sip_status')}" 234 | ) 235 | ctx.shutdown() 236 | 237 | 238 | if __name__ == "__main__": 239 | cli.run_app( 240 | WorkerOptions( 241 | entrypoint_fnc=entrypoint, 242 | agent_name="outbound-caller", 243 | ) 244 | ) 245 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "packageRules": [ 5 | { 6 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 7 | "automerge": true 8 | } 9 | ], 10 | "ignoreDeps": ["pip", "setuptools", "wheel"], 11 | "dependencyDashboard": true 12 | } 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | livekit>=1.0 2 | livekit-agents[openai,deepgram,cartesia,silero,turn_detector]~=1.0 3 | livekit-plugins-noise-cancellation~=0.2 4 | python-dotenv~=1.0 5 | -------------------------------------------------------------------------------- /taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | output: interleaved 3 | dotenv: [".env.local"] 4 | 5 | tasks: 6 | post_create: 7 | desc: "Runs after this template is instantiated as a Sandbox or Bootstrap" 8 | cmds: 9 | - echo -e "To setup and run the agent:\r\n" 10 | - echo -e "\tcd {{.ROOT_DIR}}\r" 11 | - echo -e "\tpython3 -m venv venv\r" 12 | - platforms: [darwin, linux] 13 | cmd: echo -e "\tsource venv/bin/activate\r" 14 | - platforms: [windows] 15 | cmd: echo -e "\tpowershell venv/Scripts/Activate.ps1\r" 16 | - echo -e "\tpip install -r requirements.txt\r" 17 | - echo -e "\tpython3 agent.py download-files\r" 18 | - echo -e "\tpython3 agent.py dev\r\n" 19 | 20 | install: 21 | desc: "Bootstrap application for local development" 22 | cmds: 23 | - "python3 -m venv venv" 24 | - platforms: [darwin, linux] 25 | cmd: "source venv/bin/activate" 26 | - platforms: [windows] 27 | cmd: "powershell venv/Scripts/Activate.ps1" 28 | - "pip install -r requirements.txt" 29 | - "python3 agent.py download-files" 30 | 31 | dev: 32 | interactive: true 33 | cmds: 34 | - platforms: [darwin, linux] 35 | cmd: "source venv/bin/activate" 36 | - platforms: [windows] 37 | cmd: "powershell venv/Scripts/Activate.ps1" 38 | - "python3 agent.py dev" 39 | --------------------------------------------------------------------------------