All projects
Voice agentMarch 2026Illustrative

After-hours calls answered, zero hallucinated answers

Built for

SG CPA, a certified public accounting firm in Plano, Texas

Project

SG CPA voice receptionist

Twilio hands each utterance to FastAPI, Claude answers from a strict business-facts prompt, ElevenLabs speaks it, and Polly takes over if ElevenLabs fails.

02

Demo

SG CPA receptioniststandby

Incoming call from Caller

0 / 11

Live signal

awaiting first turn…

turn
0/11
agent replies
0

Illustrative call, authored from the real system prompt.

Illustrative transcript authored from the agent's real system prompt; no model call.

03

How it works

Architecture diagram
  1. 01

    Twilio Gather webhooks instead of media streams: simpler, cheaper, good enough for Q&A calls.

  2. 02

    System prompt is templated from business_info.json so the same code serves any firm.

  3. 03

    Claude called with max 256 tokens and temperature 0.3 to keep answers short and literal.

  4. 04

    ElevenLabs TTS with automatic Polly fallback so a vendor outage never drops a call.

  5. 05

    Health endpoint and Railway config for one-command deploys.

05

Stack and code

system_prompt.txt
You are the AI phone receptionist for {business_name}, a {industry} firm.

Your job is to answer inbound phone calls and help callers with basic business information. You must be professional, warm, and concise.

BUSINESS INFORMATION:
- Business Name: {business_name}
- Address: {address}
- Hours of Operation: Monday through Friday, 9:00 AM to 5:00 PM CST. Closed Saturday and Sunday.
- Services: {services}

RESPONSE RULES:
- Keep answers to 1-2 sentences maximum. Callers are listening, not reading.
- Use a professional, friendly tone appropriate for a CPA firm.
- Only provide information listed above. Do not speculate or make up information.
- If asked about pricing, appointments, or specific tax questions, say: "I'd be happy to help with that. Please call us during business hours, Monday through Friday, 9 AM to 5 PM, and one of our team members can assist you."
- If the caller's question is unclear, politely ask them to repeat it.
- Do not discuss topics unrelated to the business.

~/Documents/SG_Receptionist/data/system_prompt.txt

claude_client.py
import logging
from typing import Optional
import anthropic

logger = logging.getLogger(__name__)

FALLBACK_RESPONSE = (
    "I'm sorry, I'm having trouble right now. "
    "Our office hours are Monday through Friday, 9 AM to 5 PM. "
    "Please call back during business hours for assistance."
)


class ClaudeClient:
    def __init__(self, api_key: str, system_prompt: str):
        self.client = anthropic.AsyncAnthropic(api_key=api_key)
        self.system_prompt = system_prompt

    async def generate_response(
        self,
        user_message: str,
        conversation_history: Optional[list] = None,
    ) -> str:
        messages = list(conversation_history or [])
        messages.append({"role": "user", "content": user_message})

        try:
            response = await self.client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=256,
                temperature=0.3,
                system=self.system_prompt,
                messages=messages,
            )
            for block in response.content:
                if block.type == "text":
                    return block.text
            return FALLBACK_RESPONSE
        except Exception:
            logger.exception("Claude API error")
            return FALLBACK_RESPONSE

~/Documents/SG_Receptionist/app/claude_client.py

voice_handler.py
import logging
import hashlib
from pathlib import Path
from typing import Dict, List
from twilio.twiml.voice_response import VoiceResponse, Gather

logger = logging.getLogger(__name__)

AUDIO_DIR = Path("/tmp/sg_cpa_audio")
AUDIO_DIR.mkdir(exist_ok=True)


def save_audio_temp(audio_bytes: bytes, call_sid: str) -> str:
    filename = f"resp_{hashlib.md5(call_sid.encode() + audio_bytes[:16]).hexdigest()[:12]}.mp3"
    path = AUDIO_DIR / filename
    path.write_bytes(audio_bytes)
    return f"/audio/{filename}"


class VoiceHandler:
    def __init__(self, app_base_url: str, claude_client, elevenlabs_client):
        self.app_base_url = app_base_url
        self.claude_client = claude_client
        self.elevenlabs_client = elevenlabs_client
        self.conversations: Dict[str, List[dict]] = {}

    def build_greeting(self) -> VoiceResponse:
        response = VoiceResponse()
        gather = Gather(
            input="speech",
            action="/voice/respond",
            method="POST",
            speech_timeout="5",
            language="en-US",
        )
        gather.say(
            "Thank you for calling SG CPA. How can I help you today?",
            voice="Polly.Joanna",
        )
        response.append(gather)
        response.say(
            "I didn't catch that. Thank you for calling SG CPA. Goodbye.",
            voice="Polly.Joanna",
        )
        return response

    async def handle_speech(self, speech_text: str, call_sid: str) -> VoiceResponse:
        history = self.conversations.get(call_sid, [])

        ai_response = await self.claude_client.generate_response(
            speech_text, conversation_history=history
        )

        history.append({"role": "user", "content": speech_text})
        history.append({"role": "assistant", "content": ai_response})
        self.conversations[call_sid] = history

        response = VoiceResponse()

        audio_bytes = await self.elevenlabs_client.synthesize(ai_response)
        if audio_bytes:
            audio_path = save_audio_temp(audio_bytes, call_sid)
            audio_url = f"{self.app_base_url}{audio_path}"
            gather = Gather(
                input="speech",
                action="/voice/respond",
                method="POST",
                speech_timeout="5",
                language="en-US",
            )
            gather.play(audio_url)
            response.append(gather)
        else:
            logger.warning("ElevenLabs failed, falling back to Twilio TTS")
            gather = Gather(
                input="speech",
                action="/voice/respond",
                method="POST",
                speech_timeout="5",
                language="en-US",
            )
            gather.say(ai_response, voice="Polly.Joanna")
            response.append(gather)

        response.say(
            "Thank you for calling SG CPA. Have a great day. Goodbye.",
            voice="Polly.Joanna",
        )
        return response

    def build_goodbye(self) -> VoiceResponse:
        response = VoiceResponse()
        response.say(
            "Thank you for calling SG CPA. Have a great day. Goodbye.",
            voice="Polly.Joanna",
        )
        response.hangup()
        return response

    def cleanup_call(self, call_sid: str) -> None:
        self.conversations.pop(call_sid, None)

~/Documents/SG_Receptionist/app/voice_handler.py