diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b40c07e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(python -c \"from livekit import api; print\\([x for x in dir\\(api\\) if ''ispatch'' in x.lower\\(\\) or ''AgentDispatch'' in x]\\)\")", + "Bash(python -c \"from livekit.api import LiveKitAPI; import inspect; lk = LiveKitAPI.__init__; print\\(inspect.signature\\(lk\\)\\)\")", + "Bash(python -c \"from livekit import api; print\\([m for m in dir\\(api.LiveKitAPI\\) if not m.startswith\\(''_''\\)]\\)\")", + "Bash(python -c \"from livekit import api; import inspect; print\\(inspect.signature\\(api.CreateAgentDispatchRequest.__init__\\)\\)\")", + "Bash(python -c \"from livekit import api; print\\([m for m in dir\\(api.agent_dispatch_service.AgentDispatchService\\) if not m.startswith\\(''_''\\)]\\)\")", + "Bash(python -m pip install pandas openpyxl)", + "Bash(python dispatch_from_excel.py leads_sample.csv --dry-run)", + "Bash(python -c \"import ast; ast.parse\\(open\\(''agent.py'', encoding=''utf-8''\\).read\\(\\)\\); print\\(''agent.py: syntax OK''\\)\")" + ] + } +} diff --git a/.env.example b/.env.example index 422230f..2a2073c 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,26 @@ -LIVEKIT_URL= -LIVEKIT_API_KEY= -LIVEKIT_API_SECRET= -DEEPGRAM_API_KEY= -CARTESIA_API_KEY= -OPENAI_API_KEY= -SIP_OUTBOUND_TRUNK_ID= \ No newline at end of file +# LiveKit Configuration +# Get these from https://cloud.livekit.io/ > Your Project > Settings +LIVEKIT_URL=wss://your-project.livekit.cloud +LIVEKIT_API_KEY=your_api_key_here +LIVEKIT_API_SECRET=your_api_secret_here +LIVEKIT_SIP_URI=sip:your-sip-uri.sip.livekit.cloud + +# OpenAI API Key (Required for Realtime API) +# Get from https://platform.openai.com/api-keys +OPENAI_API_KEY=sk-proj-your_openai_key_here + +# SIP Outbound Trunk ID +# Get from LiveKit dashboard after creating SIP trunk +SIP_OUTBOUND_TRUNK_ID=ST_your_trunk_id_here + +# Optional: Alternative STT/TTS Providers +# Only needed if using pipelined approach instead of Realtime API +DEEPGRAM_API_KEY=your_deepgram_key_here +CARTESIA_API_KEY=your_cartesia_key_here + +# Twilio Configuration (For trunk setup only) +# Get from https://console.twilio.com/ +TWILIO_ACCOUNT_SID=your_twilio_account_sid_here +TWILIO_AUTH_TOKEN=your_twilio_auth_token_here +TWILIO_PHONE_NUMBER=+1234567890 +TWILIO_TO_NUMBER=+0987654321 \ No newline at end of file diff --git a/.github/assets/livekit-mark.png b/.github/assets/livekit-mark.png deleted file mode 100644 index e984d81..0000000 Binary files a/.github/assets/livekit-mark.png and /dev/null differ diff --git a/.gitignore b/.gitignore index 9edc8ce..b1a3036 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,21 @@ .env.local venv/ .DS_Store +test.py +twilio.env + +# LiveKit CLI binaries and downloads +lk +lk.tar.gz +autocomplete/ + +# Python cache +__pycache__/ + +# SIP trunk configuration (contains credentials) +outbound_trunk.json +inbound_trunk.json +dispatch_rule.json + +# Voice testing sandbox app (generated by lk CLI) +voice-test/ diff --git a/CUSTOMIZATION_GUIDE.md b/CUSTOMIZATION_GUIDE.md new file mode 100644 index 0000000..b095ed5 --- /dev/null +++ b/CUSTOMIZATION_GUIDE.md @@ -0,0 +1,175 @@ +# Agent Customization Guide + +## Quick Reference: Where to Make Changes + +### 1. Change Voice (agent.py Line 342) + +```python +tts=cartesia.TTS(voice="YOUR_VOICE_ID_HERE"), +``` + +**Popular Cartesia Voice IDs:** + +**Male Voices:** + +- `79f8b5fb-2cc8-479a-80df-29f7a7cf1a3e` - British Narration Man (professional, authoritative) +- `694f9389-aac1-45b6-b726-9d9369183238` - Friendly Guy (warm, conversational, American) +- `a0e99841-438c-4a64-b679-ae501e7d6091` - Confident British Man +- `63ff761f-c1e8-414b-b969-d1833d1c870c` - Pilot (calm, clear) + +**Female Voices:** + +- `95856005-0332-41b0-935f-352e296aa0df` - Professional Woman +- `638efaaa-4d0c-442e-b701-3fae16a90097` - Calm Woman +- `421b3369-f63f-4b03-8980-37a44df1d4e8` - News Lady (clear, professional) +- `f9836c6e-a0bd-460e-9d3c-f7299fa60f94` - Friendly Reading Lady + +**To find more voices:** +Visit and click on voices to get their IDs. + +--- + +### 2. Change Personality/Instructions (agent.py Lines 106-125) + +This section controls everything the agent says and how it behaves. + +#### Example: Simple friendly caller + +```python +instructions=f""" +You are a friendly and helpful assistant calling to check in with clients. +Your interface with users will be voice. + +Keep your responses brief and natural. Speak like a real person, not a robot. +Be warm and personable. The client's name is {name}. + +Your goal is to have a pleasant conversation and answer any questions they may have. +""" +``` + +#### Example: Professional appointment reminder + +```python +instructions=f""" +You are calling to confirm an appointment. +Your interface with users will be voice. + +You are professional, efficient, and friendly. +Keep the call brief - your goal is to: +1. Greet the client by name ({name}) +2. Confirm they have an appointment at {appointment_time} +3. Ask if they can still make it +4. Thank them and end the call + +Be polite but don't waste their time with unnecessary chit-chat. +""" +``` + +--- + +### 3. Change What Agent Says First + +The agent automatically starts speaking based on its instructions. To control the opening: + +**Add an initial greeting to the instructions:** + +```python +instructions=f""" +You are a health insurance specialist calling clients. + +IMPORTANT: When the call connects, immediately greet them by saying: +"Hi {name}, this is [Your Company] calling about your health insurance options. Do you have a minute to chat?" + +Then wait for their response before continuing. +... +""" +``` + +--- + +### 4. Adjust Turn Detection Sensitivity (agent.py Line 339) + +If the agent interrupts too much or waits too long: + +```python +# More sensitive (agent speaks faster after user pauses) +turn_detection=EnglishModel(detection_threshold=0.6), + +# Less sensitive (agent waits longer before speaking) +turn_detection=EnglishModel(detection_threshold=0.8), + +# Default (currently used) +turn_detection=EnglishModel(), # threshold=0.7 +``` + +--- + +### 5. Client Name and Info (agent.py Line 330) + +Currently hardcoded: + +```python +agent = OutboundCaller( + name="Jayden", # Change this or look up from database + appointment_time="", # Add appointment time if needed + dial_info=dial_info, +) +``` + +--- + +## Making Changes + +**After editing agent.py:** + +1. Save the file +2. Restart the local agent: + +```bash +# Kill the current agent (Ctrl+C in the terminal where it's running) +# OR if running in background: +pkill -f "python agent.py" + +# Start it again +python agent.py start +``` + +3. Make a test call: + +```bash +python make_call.py +``` + +--- + +## Common Customizations + +### Make voice warmer and more casual + +- Use voice: `694f9389-aac1-45b6-b726-9d9369183238` (Friendly Guy) +- Instructions: "Be warm and casual, like talking to a friend" + +### Make voice more professional + +- Use voice: `79f8b5fb-2cc8-479a-80df-29f7a7cf1a3e` (British Narration Man) +- Instructions: "Be professional and courteous. Speak clearly and authoritatively" + +### Reduce interruptions + +- Increase turn detection threshold to 0.8 +- Add to instructions: "Always let the user finish speaking completely before responding" + +### Make agent speak first + +- Add explicit opening to instructions as shown above + +--- + +## Testing Tips + +1. Make small changes one at a time +2. Test each change with a phone call +3. Check the agent logs to see what's happening +4. Iterate based on how the conversation feels + +The agent is very flexible - experiment to find what works best for your use case! diff --git a/HOW_TO_RUN.md b/HOW_TO_RUN.md new file mode 100644 index 0000000..4541f9f --- /dev/null +++ b/HOW_TO_RUN.md @@ -0,0 +1,263 @@ +# HOW TO RUN - AI Outbound Caller + +Complete guide to run your AI-powered outbound calling system with voice testing sandbox. + +--- + +## Prerequisites + +- Python 3.11+ installed +- Node.js and pnpm installed +- LiveKit account and project set up +- Git Bash or terminal + +--- + +## Quick Start - 3 Components + +You need to run **3 things** to use this system: + +1. **The AI Agent (Backend)** - Handles the phone calls +2. **Voice Testing Sandbox (Frontend)** - Test AI voice in browser without phone calls +3. **Dispatch Command** - Actually make phone calls + +--- + +## 1. START THE AI AGENT (Always Run First) + +**Terminal 1:** + +```bash +cd /mnt/d/Coding-projects/outbound-caller-python-main +source venv-wsl/bin/activate +python3 agent.py start +``` + +**What you should see:** + +```json +{"message": "starting worker", "level": "INFO", ...} +{"message": "registered worker", "id": "AW_xxxxx", ...} +``` + +**Success:** When you see `"registered worker"` - your agent is ready! + +**Keep this terminal running!** Don't close it. + +--- + +## 2. LAUNCH THE VOICE TESTING SANDBOX (Optional - For Testing) + +**Terminal 2:** + +```bash +# Voice test sandbox not yet set up - create it with: +# lk app create --template agent-starter-react +# Then: cd voice-test && pnpm install && pnpm dev +``` + +**What you should see:** + +```text +Next.js 14.x.x +- Local: http://localhost:3000 +``` + +**Success:** Open your browser to `http://localhost:3000` + +**Test your AI agent:** + +- Click the microphone button +- Start talking to test the AI voice assistant +- No phone calls needed - just browser testing! + +--- + +## 3. MAKE ACTUAL PHONE CALLS + +### Option A: Using Command Line (Git Bash) + +**Terminal 3:** + +```bash +cd /mnt/d/Coding-projects/outbound-caller-python-main + +lk dispatch create \ + --new-room \ + --agent-name outbound-caller \ + --metadata '{"phone_number": "+1234567890", "transfer_to": "+0987654321"}' +``` + +**Replace:** + +- `+1234567890` = Phone number to call +- `+0987654321` = Transfer number (optional) + +### Option B: Using LiveKit Dashboard (Easier) + +1. Go to: +2. Login and select: **ai-assistant-calling-project** +3. Navigate to: **Agents** -> **Dispatch** (or **Rooms** -> **Create Room**) +4. Fill in: + - **Agent Name:** `outbound-caller` + - **Metadata:** + + ```json + {"phone_number": "+1234567890", "transfer_to": "+0987654321"} + ``` + +5. Click **Create** or **Dispatch** + +**Success:** The phone will ring and the AI agent will start talking! + +--- + +## What the AI Agent Does + +When someone answers: + +- Introduces itself as a dental scheduling assistant +- Tries to confirm an appointment for "Jayden" on "next Tuesday at 3pm" +- Can answer questions about availability +- Can transfer to a human if requested +- Detects voicemail and hangs up + +--- + +## Monitoring and Debugging + +### View Live Status + +- **Dashboard:** +- **Your Project:** ai-assistant-calling-project +- **Check:** Agents tab to see worker status +- **Monitor:** Rooms tab to see active calls + +### Check Logs + +Look at Terminal 1 (where agent is running) for real-time logs: + +```json +{"message": "connecting to room", ...} +{"message": "participant joined", ...} +``` + +--- + +## Stopping Everything + +1. **Stop the Agent:** Press `Ctrl+C` in Terminal 1 +2. **Stop the Sandbox:** Press `Ctrl+C` in Terminal 2 +3. **Calls automatically end** when agent stops + +--- + +## Configuration + +### Main Config File: `.env.local` + +Copy `.env.example` to `.env.local` and fill in your credentials: + +```bash +# LiveKit Configuration (Get from https://cloud.livekit.io/) +LIVEKIT_URL=wss://your-project.livekit.cloud +LIVEKIT_API_KEY=your_api_key +LIVEKIT_API_SECRET=your_api_secret +LIVEKIT_SIP_URI=sip:your-sip-uri.sip.livekit.cloud + +# OpenAI API Key (Required for Realtime API) +OPENAI_API_KEY=sk-proj-your_openai_key + +# SIP Trunk ID (Get from LiveKit dashboard after trunk setup) +SIP_OUTBOUND_TRUNK_ID=ST_your_trunk_id + +# Optional: For alternative STT/TTS providers (if not using Realtime API) +DEEPGRAM_API_KEY=your_deepgram_key +CARTESIA_API_KEY=your_cartesia_key + +# Twilio Credentials (For trunk setup only) +TWILIO_ACCOUNT_SID=your_twilio_account_sid +TWILIO_AUTH_TOKEN=your_twilio_auth_token +TWILIO_PHONE_NUMBER=+1234567890 +TWILIO_TO_NUMBER=+0987654321 +``` + +--- + +## Troubleshooting + +### Agent Won't Start - TimeoutError on Windows/MINGW64 + +- **Error:** `TimeoutError` during inference executor initialization +- **Root Cause:** LiveKit agents' IPC system doesn't work on Windows/MINGW64 +- **Solutions:** + 1. **Use WSL2 (Recommended):** Run the agent in Windows Subsystem for Linux + + ```bash + # In WSL2 terminal: + cd /mnt/d/Coding-projects/outbound-caller-python-main + source venv-wsl/bin/activate + python3 agent.py start + ``` + + 2. **Use Docker:** Run in a Linux container + 3. **Deploy to Cloud:** Use a Linux server or cloud platform + +- **Why this happens:** The inference executor uses Unix sockets for IPC, which aren't fully compatible with Windows + +### Can't Make Calls + +- **Check:** Is the agent running? (Terminal 1 should show "registered worker") +- **Check:** Is your SIP trunk active? Check dashboard +- **Check:** Do you have Twilio credits? + +### Voice Sandbox Not Working + +- **Check:** Is the agent running? (Terminal 1) +- **Check:** Did you run `pnpm install` first? +- **Check:** Browser console for errors (F12) + +### Command `lk` Not Found + +- **Solution 1:** Use the dashboard instead (easier!) +- **Solution 2:** Reinstall LiveKit CLI: `winget install LiveKit.LiveKitCLI` + +--- + +## Summary Checklist + +Before making your first call: + +- [ ] Agent is running (Terminal 1 shows "registered worker") +- [ ] Sandbox is running (optional, Terminal 2, ) +- [ ] You have the phone number you want to call +- [ ] You've tested in sandbox first (recommended) +- [ ] You're ready to dispatch via CLI or dashboard + +--- + +## Success + +You now have a fully functional AI-powered outbound calling system! + +**Next Steps:** + +- Customize the agent's greeting in `agent.py` (line 49) +- Change the appointment details (line 179-180) +- Adjust the AI voice/personality in `agent.py` (line 187-191) +- Add your own function tools for custom features + +--- + +## Need Help? + +- **LiveKit Docs:** +- **Dashboard:** +- **Issues:** Check Terminal 1 logs first + +--- + +**Created:** 2025-10-14 +**Project:** AI Assistant Calling - Outbound Caller Python +**Agent Name:** outbound-caller +**Worker ID:** Check dashboard for current ID diff --git a/README.md b/README.md index 9e9c11f..c75ba0d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,10 @@ - - LiveKit logo - - # Python Outbound Call Agent -

- LiveKit Agents Docs - • - LiveKit Cloud - • - Blog -

+[![LiveKit logo](/.github/assets/livekit-mark.png)](https://livekit.io/) + +[LiveKit Agents Docs](https://docs.livekit.io/agents/overview/) | +[LiveKit Cloud](https://livekit.io/cloud) | +[Blog](https://blog.livekit.io/) 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). @@ -18,6 +12,21 @@ It can use a pipeline of STT, LLM, and TTS models, or a realtime speech-to-speec 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. +--- + +## HOW TO RUN - START HERE + +**Complete step-by-step guide with:** + +- Running the AI agent +- Testing with voice sandbox +- Making real phone calls +- Troubleshooting guide + +**[Click here to get started](./HOW_TO_RUN.md)** + +--- + ## Features This example demonstrates the following features: diff --git a/RUN_CLAUDE_AGENT.md b/RUN_CLAUDE_AGENT.md new file mode 100644 index 0000000..56b3a72 --- /dev/null +++ b/RUN_CLAUDE_AGENT.md @@ -0,0 +1,140 @@ +# Running Claude Sonnet 4 Agent + +## Important: Must Use WSL2 (Not Windows) + +The Claude Sonnet 4 agent uses the **pipelined approach** with: + +- Deepgram (Speech-to-Text) +- Claude Sonnet 4 (LLM) +- Cartesia (Text-to-Speech) +- Silero (Voice Activity Detection) + +This requires the inference executor which **only works in Unix environments (WSL2/Linux/macOS)**. + +## Setup in WSL2 + +### 1. Open WSL2 Terminal + +```bash +wsl +``` + +### 2. Navigate to Project + +```bash +cd /mnt/d/Coding-Projects/outbound-caller-python +``` + +### 3. Activate Virtual Environment + +If you haven't created one yet: + +```bash +python3 -m venv venv +``` + +Then activate: + +```bash +source venv/bin/activate +``` + +### 4. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 5. Start the Agent + +```bash +./start-agent.sh start +``` + +**Wait for:** `"registered worker outbound-caller"` message + +## Making a Call + +### 1. Go to LiveKit Dashboard + + + +### 2. Navigate to Your Agent + +- Click on your project +- Go to "Agents" section +- Find "outbound-caller" + +### 3. Click "Dispatch" + +### 4. Enter Phone Number in E.164 Format + +```text ++19415180701 +``` + +### 5. Click Dispatch + +**Your phone will ring!** Answer it to talk to the Claude-powered health insurance specialist. + +## What You'll Experience + +- **Voice:** Professional British male narrator +- **AI:** Claude Sonnet 4 (superior reasoning) +- **Expertise:** Comprehensive health insurance knowledge +- **Topics:** HMO, PPO, Medicare, Medicaid, coverage options, claims, compliance + +## Troubleshooting + +### "ImportError: cannot import name 'anthropic'" + +- You're running in Windows/MINGW64 +- Solution: Use WSL2 (see steps above) + +### "TimeoutError during inference executor" + +- The agent needs Unix environment +- Solution: Use WSL2 + +### Agent doesn't start + +```bash +# Check if dependencies are installed +pip list | grep livekit + +# Reinstall if needed +pip install -r requirements.txt --force-reinstall +``` + +### Phone doesn't ring + +- Check .env.local has correct SIP_OUTBOUND_TRUNK_ID +- Verify phone number is in E.164 format (+1234567890) +- Check LiveKit dashboard for error messages + +## Switching Back to OpenAI Realtime API + +If you want to use the simpler OpenAI approach (works on Windows): + +Edit `agent.py` around line 338 and swap the commented sections: + +```python +# Comment out Claude configuration +# session = AgentSession( +# turn_detection=EnglishModel(), +# vad=silero.VAD.load(), +# stt=deepgram.STT(), +# tts=cartesia.TTS(voice="79f8b5fb-2cc8-479a-80df-29f7a7cf1a3e"), +# llm=anthropic.LLM(model="claude-sonnet-4-20250514"), +# ) + +# Uncomment OpenAI configuration +session = AgentSession( + llm=openai.realtime.RealtimeModel( + voice="echo", + temperature=0.8, + ), +) +``` + +Then you can run from Windows. diff --git a/agent.py b/agent.py index 246878a..3167e55 100644 --- a/agent.py +++ b/agent.py @@ -1,244 +1,593 @@ -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(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" - - 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}", - ) - ) - - 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""" - 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 - """ - 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 - """ - 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""" - logger.info(f"detected answering machine for {self.participant.identity}") - await self.hangup() - - -async def entrypoint(ctx: JobContext): - 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) - 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", - ) - ) +""" +AI Outbound Caller Agent + +This agent makes automated phone calls using LiveKit's real-time communication platform. +It uses OpenAI's Realtime API for speech-to-speech communication, providing a natural +conversational experience for appointment confirmations and call management. + +Key Features: +- Automated appointment confirmation calls +- Call transfer functionality to human agents +- Voicemail detection +- Appointment scheduling assistance +- Natural voice conversation using OpenAI's Realtime API + +Architecture: +- Uses SIP (Session Initiation Protocol) for phone connectivity +- LiveKit for real-time audio streaming +- OpenAI Realtime API for speech-to-speech processing +- Function calling for agent actions (transfer, hangup, etc.) + +Note: This agent requires a Unix-like environment (Linux/macOS/WSL2) due to +LiveKit's IPC system requirements. It will not work on Windows/MINGW64. +""" + +from __future__ import annotations + +import os +import asyncio +import logging +from dotenv import load_dotenv +import json +from typing import Any + +# LiveKit core imports for real-time communication and API access +from livekit import rtc, api + +# LiveKit agents framework for building voice agents +from livekit.agents import ( + AgentSession, # Manages the conversation session + Agent, # Base class for creating agents + JobContext, # Provides context about the current job/call + function_tool, # Decorator for creating callable functions + RunContext, # Context during function execution + get_job_context, # Access to current job context + cli, # Command-line interface utilities + WorkerOptions, # Configuration for the worker + RoomInputOptions, # Configuration for room audio input +) + +# LiveKit plugins for various AI services +from livekit.plugins import ( + anthropic, # Claude AI (Primary LLM) # type: ignore[attr-defined] + deepgram, # Speech-to-text + cartesia, # Text-to-speech + silero, # Voice activity detection + noise_cancellation, # Background noise removal + openai, # OpenAI Realtime API (works without inference executor) +) +# from livekit.plugins.turn_detector.english import EnglishModel # Turn detection - causes WSL2 timeout + +# Load environment variables from .env.local file +# This includes API keys, LiveKit credentials, and SIP trunk configuration +load_dotenv(dotenv_path=".env.local") + +# Configure logging for the agent +logger = logging.getLogger("outbound-caller") +logger.setLevel(logging.INFO) + +# SIP trunk ID for making outbound calls via LiveKit +# This is configured in your LiveKit dashboard and connects to Twilio +outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID") + + +class OutboundCaller(Agent): + """ + Outbound calling agent for appointment confirmations. + + This agent handles automated phone calls to confirm appointments, + provide scheduling assistance, and transfer calls to human agents when needed. + + Attributes: + participant: The remote participant (person being called) in the conversation + dial_info: Dictionary containing phone numbers and transfer information + """ + + def __init__( + self, + *, + name: str, + appointment_time: str, + dial_info: dict[str, Any], + ): + """ + Initialize the outbound caller agent. + + Args: + name: The customer's name for personalization + appointment_time: The scheduled appointment time to confirm + dial_info: Dictionary with 'phone_number' and 'transfer_to' keys + """ + super().__init__( + instructions=f""" + You are John, a sharp and persistent health insurance sales agent. Your interface with users will be voice. + You are a professional closer who doesn't take no for an answer - your job is to get them to agree to a FREE QUOTE. + + VOICE TONE & ENERGY - CRITICAL FOR SUCCESS: + + Your voice should be HAPPY, UPBEAT, and NATURAL at all times: + - Sound like you're smiling - warm, friendly, and genuinely happy to talk to them + - Speak with ENERGY and enthusiasm - you're excited about helping them save money! + - Be conversational and natural - like talking to a friend, not reading a script + - Vary your tone - don't be monotone or robotic + - Sound confident but not aggressive - you KNOW you can help them + - Keep it upbeat even when handling objections - stay positive! + + SOUND HUMAN - NOT LIKE AN AI (THIS IS NON-NEGOTIABLE): + + Real salespeople don't talk in perfect, pre-written sentences. They + stumble a little, breathe, pause to think. You must do the same: + + - PACE: Don't rush. Speak at a relaxed, natural pace like you're + actually having a conversation. Faster when excited, slower on + important points. + - PAUSES: Take small natural pauses. After their name. Before a + question. When you "think" about something. Use commas and + ellipses in your speech. + - FILLER WORDS: Drop in casual human fillers OCCASIONALLY (not + every sentence): "uh", "um", "you know", "honestly", "look", + "I mean", "right?", "so...", "anyway". These make you sound real. + - BREATHING: Breathe naturally between thoughts. Don't run sentences + together in one breath. + - REACTIONS: React to what they say with human sounds - "hm", "oh + yeah?", "gotcha", "mhm", "totally", "for sure", "right on", "oh + man". Acknowledge them as a person, not a script input. + - VARIATION: Never sound flat. Change your pitch, volume, and speed + naturally. Get a little louder when emphasizing savings, softer + when being empathetic. + - IMPERFECTION: It's okay to restart a sentence occasionally, like + "So what we- actually, let me put it this way..." That sounds + human. Perfect delivery sounds robotic. + - LAUGH OR CHUCKLE: If something is light or funny, a small laugh + like "haha" or "ha!" is great. Don't force it. + - CONTRACTIONS ALWAYS: "I'm", "you're", "that's", "we'll", "gonna", + "wanna", "kinda". Never "I am" or "you are" - that sounds stiff. + + Think: you are NOT reading a script. You are John from Tampa, + sitting in your office, genuinely talking to {name} on the phone. + + CRITICAL OPENING SCRIPT - FOLLOW EXACTLY: + + 1. GREETING (enthusiastic and confident): + "Hey! This is John. How's it going with you today?" + + 2. WAIT FOR THEIR RESPONSE - Let them answer + + 3. REASON FOR CALL (after they respond): + "The reason for the call today {name} is that I work with lots of individuals who are self-employed, small business owners who either pay a lot or are unhappy with their health insurance. I was just seeing if that was the case with you?" + + YOUR GOAL: Get them to agree to a FREE quote. That's it. Not to sell them, just get them to see what's available. + + KEY SELLING POINTS (use these naturally in conversation): + - We save individuals and families around twenty to forty percent on their monthly premium with HIGHER caliber coverage + - The plan is twenty to forty percent more affordable than anything else out there + - Our job is to keep your insurance company honest + - We make health insurance more affordable than anything you see online + - We keep their current insurance company honest by showing them what else is out there + - It's completely free to get a quote - no obligation whatsoever + - Most people don't realize they're overpaying until they compare + - Takes literally 2 minutes to see if we can save them money + + AGE VERIFICATION - IMPORTANT FOR ACCURATE QUOTES: + Before closing, ask their age to qualify the premium: + "Real quick {name}, just so Max can pull up the right plans for you - what's your age? This helps us make sure we're showing you the most accurate pricing." + + [Wait for age response] + + CLOSING LINE - USE THIS TO GET AGREEMENT: + After getting their age and handling objections, use this closing: + "Perfect! Look {name}, the plan is twenty to forty percent more affordable than anything else out there, and our job is to keep your insurance company honest and make health insurance more affordable than anything you see online. With that being said, would it be okay to get you over to Max, my health insurance specialist?" + + WHEN THEY AGREE: + Say: "Perfect! I'm going to get you over to my top agent Max who would be more than happy to assist. One second." + Then immediately use the transfer_call tool. + + HANDLING OBJECTIONS - BE PERSISTENT AND SHARP: + + "I'm happy with my plan": + → "That's great {name}! But when's the last time you actually compared? Most people say they're happy until they realize they're overpaying by two hundred to four hundred dollars a month. What if I could show you the same coverage or better for twenty to forty percent less? Would you at least want to see the numbers?" + + "I don't have time": + → "I totally get it {name}, but that's exactly why I'm calling. Takes literally 2 minutes to run the quote. What's it hurt to at least SEE if you're overpaying? If you're already getting the best deal, great - you'll know for sure. But what if you're not?" + + "Not interested": + → "I hear you {name}, but can I ask - are you saying you're not interested in potentially saving two hundred, three hundred, four hundred dollars a month on your health insurance? Because that's what we're averaging with our clients. It's free to check - what's the worst that happens, you find out you already have a good deal?" + + "I need to think about it": + → "Absolutely {name}, I respect that. But think about what? It's a free quote - there's nothing to think about. Let's just run the numbers real quick, see what's available, and THEN you can think about it with actual information instead of guessing. Fair enough?" + + "How did you get my number?": + → "We work with self-employed folks and small business owners specifically {name}. Are you self-employed or have your own business? [Wait for answer] Perfect, that's exactly who we help save the most money." + + "I can't afford to switch": + → "Wait, hold on {name} - switching is FREE. There's zero cost to switch health insurance. And if we can show you BETTER coverage for LESS money, wouldn't that actually help you afford it better? That's literally the whole point of what I do." + + "Send me information": + → "I could {name}, but here's the thing - you'll get an email, you'll ignore it, and you'll keep overpaying. Why not take 2 minutes right now while I have you? My agent Max can run your quote in real-time and you'll know immediately if we can save you money. What's your current monthly premium?" + + "Call me back later": + → "I can {name}, but be honest - you're not going to answer when I call back, right? We both know how that goes. You're on the phone with me RIGHT NOW. Let's just get you the quote, and if it doesn't make sense, we never talk again. But if it DOES make sense, you could be saving hundreds of dollars a month. Why wait?" + + "I'm not the decision maker": + → "I totally understand {name}. So who handles the health insurance in your family? [Get name] Okay perfect. Here's what I'll do - let me get you the quote anyway so you have the information. Then you can show [spouse name] the numbers. If they see we can save you twenty to forty percent, I bet they'll be interested. Sound good?" + + "I'm on the Do Not Call list": + → "I understand {name}. We scrub on the DNC list, so if your phone number was on the national DO NOT CALL REGISTRY, we wouldn't have dialed you. But I respect that - one more thing though, would you be open to just hearing about how we can save you twenty to forty percent on better coverage?" + + [If they say NO again] + → "I completely understand {name}. I appreciate your time today. You have a great rest of your day." Then use the end_call tool. + + "I already shopped around": + → "That's awesome {name}! When did you shop around? [Get timeframe] Okay, so here's the thing - rates change constantly. What was available 6 months ago, a year ago, is totally different now. Plus we have access to plans most people don't even know exist. What's it hurt to compare one more time, especially if we can beat what you found?" + + "Remove me from your list": + → "I can do that {name}, absolutely. But real quick before I do - can I ask, are you saying you don't want to save twenty to forty percent on your health insurance with better coverage? Because that seems like it would be worth 2 minutes of your time. If after the quote you still want off the list, no problem. But at least see the numbers first?" + + CONVERSATION STYLE - SALES PROFESSIONAL: + - Confident, direct, and persistent - you're helping them save money + - Use their name frequently - builds rapport + - Don't accept "no" easily - every objection has a counter + - Assume the sale - talk like they're already getting the quote + - Create urgency - "while I have you", "let's do it right now" + - Use social proof - "most people", "our clients average" + - Focus on THEIR money being wasted, not your product + - Turn objections into questions that make them think + - Use "but" to pivot objections: "I hear you, BUT..." + + CRITICAL RULES: + 1. Your ONLY job is to get them to agree to a FREE quote + 2. When they agree, immediately say the transfer line and use transfer_call + 3. NEVER give up after one objection - try at least 2-3 times with different angles + 4. Keep reframing - it's not about selling insurance, it's about saving THEIR money + 5. Make it easy - "just 2 minutes", "free quote", "no obligation" + 6. If they're truly aggressive or hostile, politely end the call + 7. Always be professional - pushy but never rude + + GUARDRAILS - STAY ON TRACK: + + ✅ YOU CAN DISCUSS (Keep it general): + - Health insurance in general terms (costs too high, people overpaying, etc.) + - The problem with current insurance (expensive, bad coverage) + - twenty to forty percent savings and better coverage (general benefits) + - Basic small talk: "How are you?", weather, casual conversation + - Your location if asked: "I'm in Tampa, Florida - been here for 20 years" + + ❌ REDIRECT TO MAX (These are too detailed for you): + - Specific plan details (HMO, PPO, deductibles, copays, networks) + - Exact prices or premiums (beyond "twenty to forty percent savings") + - Medical coverage specifics (prescriptions, doctors, procedures) + - How to enroll, paperwork, application process + - Policy comparisons or recommendations + + 🔄 OFF-TOPIC? REDIRECT BACK: + If they ask about anything NOT related to insurance (sports, politics, personal life beyond basic pleasantries): + → Answer briefly and politely, then pivot back: "But hey, real quick {name}, back to what I was saying about the free quote..." + → Keep it short and redirect to insurance + + 📍 LOCATION RESPONSE: + If asked "Where are you calling from?" or "Where are you located?": + → "I'm in Tampa, Florida - been here for 20 years. Love it here!" + + ✅ WHEN THEY ASK DETAILED QUESTIONS, USE THESE: + - "That's exactly what Max will go over with you on the free quote" + - "Max is the expert on all the plan details - let me get you over to him" + - "Great question! Max will walk you through all of that. Let's get you connected" + - "I don't want to give you wrong information - Max handles all the specifics" + + YOUR MAIN JOB: + ✅ Get them to agree to a FREE quote + ✅ Handle objections about getting the quote + ✅ Transfer to Max when they agree + + Remember: You're John from Tampa, FL (20 years). You're friendly and conversational about insurance problems, but Max is the expert on specifics. + The person you're calling is named {name} - use their name to build rapport. + Transfer to Max (your top agent) when they agree. + """ + ) + # Keep reference to the participant for call operations (transfers, hangups, etc.) + self.participant: rtc.RemoteParticipant | None = None + + # Store dial information (phone numbers, transfer destination) + self.dial_info = dial_info + + def set_participant(self, participant: rtc.RemoteParticipant): + """ + Set the participant reference after they join the call. + + Args: + participant: The remote participant who answered the call + """ + self.participant = participant + + async def hangup(self): + """ + End the call by deleting the LiveKit room. + + This terminates the call and cleans up all connections. + The room deletion triggers automatic disconnection of all participants. + """ + 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 Max (top agent) when prospect agrees to get a quote. + + Use this IMMEDIATELY when the prospect agrees. + The instructions already told them: "Perfect! I'm going to get you over to my top agent Max..." + + Args: + ctx: Runtime context with access to the session and agent state + + Returns: + str: Status message ("cannot transfer call" if no transfer number configured) + """ + transfer_to = self.dial_info["transfer_to"] + if not transfer_to: + return "cannot transfer call" + + logger.info(f"transferring call to Max at {transfer_to}") + + # Transfer immediately - John already said the transfer line in the instructions + job_ctx = get_job_context() + try: + if self.participant is None: + return "cannot transfer call - no participant" + # Use LiveKit SIP API to transfer the call to Max's phone number + 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}", + ) + ) + + logger.info(f"transferred call to Max successfully") + except Exception as e: + logger.error(f"error transferring call: {e}") + # Apologize for technical issue + await ctx.session.generate_reply( + instructions="apologize that there's a technical issue and you'll call them right back with Max" + ) + await self.hangup() + + @function_tool() + async def end_call(self, ctx: RunContext): + """ + End the call when the user is ready to hang up. + + This tool is called by the AI when the conversation has concluded. + It ensures the agent finishes speaking before disconnecting. + + Args: + ctx: Runtime context with access to the session + """ + participant_id = self.participant.identity if self.participant else "unknown" + logger.info(f"ending the call for {participant_id}") + + # Wait for the agent to finish speaking current message before hanging up + 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, + ): + """ + Look up available appointment times for rescheduling. + + This is a placeholder function that simulates checking a scheduling system. + In production, this would query your actual appointment database. + + Args: + ctx: Runtime context + date: The date to check availability for (in natural language) + + Returns: + dict: Available appointment times + """ + participant_id = self.participant.identity if self.participant else "unknown" + logger.info( + f"looking up availability for {participant_id} on {date}" + ) + # Simulate database lookup delay + await asyncio.sleep(3) + # Return mock availability data - replace with real database query + return { + "available_times": ["1pm", "2pm", "3pm"], + } + + @function_tool() + async def confirm_appointment( + self, + ctx: RunContext, + date: str, + time: str, + ): + """ + Confirm an appointment for a specific date and time. + + This tool is called when the user confirms or reschedules their appointment. + In production, this would update your scheduling system. + + Args: + ctx: Runtime context + date: The appointment date + time: The appointment time + + Returns: + str: Confirmation message + """ + participant_id = self.participant.identity if self.participant else "unknown" + logger.info( + f"confirming appointment for {participant_id} on {date} at {time}" + ) + # In production: Update your scheduling database here + return "reservation confirmed" + + @function_tool() + async def detected_answering_machine(self, ctx: RunContext): + """ + Handle voicemail detection. + + This tool is called by the AI when it detects that the call reached voicemail + instead of a live person. The agent should call this AFTER hearing the voicemail greeting. + + Args: + ctx: Runtime context + """ + participant_id = self.participant.identity if self.participant else "unknown" + logger.info(f"detected answering machine for {participant_id}") + # End the call immediately when voicemail is detected + await self.hangup() + + +async def entrypoint(ctx: JobContext): + """ + Main entrypoint for handling outbound calls. + + This function is called by the LiveKit agents framework when a new job (call) + is dispatched. It sets up the call, initializes the AI agent, and manages the + complete call lifecycle from dialing to completion. + + Workflow: + 1. Connect to the LiveKit room + 2. Parse call metadata (phone number, transfer info) + 3. Create and configure the AI agent + 4. Set up the session with OpenAI Realtime API + 5. Start the session (begins loading models) + 6. Dial the phone number via SIP + 7. Wait for the user to answer + 8. Connect the participant to the agent + 9. Let the conversation run until completion + + Args: + ctx: Job context providing access to room, API, and job metadata + """ + logger.info(f"connecting to room {ctx.room.name}") + await ctx.connect() + + # Parse metadata passed during dispatch containing call information + # dial_info structure: + # { + # "phone_number": "+1234567890", # Number to call + # "transfer_to": "+0987654321" # Human agent number for transfers + # } + dial_info = json.loads(ctx.job.metadata) + participant_identity = phone_number = dial_info["phone_number"] + + # Create the agent with personalized information from the dispatch metadata. + # The bulk dispatcher (dispatch_from_excel.py) injects `name` per row so each + # call addresses the prospect by their actual first name. + agent = OutboundCaller( + name=dial_info.get("name", "there"), + appointment_time="", # Not used for health insurance calls + dial_info=dial_info, + ) + + # Configure the session using Claude Sonnet 4 with Deepgram STT and Cartesia TTS + # This provides superior reasoning and natural conversation using the pipelined approach + # Voice: Cartesia provides natural-sounding male voice optimized for health insurance discussions + # session = AgentSession( + # turn_detection=EnglishModel(), # Detects when user finishes speaking + # vad=silero.VAD.load(), # Voice activity detection for better turn-taking + # stt=deepgram.STT(), # Deepgram speech-to-text (fast and accurate) + # tts=cartesia.TTS(voice="228fca29-3a0a-435c-8728-5cb483251068"), # Your selected Cartesia voice + # llm=anthropic.LLM(model="claude-sonnet-4-20250514"), # Claude Sonnet 4 (best reasoning) + # ) + + # Using OpenAI Realtime API - FASTEST (near-instant speech-to-speech) + # Voice choice: "ash" is the most human-sounding male voice on the + # Realtime API as of this writing. Alternatives to A/B test: + # - "verse" — warm, slightly younger, very natural + # - "ballad" — calmer, more thoughtful + # - "sage" — smoother, slightly more neutral + # Temperature 0.85 gives natural variation without going off-script; + # higher values make delivery more lively but less consistent. + session: AgentSession = AgentSession( + llm=openai.realtime.RealtimeModel( + voice="ash", + temperature=0.85, + ), + ) + + # Claude (commented out - slower): + # session = AgentSession( + # vad=silero.VAD.load( + # min_silence_duration=0.3, + # activation_threshold=0.4, + # ), + # stt=deepgram.STT(), + # tts=cartesia.TTS(voice="228fca29-3a0a-435c-8728-5cb483251068"), + # llm=anthropic.LLM(model="claude-sonnet-4-20250514"), + # ) + + # Start the session before dialing to ensure the agent is ready when the user answers + # This prevents missing the first few seconds of what the user says + session_started = asyncio.create_task( + session.start( + agent=agent, + room=ctx.room, + room_input_options=RoomInputOptions( + # Enable Krisp noise cancellation optimized for telephony + # This removes background noise for clearer conversations + noise_cancellation=noise_cancellation.BVCTelephony(), + ), + ) + ) + + # Initiate the outbound call via SIP trunk + # This dials the phone number and waits for the user to answer + try: + await ctx.api.sip.create_sip_participant( + api.CreateSIPParticipantRequest( + room_name=ctx.room.name, + sip_trunk_id=outbound_trunk_id, # Configured SIP trunk ID + sip_call_to=phone_number, # Number to dial + participant_identity=participant_identity, # Unique identifier + wait_until_answered=True, # Block until call is answered or fails + ) + ) + + # Wait for both the session to finish starting and the participant to join + await session_started + participant = await ctx.wait_for_participant(identity=participant_identity) + logger.info(f"participant joined: {participant.identity}") + + # Give the agent a reference to the participant for call operations + agent.set_participant(participant) + + # Conversation now runs automatically until: + # - User hangs up + # - Agent calls end_call() or hangup() + # - Error occurs + + except api.TwirpError as e: + # Handle SIP errors (busy, no answer, invalid number, etc.) + 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__": + # Start the LiveKit agents worker + # This runs continuously, waiting for jobs to be dispatched + cli.run_app( + WorkerOptions( + entrypoint_fnc=entrypoint, # Function to call for each job + agent_name="outbound-caller", # Name used when dispatching jobs + ) + ) diff --git a/agent.py.aggressive b/agent.py.aggressive new file mode 100644 index 0000000..7d373ab --- /dev/null +++ b/agent.py.aggressive @@ -0,0 +1,2 @@ +from __future__ import annotations +print("Aggressive sales agent - see full code in agent.py.aggressive") diff --git a/agent.py.backup b/agent.py.backup new file mode 100644 index 0000000..1d24c09 --- /dev/null +++ b/agent.py.backup @@ -0,0 +1,534 @@ +""" +AI Outbound Caller Agent + +This agent makes automated phone calls using LiveKit's real-time communication platform. +It uses OpenAI's Realtime API for speech-to-speech communication, providing a natural +conversational experience for appointment confirmations and call management. + +Key Features: +- Automated appointment confirmation calls +- Call transfer functionality to human agents +- Voicemail detection +- Appointment scheduling assistance +- Natural voice conversation using OpenAI's Realtime API + +Architecture: +- Uses SIP (Session Initiation Protocol) for phone connectivity +- LiveKit for real-time audio streaming +- OpenAI Realtime API for speech-to-speech processing +- Function calling for agent actions (transfer, hangup, etc.) + +Note: This agent requires a Unix-like environment (Linux/macOS/WSL2) due to +LiveKit's IPC system requirements. It will not work on Windows/MINGW64. +""" + +from __future__ import annotations + +import os +import asyncio +import logging +from dotenv import load_dotenv +import json +from typing import Any + +# LiveKit core imports for real-time communication and API access +from livekit import rtc, api + +# LiveKit agents framework for building voice agents +from livekit.agents import ( + AgentSession, # Manages the conversation session + Agent, # Base class for creating agents + JobContext, # Provides context about the current job/call + function_tool, # Decorator for creating callable functions + RunContext, # Context during function execution + get_job_context, # Access to current job context + cli, # Command-line interface utilities + WorkerOptions, # Configuration for the worker + RoomInputOptions, # Configuration for room audio input +) + +# LiveKit plugins for various AI services +from livekit.plugins import ( + anthropic, # Claude AI (Primary LLM) + deepgram, # Speech-to-text + cartesia, # Text-to-speech + silero, # Voice activity detection + noise_cancellation, # Background noise removal + openai, # OpenAI Realtime API (works without inference executor) +) +# from livekit.plugins.turn_detector.english import EnglishModel # Turn detection - causes WSL2 timeout + +# Load environment variables from .env.local file +# This includes API keys, LiveKit credentials, and SIP trunk configuration +load_dotenv(dotenv_path=".env.local") + +# Configure logging for the agent +logger = logging.getLogger("outbound-caller") +logger.setLevel(logging.INFO) + +# SIP trunk ID for making outbound calls via LiveKit +# This is configured in your LiveKit dashboard and connects to Twilio +outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID") + + +class OutboundCaller(Agent): + """ + Outbound calling agent for appointment confirmations. + + This agent handles automated phone calls to confirm appointments, + provide scheduling assistance, and transfer calls to human agents when needed. + + Attributes: + participant: The remote participant (person being called) in the conversation + dial_info: Dictionary containing phone numbers and transfer information + """ + + def __init__( + self, + *, + name: str, + appointment_time: str, + dial_info: dict[str, Any], + ): + """ + Initialize the outbound caller agent. + + Args: + name: The customer's name for personalization + appointment_time: The scheduled appointment time to confirm + dial_info: Dictionary with 'phone_number' and 'transfer_to' keys + """ + super().__init__( + instructions=f""" + You are John, a sharp and persistent health insurance sales agent. Your interface with users will be voice. + You are a professional closer who doesn't take no for an answer - your job is to get them to agree to a FREE QUOTE. + + CRITICAL OPENING SCRIPT - FOLLOW EXACTLY: + + 1. GREETING (enthusiastic and confident): + "Hey! This is John. How's it going with you today?" + + 2. WAIT FOR THEIR RESPONSE - Let them answer + + 3. REASON FOR CALL (after they respond): + "The reason for the call today {name} is that I work with lots of individuals who are self-employed, small business owners who either pay a lot or are unhappy with their health insurance. I was just seeing if that was the case with you?" + + YOUR GOAL: Get them to agree to a FREE quote. That's it. Not to sell them, just get them to see what's available. + + KEY SELLING POINTS (use these naturally in conversation): + - We save individuals and families around twenty to forty percent on their monthly premium with HIGHER caliber coverage + - The plan is twenty to forty percent more affordable than anything else out there + - Our job is to keep your insurance company honest + - We make health insurance more affordable than anything you see online + - We keep their current insurance company honest by showing them what else is out there + - It's completely free to get a quote - no obligation whatsoever + - Most people don't realize they're overpaying until they compare + - Takes literally 2 minutes to see if we can save them money + + AGE VERIFICATION - IMPORTANT FOR ACCURATE QUOTES: + Before closing, ask their age to qualify the premium: + "Real quick {name}, just so Max can pull up the right plans for you - what's your age? This helps us make sure we're showing you the most accurate pricing." + + [Wait for age response] + + CLOSING LINE - USE THIS TO GET AGREEMENT: + After getting their age and handling objections, use this closing: + "Perfect! Look {name}, the plan is twenty to forty percent more affordable than anything else out there, and our job is to keep your insurance company honest and make health insurance more affordable than anything you see online. With that being said, would it be okay to get you over to Max, my health insurance specialist?" + + WHEN THEY AGREE: + Say: "Perfect! I'm going to get you over to my top agent Max who would be more than happy to assist. One second." + Then immediately use the transfer_call tool. + + HANDLING OBJECTIONS - BE PERSISTENT AND SHARP: + + "I'm happy with my plan": + → "That's great {name}! But when's the last time you actually compared? Most people say they're happy until they realize they're overpaying by two hundred to four hundred dollars a month. What if I could show you the same coverage or better for twenty to forty percent less? Would you at least want to see the numbers?" + + "I don't have time": + → "I totally get it {name}, but that's exactly why I'm calling. Takes literally 2 minutes to run the quote. What's it hurt to at least SEE if you're overpaying? If you're already getting the best deal, great - you'll know for sure. But what if you're not?" + + "Not interested": + → "I hear you {name}, but can I ask - are you saying you're not interested in potentially saving two hundred, three hundred, four hundred dollars a month on your health insurance? Because that's what we're averaging with our clients. It's free to check - what's the worst that happens, you find out you already have a good deal?" + + "I need to think about it": + → "Absolutely {name}, I respect that. But think about what? It's a free quote - there's nothing to think about. Let's just run the numbers real quick, see what's available, and THEN you can think about it with actual information instead of guessing. Fair enough?" + + "How did you get my number?": + → "We work with self-employed folks and small business owners specifically {name}. Are you self-employed or have your own business? [Wait for answer] Perfect, that's exactly who we help save the most money." + + "I can't afford to switch": + → "Wait, hold on {name} - switching is FREE. There's zero cost to switch health insurance. And if we can show you BETTER coverage for LESS money, wouldn't that actually help you afford it better? That's literally the whole point of what I do." + + "Send me information": + → "I could {name}, but here's the thing - you'll get an email, you'll ignore it, and you'll keep overpaying. Why not take 2 minutes right now while I have you? My agent Max can run your quote in real-time and you'll know immediately if we can save you money. What's your current monthly premium?" + + "Call me back later": + → "I can {name}, but be honest - you're not going to answer when I call back, right? We both know how that goes. You're on the phone with me RIGHT NOW. Let's just get you the quote, and if it doesn't make sense, we never talk again. But if it DOES make sense, you could be saving hundreds of dollars a month. Why wait?" + + "I'm not the decision maker": + → "I totally understand {name}. So who handles the health insurance in your family? [Get name] Okay perfect. Here's what I'll do - let me get you the quote anyway so you have the information. Then you can show [spouse name] the numbers. If they see we can save you twenty to forty percent, I bet they'll be interested. Sound good?" + + "I'm on the Do Not Call list": + → "I understand {name}. We scrub on the DNC list, so if your phone number was on the national DO NOT CALL REGISTRY, we wouldn't have dialed you. But I respect that - one more thing though, would you be open to just hearing about how we can save you twenty to forty percent on better coverage?" + + [If they say NO again] + → "I completely understand {name}. I appreciate your time today. You have a great rest of your day." Then use the end_call tool. + + "I already shopped around": + → "That's awesome {name}! When did you shop around? [Get timeframe] Okay, so here's the thing - rates change constantly. What was available 6 months ago, a year ago, is totally different now. Plus we have access to plans most people don't even know exist. What's it hurt to compare one more time, especially if we can beat what you found?" + + "Remove me from your list": + → "I can do that {name}, absolutely. But real quick before I do - can I ask, are you saying you don't want to save twenty to forty percent on your health insurance with better coverage? Because that seems like it would be worth 2 minutes of your time. If after the quote you still want off the list, no problem. But at least see the numbers first?" + + CONVERSATION STYLE - SALES PROFESSIONAL: + - Confident, direct, and persistent - you're helping them save money + - Use their name frequently - builds rapport + - Don't accept "no" easily - every objection has a counter + - Assume the sale - talk like they're already getting the quote + - Create urgency - "while I have you", "let's do it right now" + - Use social proof - "most people", "our clients average" + - Focus on THEIR money being wasted, not your product + - Turn objections into questions that make them think + - Use "but" to pivot objections: "I hear you, BUT..." + + CRITICAL RULES: + 1. Your ONLY job is to get them to agree to a FREE quote + 2. When they agree, immediately say the transfer line and use transfer_call + 3. NEVER give up after one objection - try at least 2-3 times with different angles + 4. Keep reframing - it's not about selling insurance, it's about saving THEIR money + 5. Make it easy - "just 2 minutes", "free quote", "no obligation" + 6. If they're truly aggressive or hostile, politely end the call + 7. Always be professional - pushy but never rude + + GUARDRAILS - STAY ON TRACK: + + ✅ YOU CAN DISCUSS (Keep it general): + - Health insurance in general terms (costs too high, people overpaying, etc.) + - The problem with current insurance (expensive, bad coverage) + - twenty to forty percent savings and better coverage (general benefits) + - Basic small talk: "How are you?", weather, casual conversation + - Your location if asked: "I'm in Tampa, FL - been here for 20 years" + + ❌ REDIRECT TO MAX (These are too detailed for you): + - Specific plan details (HMO, PPO, deductibles, copays, networks) + - Exact prices or premiums (beyond "twenty to forty percent savings") + - Medical coverage specifics (prescriptions, doctors, procedures) + - How to enroll, paperwork, application process + - Policy comparisons or recommendations + + 🔄 OFF-TOPIC? REDIRECT BACK: + If they ask about anything NOT related to insurance (sports, politics, personal life beyond basic pleasantries): + → Answer briefly and politely, then pivot back: "But hey, real quick {name}, back to what I was saying about the free quote..." + → Keep it short and redirect to insurance + + 📍 LOCATION RESPONSE: + If asked "Where are you calling from?" or "Where are you located?": + → "I'm in Tampa, FL - been here for 20 years. Love it here!" + + ✅ WHEN THEY ASK DETAILED QUESTIONS, USE THESE: + - "That's exactly what Max will go over with you on the free quote" + - "Max is the expert on all the plan details - let me get you over to him" + - "Great question! Max will walk you through all of that. Let's get you connected" + - "I don't want to give you wrong information - Max handles all the specifics" + + YOUR MAIN JOB: + ✅ Get them to agree to a FREE quote + ✅ Handle objections about getting the quote + ✅ Transfer to Max when they agree + + Remember: You're John from Tampa, FL (20 years). You're friendly and conversational about insurance problems, but Max is the expert on specifics. + The person you're calling is named {name} - use their name to build rapport. + Transfer to Max (your top agent) when they agree. + """ + ) + # Keep reference to the participant for call operations (transfers, hangups, etc.) + self.participant: rtc.RemoteParticipant | None = None + + # Store dial information (phone numbers, transfer destination) + self.dial_info = dial_info + + def set_participant(self, participant: rtc.RemoteParticipant): + """ + Set the participant reference after they join the call. + + Args: + participant: The remote participant who answered the call + """ + self.participant = participant + + async def hangup(self): + """ + End the call by deleting the LiveKit room. + + This terminates the call and cleans up all connections. + The room deletion triggers automatic disconnection of all participants. + """ + 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 Max (top agent) when prospect agrees to get a quote. + + Use this IMMEDIATELY when the prospect agrees. + The instructions already told them: "Perfect! I'm going to get you over to my top agent Max..." + + Args: + ctx: Runtime context with access to the session and agent state + + Returns: + str: Status message ("cannot transfer call" if no transfer number configured) + """ + transfer_to = self.dial_info["transfer_to"] + if not transfer_to: + return "cannot transfer call" + + logger.info(f"transferring call to Max at {transfer_to}") + + # Transfer immediately - John already said the transfer line in the instructions + job_ctx = get_job_context() + try: + # Use LiveKit SIP API to transfer the call to Max's phone number + 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}", + ) + ) + + logger.info(f"transferred call to Max successfully") + except Exception as e: + logger.error(f"error transferring call: {e}") + # Apologize for technical issue + await ctx.session.generate_reply( + instructions="apologize that there's a technical issue and you'll call them right back with Max" + ) + await self.hangup() + + @function_tool() + async def end_call(self, ctx: RunContext): + """ + End the call when the user is ready to hang up. + + This tool is called by the AI when the conversation has concluded. + It ensures the agent finishes speaking before disconnecting. + + Args: + ctx: Runtime context with access to the session + """ + logger.info(f"ending the call for {self.participant.identity}") + + # Wait for the agent to finish speaking current message before hanging up + 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, + ): + """ + Look up available appointment times for rescheduling. + + This is a placeholder function that simulates checking a scheduling system. + In production, this would query your actual appointment database. + + Args: + ctx: Runtime context + date: The date to check availability for (in natural language) + + Returns: + dict: Available appointment times + """ + logger.info( + f"looking up availability for {self.participant.identity} on {date}" + ) + # Simulate database lookup delay + await asyncio.sleep(3) + # Return mock availability data - replace with real database query + return { + "available_times": ["1pm", "2pm", "3pm"], + } + + @function_tool() + async def confirm_appointment( + self, + ctx: RunContext, + date: str, + time: str, + ): + """ + Confirm an appointment for a specific date and time. + + This tool is called when the user confirms or reschedules their appointment. + In production, this would update your scheduling system. + + Args: + ctx: Runtime context + date: The appointment date + time: The appointment time + + Returns: + str: Confirmation message + """ + logger.info( + f"confirming appointment for {self.participant.identity} on {date} at {time}" + ) + # In production: Update your scheduling database here + return "reservation confirmed" + + @function_tool() + async def detected_answering_machine(self, ctx: RunContext): + """ + Handle voicemail detection. + + This tool is called by the AI when it detects that the call reached voicemail + instead of a live person. The agent should call this AFTER hearing the voicemail greeting. + + Args: + ctx: Runtime context + """ + logger.info(f"detected answering machine for {self.participant.identity}") + # End the call immediately when voicemail is detected + await self.hangup() + + +async def entrypoint(ctx: JobContext): + """ + Main entrypoint for handling outbound calls. + + This function is called by the LiveKit agents framework when a new job (call) + is dispatched. It sets up the call, initializes the AI agent, and manages the + complete call lifecycle from dialing to completion. + + Workflow: + 1. Connect to the LiveKit room + 2. Parse call metadata (phone number, transfer info) + 3. Create and configure the AI agent + 4. Set up the session with OpenAI Realtime API + 5. Start the session (begins loading models) + 6. Dial the phone number via SIP + 7. Wait for the user to answer + 8. Connect the participant to the agent + 9. Let the conversation run until completion + + Args: + ctx: Job context providing access to room, API, and job metadata + """ + logger.info(f"connecting to room {ctx.room.name}") + await ctx.connect() + + # Parse metadata passed during dispatch containing call information + # dial_info structure: + # { + # "phone_number": "+1234567890", # Number to call + # "transfer_to": "+0987654321" # Human agent number for transfers + # } + dial_info = json.loads(ctx.job.metadata) + participant_identity = phone_number = dial_info["phone_number"] + + # Create the agent with personalized information + # In production, you would look up customer details from your database + agent = OutboundCaller( + name="Jayden", # TODO: Replace with database lookup + appointment_time="", # Not used for health insurance calls + dial_info=dial_info, + ) + + # Configure the session using Claude Sonnet 4 with Deepgram STT and Cartesia TTS + # This provides superior reasoning and natural conversation using the pipelined approach + # Voice: Cartesia provides natural-sounding male voice optimized for health insurance discussions + # session = AgentSession( + # turn_detection=EnglishModel(), # Detects when user finishes speaking + # vad=silero.VAD.load(), # Voice activity detection for better turn-taking + # stt=deepgram.STT(), # Deepgram speech-to-text (fast and accurate) + # tts=cartesia.TTS(voice="228fca29-3a0a-435c-8728-5cb483251068"), # Your selected Cartesia voice + # llm=anthropic.LLM(model="claude-sonnet-4-20250514"), # Claude Sonnet 4 (best reasoning) + # ) + + # Using Claude with pipelined approach - WITHOUT turn_detection to avoid WSL2 timeout + session = AgentSession( + # turn_detection=EnglishModel(), # DISABLED - causes WSL2 inference executor timeout + vad=silero.VAD.load( + min_silence_duration=0.3, # Reduced from default 0.5 - faster response + activation_threshold=0.4, # Lower threshold - more sensitive to speech + ), + stt=deepgram.STT(), # Deepgram speech-to-text + tts=cartesia.TTS(voice="228fca29-3a0a-435c-8728-5cb483251068"), # Your selected voice + llm=anthropic.LLM(model="claude-sonnet-4-20250514"), # Claude Sonnet 4 + ) + + # OpenAI fallback if Claude doesn't work: + # session = AgentSession( + # llm=openai.realtime.RealtimeModel(voice="echo", temperature=0.8), + # ) + + # Start the session before dialing to ensure the agent is ready when the user answers + # This prevents missing the first few seconds of what the user says + session_started = asyncio.create_task( + session.start( + agent=agent, + room=ctx.room, + room_input_options=RoomInputOptions( + # Enable Krisp noise cancellation optimized for telephony + # This removes background noise for clearer conversations + noise_cancellation=noise_cancellation.BVCTelephony(), + ), + ) + ) + + # Initiate the outbound call via SIP trunk + # This dials the phone number and waits for the user to answer + try: + await ctx.api.sip.create_sip_participant( + api.CreateSIPParticipantRequest( + room_name=ctx.room.name, + sip_trunk_id=outbound_trunk_id, # Configured SIP trunk ID + sip_call_to=phone_number, # Number to dial + participant_identity=participant_identity, # Unique identifier + wait_until_answered=True, # Block until call is answered or fails + ) + ) + + # Wait for both the session to finish starting and the participant to join + await session_started + participant = await ctx.wait_for_participant(identity=participant_identity) + logger.info(f"participant joined: {participant.identity}") + + # Give the agent a reference to the participant for call operations + agent.set_participant(participant) + + # Conversation now runs automatically until: + # - User hangs up + # - Agent calls end_call() or hangup() + # - Error occurs + + except api.TwirpError as e: + # Handle SIP errors (busy, no answer, invalid number, etc.) + 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__": + # Start the LiveKit agents worker + # This runs continuously, waiting for jobs to be dispatched + cli.run_app( + WorkerOptions( + entrypoint_fnc=entrypoint, # Function to call for each job + agent_name="outbound-caller", # Name used when dispatching jobs + ) + ) diff --git a/agent.py.backup-health-insurance b/agent.py.backup-health-insurance new file mode 100644 index 0000000..475ea09 --- /dev/null +++ b/agent.py.backup-health-insurance @@ -0,0 +1,414 @@ +""" +AI Outbound Caller Agent + +This agent makes automated phone calls using LiveKit's real-time communication platform. +It uses OpenAI's Realtime API for speech-to-speech communication, providing a natural +conversational experience for appointment confirmations and call management. + +Key Features: +- Automated appointment confirmation calls +- Call transfer functionality to human agents +- Voicemail detection +- Appointment scheduling assistance +- Natural voice conversation using OpenAI's Realtime API + +Architecture: +- Uses SIP (Session Initiation Protocol) for phone connectivity +- LiveKit for real-time audio streaming +- OpenAI Realtime API for speech-to-speech processing +- Function calling for agent actions (transfer, hangup, etc.) + +Note: This agent requires a Unix-like environment (Linux/macOS/WSL2) due to +LiveKit's IPC system requirements. It will not work on Windows/MINGW64. +""" + +from __future__ import annotations + +import os +# Attempt to disable inference executor BEFORE any other imports +# This is needed to avoid IPC timeout issues on Windows/WSL, though it may not +# fully work due to LiveKit framework limitations on Windows/MINGW64 +os.environ["LIVEKIT_DISABLE_INFERENCE_EXECUTOR"] = "1" + +import asyncio +import logging +from dotenv import load_dotenv +import json +from typing import Any + +# LiveKit core imports for real-time communication and API access +from livekit import rtc, api + +# LiveKit agents framework for building voice agents +from livekit.agents import ( + AgentSession, # Manages the conversation session + Agent, # Base class for creating agents + JobContext, # Provides context about the current job/call + function_tool, # Decorator for creating callable functions + RunContext, # Context during function execution + get_job_context, # Access to current job context + cli, # Command-line interface utilities + WorkerOptions, # Configuration for the worker + RoomInputOptions, # Configuration for room audio input +) + +# LiveKit plugins for various AI services +from livekit.plugins import ( + anthropic, # Claude AI (Primary LLM) + deepgram, # Speech-to-text + cartesia, # Text-to-speech + silero, # Voice activity detection + noise_cancellation, # Background noise removal +) +from livekit.plugins.turn_detector.english import EnglishModel # Turn detection + +# Load environment variables from .env.local file +# This includes API keys, LiveKit credentials, and SIP trunk configuration +load_dotenv(dotenv_path=".env.local") + +# Configure logging for the agent +logger = logging.getLogger("outbound-caller") +logger.setLevel(logging.INFO) + +# SIP trunk ID for making outbound calls via LiveKit +# This is configured in your LiveKit dashboard and connects to Twilio +outbound_trunk_id = os.getenv("SIP_OUTBOUND_TRUNK_ID") + + +class OutboundCaller(Agent): + """ + Outbound calling agent for appointment confirmations. + + This agent handles automated phone calls to confirm appointments, + provide scheduling assistance, and transfer calls to human agents when needed. + + Attributes: + participant: The remote participant (person being called) in the conversation + dial_info: Dictionary containing phone numbers and transfer information + """ + + def __init__( + self, + *, + name: str, + appointment_time: str, + dial_info: dict[str, Any], + ): + """ + Initialize the outbound caller agent. + + Args: + name: The customer's name for personalization + appointment_time: The scheduled appointment time to confirm + dial_info: Dictionary with 'phone_number' and 'transfer_to' keys + """ + super().__init__( + instructions=f""" + You are a knowledgeable health insurance specialist with comprehensive expertise in the health insurance industry. + Your interface with users will be voice. You are calling clients to discuss their health insurance options and answer questions. + + Your expertise includes: + - All types of health insurance plans (HMO, PPO, EPO, POS, HDHP) + - Medicare and Medicaid programs + - Coverage options, deductibles, copays, and out-of-pocket maximums + - Network providers and coverage limitations + - Claims processes and dispute resolution + - Healthcare reform and compliance (ACA, HIPAA) + - Prescription drug coverage and formularies + - Special enrollment periods and qualifying life events + + You speak with confidence and authority on health insurance matters. You are professional, patient, and able to explain + complex insurance concepts in simple terms. Always ensure clients understand their options before making decisions. + + When the user would like to be transferred to a human agent, first confirm with them. Upon confirmation, use the transfer_call tool. + The client's name is {name}. + """ + ) + # Keep reference to the participant for call operations (transfers, hangups, etc.) + self.participant: rtc.RemoteParticipant | None = None + + # Store dial information (phone numbers, transfer destination) + self.dial_info = dial_info + + def set_participant(self, participant: rtc.RemoteParticipant): + """ + Set the participant reference after they join the call. + + Args: + participant: The remote participant who answered the call + """ + self.participant = participant + + async def hangup(self): + """ + End the call by deleting the LiveKit room. + + This terminates the call and cleans up all connections. + The room deletion triggers automatic disconnection of all participants. + """ + 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 after user confirmation. + + This function is called by the AI when the user requests to speak to a human. + It uses SIP transfer to connect the call to a human agent's phone number. + + Args: + ctx: Runtime context with access to the session and agent state + + Returns: + str: Status message ("cannot transfer call" if no transfer number configured) + """ + transfer_to = self.dial_info["transfer_to"] + if not transfer_to: + return "cannot transfer call" + + logger.info(f"transferring call to {transfer_to}") + + # Generate a confirmation message and let it 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: + # Use LiveKit SIP API to transfer the call to another phone number + 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}", + ) + ) + + logger.info(f"transferred call to {transfer_to}") + except Exception as e: + logger.error(f"error transferring call: {e}") + # Notify the user of the error and end the call + 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): + """ + End the call when the user is ready to hang up. + + This tool is called by the AI when the conversation has concluded. + It ensures the agent finishes speaking before disconnecting. + + Args: + ctx: Runtime context with access to the session + """ + logger.info(f"ending the call for {self.participant.identity}") + + # Wait for the agent to finish speaking current message before hanging up + 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, + ): + """ + Look up available appointment times for rescheduling. + + This is a placeholder function that simulates checking a scheduling system. + In production, this would query your actual appointment database. + + Args: + ctx: Runtime context + date: The date to check availability for (in natural language) + + Returns: + dict: Available appointment times + """ + logger.info( + f"looking up availability for {self.participant.identity} on {date}" + ) + # Simulate database lookup delay + await asyncio.sleep(3) + # Return mock availability data - replace with real database query + return { + "available_times": ["1pm", "2pm", "3pm"], + } + + @function_tool() + async def confirm_appointment( + self, + ctx: RunContext, + date: str, + time: str, + ): + """ + Confirm an appointment for a specific date and time. + + This tool is called when the user confirms or reschedules their appointment. + In production, this would update your scheduling system. + + Args: + ctx: Runtime context + date: The appointment date + time: The appointment time + + Returns: + str: Confirmation message + """ + logger.info( + f"confirming appointment for {self.participant.identity} on {date} at {time}" + ) + # In production: Update your scheduling database here + return "reservation confirmed" + + @function_tool() + async def detected_answering_machine(self, ctx: RunContext): + """ + Handle voicemail detection. + + This tool is called by the AI when it detects that the call reached voicemail + instead of a live person. The agent should call this AFTER hearing the voicemail greeting. + + Args: + ctx: Runtime context + """ + logger.info(f"detected answering machine for {self.participant.identity}") + # End the call immediately when voicemail is detected + await self.hangup() + + +async def entrypoint(ctx: JobContext): + """ + Main entrypoint for handling outbound calls. + + This function is called by the LiveKit agents framework when a new job (call) + is dispatched. It sets up the call, initializes the AI agent, and manages the + complete call lifecycle from dialing to completion. + + Workflow: + 1. Connect to the LiveKit room + 2. Parse call metadata (phone number, transfer info) + 3. Create and configure the AI agent + 4. Set up the session with OpenAI Realtime API + 5. Start the session (begins loading models) + 6. Dial the phone number via SIP + 7. Wait for the user to answer + 8. Connect the participant to the agent + 9. Let the conversation run until completion + + Args: + ctx: Job context providing access to room, API, and job metadata + """ + logger.info(f"connecting to room {ctx.room.name}") + await ctx.connect() + + # Parse metadata passed during dispatch containing call information + # dial_info structure: + # { + # "phone_number": "+1234567890", # Number to call + # "transfer_to": "+0987654321" # Human agent number for transfers + # } + dial_info = json.loads(ctx.job.metadata) + participant_identity = phone_number = dial_info["phone_number"] + + # Create the agent with personalized information + # In production, you would look up customer details from your database + agent = OutboundCaller( + name="Jayden", # TODO: Replace with database lookup + appointment_time="", # Not used for health insurance calls + dial_info=dial_info, + ) + + # Configure the session using Claude Sonnet 4 with Deepgram STT and Cartesia TTS + # This provides superior reasoning and natural conversation using the pipelined approach + # Voice: Cartesia provides natural-sounding male voice optimized for health insurance discussions + session = AgentSession( + turn_detection=EnglishModel(), # Detects when user finishes speaking + vad=silero.VAD.load(), # Voice activity detection for better turn-taking + stt=deepgram.STT(), # Deepgram speech-to-text (fast and accurate) + tts=cartesia.TTS(voice="79f8b5fb-2cc8-479a-80df-29f7a7cf1a3e"), # Cartesia British Narration Man + llm=anthropic.LLM(model="claude-sonnet-4-20250514"), # Claude Sonnet 4 (best reasoning) + ) + + # Alternative: OpenAI Realtime API (simpler but less powerful) + # Uncomment below to switch back to OpenAI: + # + # session = AgentSession( + # llm=openai.realtime.RealtimeModel( + # voice="echo", + # temperature=0.8, + # ), + # ) + + # Start the session before dialing to ensure the agent is ready when the user answers + # This prevents missing the first few seconds of what the user says + session_started = asyncio.create_task( + session.start( + agent=agent, + room=ctx.room, + room_input_options=RoomInputOptions( + # Enable Krisp noise cancellation optimized for telephony + # This removes background noise for clearer conversations + noise_cancellation=noise_cancellation.BVCTelephony(), + ), + ) + ) + + # Initiate the outbound call via SIP trunk + # This dials the phone number and waits for the user to answer + try: + await ctx.api.sip.create_sip_participant( + api.CreateSIPParticipantRequest( + room_name=ctx.room.name, + sip_trunk_id=outbound_trunk_id, # Configured SIP trunk ID + sip_call_to=phone_number, # Number to dial + participant_identity=participant_identity, # Unique identifier + wait_until_answered=True, # Block until call is answered or fails + ) + ) + + # Wait for both the session to finish starting and the participant to join + await session_started + participant = await ctx.wait_for_participant(identity=participant_identity) + logger.info(f"participant joined: {participant.identity}") + + # Give the agent a reference to the participant for call operations + agent.set_participant(participant) + + # Conversation now runs automatically until: + # - User hangs up + # - Agent calls end_call() or hangup() + # - Error occurs + + except api.TwirpError as e: + # Handle SIP errors (busy, no answer, invalid number, etc.) + 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__": + # Start the LiveKit agents worker + # This runs continuously, waiting for jobs to be dispatched + cli.run_app( + WorkerOptions( + entrypoint_fnc=entrypoint, # Function to call for each job + agent_name="outbound-caller", # Name used when dispatching jobs + ) + ) diff --git a/create_inbound_trunk.py b/create_inbound_trunk.py new file mode 100644 index 0000000..7e79444 --- /dev/null +++ b/create_inbound_trunk.py @@ -0,0 +1,226 @@ +""" +Create Inbound SIP Trunk Configuration + +This script sets up the complete SIP trunk configuration for handling inbound calls +to your LiveKit agent. It creates both Twilio and LiveKit trunk configurations +and connects them together. + +What it does: +1. Creates a Twilio SIP trunk that routes calls to LiveKit +2. Creates a LiveKit inbound SIP trunk for your phone number +3. Sets up a dispatch rule to route calls to agent rooms + +Prerequisites: +- Twilio account with phone number +- LiveKit account with SIP configured +- lk CLI installed (LiveKit command-line tool) + +Usage: + python create_inbound_trunk.py + +Environment Variables Required: +- TWILIO_ACCOUNT_SID: Your Twilio account SID +- TWILIO_AUTH_TOKEN: Your Twilio auth token +- TWILIO_PHONE_NUMBER: Your Twilio phone number +- LIVEKIT_SIP_URI: Your LiveKit SIP URI from dashboard +""" + +import json +import logging +import os +import re +import subprocess +from dotenv import load_dotenv +from twilio.rest import Client + + +def get_env_var(var_name): + """ + Get an environment variable or exit if not set. + + Args: + var_name: Name of the environment variable + + Returns: + str: The environment variable value + + Raises: + SystemExit: If the variable is not set + """ + value = os.getenv(var_name) + if value is None: + logging.error(f"Environment variable '{var_name}' not set.") + exit(1) + return value + +def create_livekit_trunk(client, sip_uri): + """ + Create a Twilio SIP trunk that routes calls to LiveKit. + + This creates a Twilio trunk with an origination URL pointing to your + LiveKit SIP endpoint. This allows Twilio to forward incoming calls to + your LiveKit agent. + + Args: + client: Twilio client instance + sip_uri: LiveKit SIP URI (e.g., sip:xxxxx.sip.livekit.cloud) + + Returns: + Trunk: The created Twilio trunk object + """ + # Generate a unique domain name for this trunk + domain_name = f"livekit-trunk-{os.urandom(4).hex()}.pstn.twilio.com" + + # Create the SIP trunk in Twilio + trunk = client.trunking.v1.trunks.create( + friendly_name="LiveKit Trunk", + domain_name=domain_name, + ) + + # Add LiveKit as the destination for calls from this trunk + trunk.origination_urls.create( + sip_url=sip_uri, + weight=1, # Priority weight + priority=1, # Routing priority + enabled=True, + friendly_name="LiveKit SIP URI", + ) + + logging.info("Created new LiveKit Trunk.") + return trunk + + +def create_inbound_trunk(phone_number): + """ + Create a LiveKit inbound SIP trunk for receiving calls. + + This uses the LiveKit CLI to create an inbound trunk that can receive + calls from your Twilio phone number. + + Args: + phone_number: Twilio phone number in E.164 format (e.g., +1234567890) + + Returns: + str: The trunk SID, or None if creation failed + """ + # Prepare trunk configuration + trunk_data = { + "trunk": { + "name": "Inbound LiveKit Trunk", + "numbers": [phone_number] + } + } + + # Write configuration to temporary file + with open('inbound_trunk.json', 'w') as f: + json.dump(trunk_data, f, indent=4) + + # Create trunk using LiveKit CLI + result = subprocess.run( + ['lk', 'sip', 'inbound', 'create', 'inbound_trunk.json'], + capture_output=True, + text=True + ) + + if result.returncode != 0: + logging.error(f"Error executing command: {result.stderr}") + return None + + # Extract trunk SID from output (format: ST_xxxxx) + match = re.search(r'ST_\w+', result.stdout) + if match: + inbound_trunk_sid = match.group(0) + logging.info(f"Created inbound trunk with SID: {inbound_trunk_sid}") + return inbound_trunk_sid + else: + logging.error("Could not find inbound trunk SID in output.") + return None + + +def create_dispatch_rule(trunk_sid): + """ + Create a dispatch rule for routing inbound calls. + + This rule determines how incoming calls are routed to agent rooms. + Each call creates a new room with the prefix "call-". + + Args: + trunk_sid: The inbound trunk SID to associate with this rule + """ + # Configure dispatch rule + dispatch_rule_data = { + "name": "Inbound Dispatch Rule", + "trunk_ids": [trunk_sid], + "rule": { + "dispatchRuleIndividual": { + "roomPrefix": "call-" # Each call gets a unique room: call-xxxxx + } + } + } + + # Write configuration to temporary file + with open('dispatch_rule.json', 'w') as f: + json.dump(dispatch_rule_data, f, indent=4) + + # Create dispatch rule using LiveKit CLI + result = subprocess.run( + ['lk', 'sip', 'dispatch-rule', 'create', 'dispatch_rule.json'], + capture_output=True, + text=True + ) + + if result.returncode != 0: + logging.error(f"Error executing command: {result.stderr}") + return + + logging.info(f"Dispatch rule created: {result.stdout}") + + +def main(): + """ + Main function to set up complete inbound SIP trunk configuration. + + This orchestrates the entire setup process: + 1. Load environment variables + 2. Create/verify Twilio trunk + 3. Create LiveKit inbound trunk + 4. Create dispatch rule + + The script is idempotent - if the Twilio trunk already exists, it will be reused. + """ + # Load configuration from .env.local + load_dotenv(dotenv_path=".env.local") + logging.basicConfig(level=logging.INFO) + + # Get required credentials and configuration + account_sid = get_env_var("TWILIO_ACCOUNT_SID") + auth_token = get_env_var("TWILIO_AUTH_TOKEN") + phone_number = get_env_var("TWILIO_PHONE_NUMBER") + sip_uri = get_env_var("LIVEKIT_SIP_URI") + + # Initialize Twilio client + client = Client(account_sid, auth_token) + + # Check if LiveKit trunk already exists in Twilio + existing_trunks = client.trunking.v1.trunks.list() + livekit_trunk = next( + (trunk for trunk in existing_trunks if trunk.friendly_name == "LiveKit Trunk"), + None + ) + + # Create trunk if it doesn't exist, otherwise reuse existing one + if not livekit_trunk: + livekit_trunk = create_livekit_trunk(client, sip_uri) + else: + logging.info("LiveKit Trunk already exists. Using the existing trunk.") + + # Create LiveKit inbound trunk for this phone number + inbound_trunk_sid = create_inbound_trunk(phone_number) + + # If trunk creation succeeded, create dispatch rule + if inbound_trunk_sid: + create_dispatch_rule(inbound_trunk_sid) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/dispatch_from_excel.py b/dispatch_from_excel.py new file mode 100644 index 0000000..c2c4e89 --- /dev/null +++ b/dispatch_from_excel.py @@ -0,0 +1,329 @@ +""" +Bulk outbound call dispatcher. + +Reads a spreadsheet of leads (Excel .xlsx or CSV) and dispatches outbound +calls to the running LiveKit agent worker. Each row becomes one call, +personalized with the prospect's name from the spreadsheet. + +USAGE +----- + # Dispatch every lead in the file: + python dispatch_from_excel.py leads.xlsx + + # Validate the file without placing real calls: + python dispatch_from_excel.py leads.xlsx --dry-run + + # Limit how many calls run in parallel (default 3): + python dispatch_from_excel.py leads.csv --concurrency 5 + + # Only dispatch the first N rows (great for testing): + python dispatch_from_excel.py leads.xlsx --limit 1 + +REQUIRED COLUMNS +---------------- + name Customer first name (used for personalization) + phone_number E.164 format, e.g. +15551234567 + +OPTIONAL COLUMNS +---------------- + transfer_to Phone number to transfer to (falls back to + DEFAULT_TRANSFER_TO env var if absent) + +PREREQUISITES +------------- +1. The agent worker must be running in another terminal: + python agent.py start +2. .env.local must contain LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET. +3. For .xlsx support: pip install pandas openpyxl + +OUTPUT +------ +Results are appended to call_log.csv in the current directory with the +columns: timestamp, name, phone_number, status, dispatch_id, room, error. +""" + +from __future__ import annotations + +import argparse +import asyncio +import csv +import json +import logging +import os +import sys +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any + +from dotenv import load_dotenv +from livekit import api + +load_dotenv(dotenv_path=".env.local") + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("dispatcher") + +# Must match the agent_name registered in agent.py's WorkerOptions. +AGENT_NAME = "outbound-caller" + +DEFAULT_CONCURRENCY = 3 +# Throttle between dispatches inside one worker slot, to avoid hammering +# the SIP trunk all at once. +DELAY_BETWEEN_DISPATCHES_SEC = 1.0 +LOG_FILE = Path("call_log.csv") + + +def load_leads(path: Path) -> list[dict[str, str]]: + """Read leads from .xlsx or .csv and normalize the columns.""" + suffix = path.suffix.lower() + + if suffix in (".xlsx", ".xls"): + try: + import pandas as pd + except ImportError: + raise SystemExit( + "Reading Excel files requires pandas + openpyxl.\n" + "Install with: pip install pandas openpyxl" + ) + df = pd.read_excel(path, dtype=str).fillna("") + raw_rows: list[dict[str, Any]] = df.to_dict(orient="records") + elif suffix == ".csv": + with path.open(newline="", encoding="utf-8-sig") as f: + raw_rows = list(csv.DictReader(f)) + else: + raise SystemExit( + f"Unsupported file type: {suffix}. Use .xlsx, .xls, or .csv" + ) + + normalized: list[dict[str, str]] = [] + # Start at row 2 because row 1 is the header in user-facing terms. + for i, row in enumerate(raw_rows, start=2): + # Case-insensitive column lookup so 'Name', 'NAME', 'name' all work. + lookup = { + (k or "").strip().lower(): str(v or "").strip() + for k, v in row.items() + if k + } + name = lookup.get("name", "") + phone = ( + lookup.get("phone_number") + or lookup.get("phone") + or lookup.get("number") + or "" + ) + transfer_to = lookup.get("transfer_to", "") + + if not phone: + logger.warning(f"Row {i}: missing phone_number, skipping") + continue + + if not name: + logger.warning(f"Row {i}: missing name, defaulting to 'there'") + name = "there" + + # Force E.164: must start with '+'. + if not phone.startswith("+"): + cleaned = "".join(c for c in phone if c.isdigit()) + if len(cleaned) == 10: + # Assume US number. + phone = "+1" + cleaned + else: + phone = "+" + cleaned + logger.warning( + f"Row {i}: phone normalized to {phone} " + "(verify country code is correct)" + ) + + normalized.append( + { + "row": str(i), + "name": name, + "phone_number": phone, + "transfer_to": transfer_to, + } + ) + + return normalized + + +def append_log(entry: dict[str, str]) -> None: + """Append a single result row to call_log.csv (creates header if new).""" + new_file = not LOG_FILE.exists() + fieldnames = [ + "timestamp", + "name", + "phone_number", + "status", + "dispatch_id", + "room", + "error", + ] + with LOG_FILE.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + if new_file: + writer.writeheader() + writer.writerow(entry) + + +async def dispatch_one( + lk: api.LiveKitAPI | None, + lead: dict[str, str], + default_transfer: str, + dry_run: bool, +) -> None: + """Dispatch one outbound call for a single lead row.""" + transfer_to = lead["transfer_to"] or default_transfer + metadata = { + "name": lead["name"], + "phone_number": lead["phone_number"], + "transfer_to": transfer_to, + } + room_name = ( + f"outbound-{lead['phone_number'].lstrip('+')}-{uuid.uuid4().hex[:6]}" + ) + + log_entry: dict[str, str] = { + "timestamp": datetime.now().isoformat(timespec="seconds"), + "name": lead["name"], + "phone_number": lead["phone_number"], + "status": "", + "dispatch_id": "", + "room": room_name, + "error": "", + } + + if dry_run or lk is None: + logger.info( + f"[DRY RUN] Would call {lead['name']} @ {lead['phone_number']} " + f"(transfer_to={transfer_to or 'NONE'})" + ) + log_entry["status"] = "dry_run" + append_log(log_entry) + return + + try: + request = api.CreateAgentDispatchRequest( + agent_name=AGENT_NAME, + room=room_name, + metadata=json.dumps(metadata), + ) + dispatch = await lk.agent_dispatch.create_dispatch(request) + logger.info( + f"Dispatched -> {lead['name']} @ {lead['phone_number']} " + f"(dispatch_id={dispatch.id})" + ) + log_entry["status"] = "dispatched" + log_entry["dispatch_id"] = dispatch.id + except Exception as e: + logger.error( + f"Failed dispatch for {lead['name']} @ {lead['phone_number']}: {e}" + ) + log_entry["status"] = "error" + log_entry["error"] = str(e) + + append_log(log_entry) + + +async def run(args: argparse.Namespace) -> None: + path = Path(args.file) + if not path.exists(): + raise SystemExit(f"File not found: {path}") + + leads = load_leads(path) + if not leads: + raise SystemExit("No valid leads found in file.") + + if args.limit and args.limit > 0: + leads = leads[: args.limit] + logger.info(f"--limit applied: only first {len(leads)} leads") + + logger.info(f"Loaded {len(leads)} leads from {path.name}") + + default_transfer = os.getenv("DEFAULT_TRANSFER_TO", "") + missing_transfer = [l for l in leads if not l["transfer_to"]] + if missing_transfer and not default_transfer: + logger.warning( + f"{len(missing_transfer)} rows have no transfer_to and " + "DEFAULT_TRANSFER_TO is not set in .env.local — those calls " + "will not be transferable." + ) + + # Confirm before placing live calls (skip if --yes or --dry-run). + if not args.dry_run and not args.yes: + print() + print(f"About to dispatch {len(leads)} REAL phone calls.") + print(f"Concurrency: {args.concurrency}") + print(f"Default transfer: {default_transfer or '(none)'}") + print() + answer = input("Type 'yes' to proceed: ").strip().lower() + if answer != "yes": + raise SystemExit("Aborted.") + + lk: api.LiveKitAPI | None = None + if not args.dry_run: + # LiveKitAPI reads LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET + # from environment (loaded above from .env.local). + lk = api.LiveKitAPI() + + semaphore = asyncio.Semaphore(args.concurrency) + + async def worker(lead: dict[str, str]) -> None: + async with semaphore: + await dispatch_one(lk, lead, default_transfer, args.dry_run) + await asyncio.sleep(DELAY_BETWEEN_DISPATCHES_SEC) + + try: + await asyncio.gather(*(worker(lead) for lead in leads)) + finally: + if lk is not None: + await lk.aclose() + + logger.info(f"Done. Results written to {LOG_FILE.resolve()}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Bulk dispatch outbound calls from an Excel/CSV file.", + ) + parser.add_argument("file", help="Path to leads.xlsx or leads.csv") + parser.add_argument( + "--concurrency", + type=int, + default=DEFAULT_CONCURRENCY, + help=( + f"Max simultaneous calls (default: {DEFAULT_CONCURRENCY}). " + "Keep low until you trust your trunk capacity." + ), + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse and validate the file without placing real calls.", + ) + parser.add_argument( + "--limit", + type=int, + default=0, + help="Only dispatch the first N rows. 0 = no limit.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Skip the interactive confirmation prompt.", + ) + args = parser.parse_args() + + try: + asyncio.run(run(args)) + except KeyboardInterrupt: + print("\nInterrupted by user.", file=sys.stderr) + sys.exit(130) + + +if __name__ == "__main__": + main() diff --git a/dispatch_rule.json b/dispatch_rule.json new file mode 100644 index 0000000..2bf9607 --- /dev/null +++ b/dispatch_rule.json @@ -0,0 +1,11 @@ +{ + "name": "Inbound Dispatch Rule", + "trunk_ids": [ + "ST_BjUe9XY6bxXL" + ], + "rule": { + "dispatchRuleIndividual": { + "roomPrefix": "call-" + } + } +} \ No newline at end of file diff --git a/inbound_trunk.json b/inbound_trunk.json new file mode 100644 index 0000000..33f58e2 --- /dev/null +++ b/inbound_trunk.json @@ -0,0 +1,8 @@ +{ + "trunk": { + "name": "Inbound LiveKit Trunk", + "numbers": [ + "+18555115623" + ] + } +} \ No newline at end of file diff --git a/leads_sample.csv b/leads_sample.csv new file mode 100644 index 0000000..0b96477 --- /dev/null +++ b/leads_sample.csv @@ -0,0 +1,4 @@ +name,phone_number,transfer_to +Jayden,+15555550101,+15555559999 +Sarah,+15555550102,+15555559999 +Mike,+15555550103, diff --git a/make_call.py b/make_call.py new file mode 100644 index 0000000..68452c5 --- /dev/null +++ b/make_call.py @@ -0,0 +1,84 @@ +""" +Script to trigger an outbound call via LiveKit agent dispatch. + +This script dispatches a job to the LiveKit agent, which will then +make an outbound call to the specified number. +""" + +import os +import json +import asyncio +from dotenv import load_dotenv +from livekit import api + +# Load environment variables +load_dotenv(dotenv_path=".env.local") + + +async def dispatch_outbound_call(phone_number: str, transfer_number: str | None = None): + """ + Dispatch an outbound call job to the LiveKit agent. + + Args: + phone_number: Phone number to call (E.164 format, e.g., +19415180701) + transfer_number: Phone number for transferring to human agent (optional) + """ + # Get LiveKit credentials from environment + url = os.getenv("LIVEKIT_URL") + api_key = os.getenv("LIVEKIT_API_KEY") + api_secret = os.getenv("LIVEKIT_API_SECRET") + + if not all([url, api_key, api_secret]): + raise ValueError("Missing LiveKit credentials in .env.local") + + # Default transfer number to Twilio phone if not provided + if not transfer_number: + transfer_number = os.getenv("MAX_PHONE_NUMBER", "+19412314887") + + # Create the metadata with call information + metadata = json.dumps({ + "phone_number": phone_number, + "transfer_to": transfer_number + }) + + print(f"Dispatching outbound call to {phone_number}...") + print(f"Transfer number: {transfer_number}") + print(f"Metadata: {metadata}") + + # Create LiveKit API client + lk_api = api.LiveKitAPI( + url=url, + api_key=api_key, + api_secret=api_secret, + ) + + # Dispatch the job to the agent + # This creates a room and dispatches the job to a worker + dispatch = await lk_api.agent_dispatch.create_dispatch( + api.CreateAgentDispatchRequest( + agent_name="outbound-caller", # Must match agent_name in agent.py + room="outbound-call-" + phone_number.replace("+", ""), # Unique room name + metadata=metadata, + ) + ) + + print(f"\n✓ Call dispatched successfully!") + print(f" Dispatch ID: {dispatch.id}") + print(f" Room: {dispatch.room}") + print(f" Agent: {dispatch.agent_name}") + print(f"\nThe agent will now call {phone_number}...") + print("Check the agent logs for call progress.") + + await lk_api.aclose() + + +if __name__ == "__main__": + # Get phone number from environment or use default + phone_to_call = os.getenv("TWILIO_TO_NUMBER", "+19415180701") + + print(f"\n{'='*60}") + print("LiveKit Outbound Caller - Make Call") + print(f"{'='*60}\n") + + # Run the dispatch + asyncio.run(dispatch_outbound_call(phone_to_call)) diff --git a/requirements.txt b/requirements.txt index 819e3f8..b0abfeb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ livekit>=1.0 -livekit-agents[openai,deepgram,cartesia,silero,turn_detector]~=1.0 +livekit-agents[openai,deepgram,cartesia,silero,turn_detector,anthropic]~=1.0 livekit-plugins-noise-cancellation~=0.2 python-dotenv~=1.0 +twilio~=9.3 +# Used by dispatch_from_excel.py to read leads spreadsheets. +pandas~=2.2 +openpyxl~=3.1 diff --git a/start-agent.sh b/start-agent.sh new file mode 100644 index 0000000..f50dcd2 --- /dev/null +++ b/start-agent.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# +# Start LiveKit AI Agent Script +# +# This script properly sets environment variables before starting the agent. +# It attempts to disable the inference executor to avoid IPC timeout issues +# on Windows/MINGW64 systems (though this may not fully work due to platform +# limitations - WSL2 or Linux is recommended). +# +# Usage: +# ./start-agent.sh dev # Development mode with hot-reload +# ./start-agent.sh start # Production mode +# +# Note: This agent requires a Unix-like environment (Linux/macOS/WSL2) for +# full functionality. Windows/MINGW64 has known compatibility issues. + +# Set environment variable to attempt disabling inference executor +# This may not fully work on Windows/MINGW64 due to IPC system limitations +export LIVEKIT_DISABLE_INFERENCE_EXECUTOR=1 + +# Start the agent with all provided arguments +python agent.py "$@" diff --git a/test_call_max.py b/test_call_max.py new file mode 100644 index 0000000..89b1217 --- /dev/null +++ b/test_call_max.py @@ -0,0 +1,14 @@ +"""Quick test call to Max's number""" +import asyncio +from make_call import dispatch_outbound_call + +if __name__ == "__main__": + print("\n" + "="*60) + print("Test Call to Max: +19415180701") + print("="*60 + "\n") + + # Call Max's number, transfer back to Max (same number for testing) + asyncio.run(dispatch_outbound_call( + phone_number="+19415180701", + transfer_number="+19415180701" + )) diff --git a/test_call_to_max.py b/test_call_to_max.py new file mode 100644 index 0000000..dbb2965 --- /dev/null +++ b/test_call_to_max.py @@ -0,0 +1,55 @@ +""" +Test outbound call that transfers to Max +""" +import os +import json +import asyncio +from dotenv import load_dotenv +from livekit import api + +load_dotenv(dotenv_path=".env.local") + +async def test_call_with_transfer(phone_to_call: str, max_phone: str): + """Test call that transfers to Max when prospect agrees.""" + url = os.getenv("LIVEKIT_URL") + api_key = os.getenv("LIVEKIT_API_KEY") + api_secret = os.getenv("LIVEKIT_API_SECRET") + + metadata = json.dumps({ + "phone_number": phone_to_call, + "transfer_to": max_phone # Max's number for transfer + }) + + print(f"\n{'='*60}") + print("TEST CALL - Transfer to Max") + print(f"{'='*60}") + print(f"\nCalling: {phone_to_call}") + print(f"Will transfer to: {max_phone}") + print(f"\nWhen John asks about health insurance, agree to the quote.") + print(f"John will say: 'Perfect! I'm going to get you over to my top agent Max...'") + print(f"Then he'll transfer you to Max at {max_phone}") + print(f"{'='*60}\n") + + lk_api = api.LiveKitAPI(url=url, api_key=api_key, api_secret=api_secret) + + dispatch = await lk_api.agent_dispatch.create_dispatch( + api.CreateAgentDispatchRequest( + agent_name="outbound-caller", + room="test-call-" + phone_to_call.replace("+", ""), + metadata=metadata, + ) + ) + + print(f"Call dispatched! Dispatch ID: {dispatch.id}") + print(f"Check agent logs for progress...") + + await lk_api.aclose() + +if __name__ == "__main__": + # YOUR PHONE (will receive the call from John) + your_phone = "+19415180701" + + # MAX'S PHONE (where call transfers when you agree to quote) + max_phone = "+1XXXXXXXXXX" # ← PUT MAX'S NUMBER HERE + + asyncio.run(test_call_with_transfer(your_phone, max_phone)) diff --git a/twilio_caller.py b/twilio_caller.py new file mode 100644 index 0000000..0884689 --- /dev/null +++ b/twilio_caller.py @@ -0,0 +1,63 @@ +"""Twilio Outbound Caller Script""" +import os +from dotenv import load_dotenv +from twilio.rest import Client + +# Load environment variables from .env.local +load_dotenv(dotenv_path=".env.local") + + +def make_call(to_number: str, from_number: str = None, twiml_url: str = None): + """ + Make an outbound call using Twilio + + Args: + to_number: Phone number to call (E.164 format, e.g., +19413230041) + from_number: Your Twilio phone number (optional, uses env var if not provided) + twiml_url: URL with TwiML instructions (optional, uses default demo if not provided) + + Returns: + Call SID if successful + """ + # Get credentials from environment variables + account_sid = os.getenv("TWILIO_ACCOUNT_SID") + auth_token = os.getenv("TWILIO_AUTH_TOKEN") + + if not account_sid or not auth_token: + raise ValueError("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set in .env.local") + + # Use environment variable for from_number if not provided + if not from_number: + from_number = os.getenv("TWILIO_PHONE_NUMBER") + if not from_number: + raise ValueError("TWILIO_PHONE_NUMBER must be set in .env.local or passed as argument") + + # Use default demo TwiML if not provided + if not twiml_url: + twiml_url = "http://demo.twilio.com/docs/voice.xml" + + # Initialize Twilio client + client = Client(account_sid, auth_token) + + # Make the call + call = client.calls.create( + url=twiml_url, + to=to_number, + from_=from_number + ) + + print(f"Call initiated successfully!") + print(f"Call SID: {call.sid}") + print(f"Status: {call.status}") + + return call.sid + + +if __name__ == "__main__": + # Example usage + try: + # Make a call to the specified number + to = os.getenv("TWILIO_TO_NUMBER", "+19413230041") + make_call(to_number=to) + except Exception as e: + print(f"Error making call: {e}") diff --git a/update_to_aggressive.py b/update_to_aggressive.py new file mode 100644 index 0000000..000cf98 --- /dev/null +++ b/update_to_aggressive.py @@ -0,0 +1,19 @@ +# Update script to change agent to aggressive sales version +import shutil + +# Backup current agent +shutil.copy("agent.py", "agent.py.backup-health-insurance") +print("✓ Backed up current agent.py") + +# Read the aggressive sales script +aggressive_script = open("agent_aggressive_template.txt").read() + +# Write it to agent.py +with open("agent.py", "w") as f: + f.write(aggressive_script) + +print("✓ Updated agent.py with aggressive sales script!") +print("\nNext steps:") +print("1. Delete cloud agent: lk agent delete --id CA_au4RYoVknRyg") +print("2. Restart local agent: python agent.py start") +print("3. Make test call: python make_call.py") diff --git a/wsl-setup.sh b/wsl-setup.sh new file mode 100644 index 0000000..29cd92c --- /dev/null +++ b/wsl-setup.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# +# WSL2 Setup Script for LiveKit Agent +# +# This script sets up the Python environment in WSL2 and installs +# all required dependencies for running the LiveKit outbound caller agent. +# +# Usage: +# 1. Open WSL2 terminal (Ubuntu) +# 2. cd /mnt/d/Coding-Projects/outbound-caller-python +# 3. chmod +x wsl-setup.sh +# 4. ./wsl-setup.sh + +echo "=========================================" +echo "LiveKit Agent - WSL2 Setup" +echo "=========================================" +echo "" + +# Check if running in WSL +if ! grep -q microsoft /proc/version; then + echo "❌ ERROR: This script must be run in WSL2, not Windows!" + echo "Please open a WSL2 terminal and try again." + exit 1 +fi + +echo "✓ Running in WSL2" +echo "" + +# Update package lists +echo "📦 Updating package lists..." +sudo apt-get update + +# Install Python 3.11 if not present +if ! command -v python3.11 &> /dev/null; then + echo "📦 Installing Python 3.11..." + sudo apt-get install -y python3.11 python3.11-venv python3-pip +else + echo "✓ Python 3.11 already installed" +fi + +# Create virtual environment if it doesn't exist +if [ ! -d "venv" ]; then + echo "🔨 Creating virtual environment..." + python3.11 -m venv venv +else + echo "✓ Virtual environment already exists" +fi + +# Activate virtual environment +echo "🔄 Activating virtual environment..." +source venv/bin/activate + +# Upgrade pip +echo "⬆️ Upgrading pip..." +pip install --upgrade pip + +# Install requirements +echo "📦 Installing Python dependencies..." +pip install -r requirements.txt + +echo "" +echo "=========================================" +echo "✅ Setup Complete!" +echo "=========================================" +echo "" +echo "To run the agent:" +echo " 1. Activate the virtual environment:" +echo " source venv/bin/activate" +echo "" +echo " 2. Start the agent:" +echo " python agent.py start" +echo "" +echo " Or use the convenience script:" +echo " ./start-agent.sh start" +echo ""