After-Hours AI Voice Agent
The Problem
Calls after 5pm go to voicemail. Emergency calls wait until morning. The customer calls your competitor. This automation puts an AI voice agent on your line that answers, qualifies whether it is an emergency or routine call, collects details, and routes emergencies to on-call. Note: AI voice quality is improving fast but is not perfect yet. Best used as an after-hours safety net, not a full phone replacement. For most contractors, start with the SMS chatbot recipe and add voice once you are comfortable with AI handling customer interactions.
How It Works
Inbound calls to business line after hours
AI voice agent answers, qualifies emergency vs routine, collects details, checks availability. Emergencies route to on-call.
Emergency calls handled real-time. Routine calls booked. Zero lost after-hours leads.
Importable Templates
PRD
# Product Requirements Document
Recipe 021 -- After-Hours AI Voice Agent
THE AI TRADES Platform
---
Recipe Slug: `after-hours-ai-voice-agent`
Recipe Number: 021
Rank: 20 | Tier: 3
Difficulty: Replit Build
Time Estimate: Half day
Category: Call & Lead Handling
Spend Replaces: Answering service ($300-1,500/mo)
Roles: Owner, CSR, Dispatcher
Trades: HVAC, Plumbing, Electrical
Software: Twilio, Vapi, Synthflow
Principles: Speed Wins the Job, Look 10x Your Size
---
Reusable Modules Referenced:
- Module 0: UX Philosophy (`modules/ux-philosophy-module.md`)
- Module 1: CRM Connector (`modules/crm-integration-module.md`)
- Module 3: FAQ / Knowledge Base (`modules/faq-knowledge-base-module.md`)
- Module 8: Analytics Dashboard (`modules/analytics-dashboard-module.md`)
- Module 10: Settings Panel (`modules/settings-panel-module.md`)
- Module 11: Notification / Toast (`modules/notification-toast-module.md`)
- SMS AI Conversation Module (`modules/sms-ai-conversation-module.md`)
Integration Docs (include with build):
- Twilio (`integrations/twilio.md`) -- SMS, call forwarding, webhook setup, recording
- Vapi (`integrations/vapi.md`) -- AI voice agent, assistant config, function calling, call recording
---
Table of Contents
3. Emergency vs Routine Classification Logic
4. Voice Agent Conversation Flow
5. Vapi/Synthflow Configuration
10. Data Model
13. Example: Overnight Session with 4 Calls
---
1. Recipe Overview
Calls after 5pm go to voicemail. Emergency calls wait until morning. The customer calls your competitor.
A plumber closes shop at 5pm on a Friday. Between 5pm and 8am Monday, 11 calls come in. Three are emergencies: a burst pipe at 9pm Friday, a gas smell Saturday morning, a flooded basement Sunday night. All three go to voicemail. The burst pipe customer calls another plumber at 9:03pm and pays $1,800. The gas smell customer finds someone on Google at 7:15am Saturday. The flooded basement customer has a competitor on site by 10pm Sunday. That is $5,400 in emergency revenue gone because nobody was answering.
The other 8 calls are routine: tune-up requests, quote inquiries, appointment scheduling. Those callers leave voicemails. Some wait for a callback Monday morning. Most do not. They go down the Google list until someone picks up.
This recipe puts an AI voice agent on your business line after hours. The agent answers every call, qualifies whether it is an emergency or routine request, collects the details a dispatcher needs, and routes emergencies to the on-call tech in real time. Routine calls get booked or logged for morning follow-up. Every call generates a structured lead card. The dispatcher opens a sorted digest at 6am with zero guesswork.
Honest caveat: AI voice quality is improving fast but is not perfect yet. Callers will sometimes notice they are talking to an AI. This is best deployed as an after-hours safety net, not a full phone replacement. The bar is not "sounds exactly like a human." The bar is "better than voicemail, better than an answering service, and catches every emergency."
Input: Inbound calls to business line after hours.
Transformation: AI voice agent answers, qualifies emergency vs routine, collects details, checks availability. Emergencies route to on-call.
Output: Emergency calls handled real-time. Routine calls booked. Zero lost after-hours leads.
---
2. Strategy Brief
The Business Villain
"The After-Hours Black Hole"
Every home service contractor faces the same problem at 5pm: the phone keeps ringing, but nobody is there to answer it. The villain is not the missed call itself. Contractors know they miss after-hours calls. The villain is what does not happen next. The caller gets a voicemail greeting, a beep, and silence. The emergency sits in a queue for 14 hours. The routine request goes cold.
The compounding damage:
- 35-40% of inbound calls to home service businesses arrive after business hours (ServiceTitan data)
- Emergency calls after hours carry 2-3x the average job value (higher urgency = higher willingness to pay premium rates)
- A missed emergency call at 10pm does not wait until 8am. The customer calls the next plumber, electrician, or HVAC company within 5 minutes.
- Answering services cost $300-$1,500/mo and deliver scripted, error-prone interactions. Agents do not know your trade. They cannot distinguish a gas smell from a dripping faucet. They put everyone on the same callback list.
- The average contractor loses $3,000-$8,000/month in after-hours emergency revenue alone
The real cost of voicemail: It is not just the emergency jobs you lose. It is the routine callers who wanted to book a tune-up at 7pm but got voicemail and decided to "call someone else tomorrow." Tomorrow never comes. They search Google the next morning and the first company that answers gets the job.
The Behavioral Solution
"From Voicemail Graveyard to 24/7 Coverage"
| Before (Current State) | After (With Voice Agent) |
|---|---|
| After-hours call = voicemail | After-hours call = live AI conversation |
| Emergency sits until morning | Emergency reaches on-call tech within 2 minutes |
| Routine caller hangs up, calls competitor | Routine caller is booked or logged instantly |
| No data on overnight call volume | Full call log with transcripts and classifications |
| Owner checks voicemail at 8am, plays catch-up | Dispatcher opens sorted digest at 6am, starts calling |
| Answering service fumbles trade-specific questions | AI trained on your services, your area, your hours |
| "We're a small shop, nobody here after 5" | "Somebody always picks up when you call us" |
The trust ladder:
1. Night 1: Owner sees the AI handled a 9pm call, collected info, logged a lead card. Feels relief.
2. Week 1: On-call tech gets an emergency alert at 11pm. Calls the customer back in 3 minutes. Books a $2,200 emergency job. Feels ROI.
3. Week 2: Dispatcher opens Monday morning digest. 9 calls sorted by priority. Three already handled. Feels efficiency.
4. Month 1: Owner realizes they have not listened to a voicemail in 3 weeks. Feels freedom.
Core Loop
```
[After-Hours Call Arrives]
-> [AI Voice Agent Answers]
-> [Qualification: Emergency or Routine?]
-> [Emergency Path] -> Collect details -> Route to on-call -> Confirm with caller
-> [Routine Path] -> Collect details -> Offer booking -> Generate lead card
-> [All Calls] -> Store transcript + lead card -> Add to morning digest
```
---
3. Emergency vs Routine Classification Logic
The AI agent must make a binary classification within the first 60 seconds of every call: is this an emergency that requires immediate human contact, or a routine request that can wait until business hours? Getting this wrong in either direction has consequences. A missed emergency costs thousands in lost revenue and puts people at risk. An over-classified routine call wakes the on-call tech at 2am for a dripping faucet.
Emergency Classification (Immediate Alert to On-Call)
Response: Route to on-call within 2 minutes.
| Category | Keyword Triggers |
|---|---|
| Water | flooding, burst pipe, water pouring, sewage backup, water everywhere, water coming through ceiling, water spraying, sump pump failed, sewer backing up |
| Gas | gas smell, smell gas, gas leak, carbon monoxide, CO detector, CO alarm, rotten egg smell |
| Heat (cold weather) | no heat, furnace out, furnace died, house is freezing, pipes freezing, frozen pipes, boiler down, heat stopped |
| AC (vulnerable persons) | no AC + elderly, baby, infant, medical condition, heat stroke risk |
| Electrical | sparking, sparks, burning smell, electrical fire, exposed wires, shock, outlet smoking, panel buzzing, arcing |
| Safety | dangerous, emergency, urgent, life-threatening |
Contextual amplifiers that upgrade to Emergency:
- Caller mentions children, elderly, or medical conditions
- Caller sounds distressed, panicked, or is speaking rapidly
- Caller explicitly says "this is an emergency" or "I need someone now"
- Active water flow (not a drip, not a stain, but water currently moving)
- Temperature extremes (caller mentions freezing, extreme heat, or weather context that implies danger)
Routine Classification (Log for Morning Follow-Up)
Response: Generate lead card, offer to book, include in morning digest.
| Category | Keyword Triggers |
|---|---|
| Maintenance | tune-up, maintenance, inspection, seasonal service, annual check, filter replacement |
| Minor repair | dripping faucet, running toilet, slow drain, thermostat issue, outlet not working |
| Quotes/Estimates | estimate, quote, bid, how much, pricing, cost of, ballpark |
| Scheduling | schedule, appointment, book, available, next week, when can you come |
| General inquiry | do you offer, do you service, service area, question about, hours |
| Non-service | warranty, billing, invoice, payment, receipt |
Time-of-Day Weighting
Calls arriving between 10pm and 5am get a slight bias toward emergency classification. The reasoning: a person calling a plumber at 2am is almost never asking about a tune-up. If the AI is unsure, the after-midnight bias tips the decision toward emergency.
| Time Window | Classification Bias |
|---|---|
| 5pm - 10pm | Neutral. Classify based on content. |
| 10pm - 5am | Lean emergency. If ambiguous, classify as emergency and let on-call tech make the final call. |
| 5am - 8am | Neutral. Many routine callers call early before work. |
Caller Tone Detection
The AI agent listens for emotional signals in the caller's voice. This is not sentiment analysis for analytics. This is a safety net for cases where the words alone do not convey urgency but the tone does.
| Tone Signal | AI Interpretation |
|---|---|
| Calm, measured, patient | Supports routine classification |
| Frustrated but controlled | Supports same-day priority, not emergency |
| Worried, rapid speech, elevated pitch | Upgrade consideration toward emergency |
| Panicked, crying, shouting | Override to emergency regardless of keywords |
Override Rules
1. Safety-first override: If the caller mentions gas, CO, or electrical sparking, classify as emergency regardless of tone or time. These are life-safety issues.
2. Explicit request: If the caller says "this is an emergency" or "I need someone tonight," classify as emergency. Respect the caller's own assessment.
3. Spam/vendor detection: If the AI detects a sales pitch, marketing offer, or robocall pattern, classify as SPAM. Do not route. Do not generate a lead card. Log and discard.
4. Repeat caller: If the same phone number has called in the last 4 hours, bump the classification up one level. They are calling back because the situation is getting worse.
---
4. Voice Agent Conversation Flow
The AI agent follows a structured conversation tree. Every call moves through five stages: Greeting, Qualification, Path Selection, Detail Collection, and Disposition. The agent should sound warm, competent, and direct. Not corporate. Not robotic. Think "friendly office manager who knows the trade."
Stage 1: Greeting (5-10 seconds)
```
"Hi, thanks for calling [Business Name]. This is [AI Name], [Owner]'s
after-hours assistant. How can I help you tonight?"
```
Rules:
- Use the contractor's business name and owner name (from profile)
- AI name is configurable (default: "Alex")
- Say "tonight" if after 8pm, "this evening" if 5-8pm, "this morning" if 5-8am
- If the caller starts talking before the greeting finishes, the AI yields immediately and listens
Stage 2: Qualification (15-30 seconds)
The AI listens to the caller describe their situation. No interrupting. No clarifying questions yet. Let them talk. After they finish:
If Emergency signals detected:
```
"That sounds like it needs immediate attention. Let me get some quick
details so I can reach our on-call technician right away."
```
If Routine signals detected:
```
"Got it. I can help with that. Let me grab a few details and we'll get
you taken care of."
```
If Ambiguous (cannot classify from initial description):
```
"I want to make sure we handle this the right way. Is this something
that needs attention tonight, or is it something that can wait until
we're back in the office tomorrow morning?"
```
The clarifying question resolves 90% of ambiguous cases. The caller knows their own urgency.
Stage 3: Emergency Path
```
AI: "Can I get your name?"
Caller: [Name]
AI: "And what's the address where you need service?"
Caller: [Address]
AI: "And just to confirm, the best number to reach you is [caller ID number]?"
Caller: [Confirms or provides different number]
AI: "OK [Name], I'm going to reach out to our on-call technician right
now. They should be calling you back within a few minutes. In the
meantime, [safety instruction based on issue type]."
[Issue-specific safety instructions:]
- Gas smell: "Please leave the house with your family and do not turn
on any lights or appliances. Wait outside for the technician's call."
- Water flooding: "If you can safely reach the main water shutoff valve,
go ahead and turn it off. That will stop the flow until our tech
gets there."
- No heat (freezing): "Open your faucets to a slow drip to keep the
pipes from freezing while we get someone out there."
- Electrical sparking: "Stay away from the area and do not touch
anything near it. If you smell smoke, call 911 first."
AI: "Is there anything else I should let the technician know?"
Caller: [Additional context]
AI: "Got it. [Tech name or 'Our on-call tech'] will be reaching out
shortly. Hang tight, [Name]."
```
Stage 4: Routine Path
```
AI: "Can I get your name?"
Caller: [Name]
AI: "And what's the best number to reach you at?"
Caller: [Confirms or provides number]
AI: "What's the address for the service?"
Caller: [Address]
AI: "And can you tell me a little more about what's going on with
your [service type, e.g., 'plumbing' / 'heating system' / 'electrical']?"
Caller: [Details]
```
If contractor has calendar integration enabled:
```
AI: "I can get you on the schedule. We have availability on [next
available day]. Would [morning/afternoon] work for you?"
Caller: [Confirms or requests different time]
AI: "Great, I've got you down for [day] at [time]. You'll get a
confirmation text at this number. Is there anything else?"
```
If no calendar integration:
```
AI: "I've got all your info. Our office opens at [business hours start].
Someone will call you first thing in the morning to get you scheduled.
Is there anything else I should pass along?"
```
Stage 5: Disposition
For all call types, the agent ends with:
```
AI: "Thanks for calling [Business Name], [Name]. We'll take good care
of you. Have a good night."
```
Post-call processing (invisible to caller):
1. Transcript saved to database
2. Lead card generated from extracted fields
3. Call recording stored
4. If emergency: on-call alert fires (see Section 7)
5. Call added to morning digest queue
---
5. Vapi/Synthflow Configuration
This recipe supports two voice AI platforms: Vapi (recommended for developers, more control) and Synthflow (recommended for no-code, faster setup). The core logic is identical. The implementation differs.
Option A: Vapi (Recommended)
#### Assistant Setup
```typescript
// services/after-hours-assistant.ts
interface AfterHoursAssistantParams {
contractorName: string;
businessName: string;
businessType: string; // "plumbing", "hvac", "electrical"
servicesOffered: string[];
serviceArea: string;
businessHours: string;
onCallTechName: string;
onCallTechPhone: string;
aiName: string; // default: "Alex"
}
async function createAfterHoursAssistant(params: AfterHoursAssistantParams) {
const systemPrompt = buildAfterHoursPrompt(params);
const assistant = await vapiRequest('POST', '/assistant', {
name: `${params.businessName} - After Hours Agent`,
model: {
provider: 'openai',
model: 'gpt-4o-mini',
temperature: 0.2, // low creativity for consistent classification
messages: [
{ role: 'system', content: systemPrompt },
],
},
voice: {
provider: '11labs',
voiceId: 'paula', // warm, professional
stability: 0.6,
similarityBoost: 0.75,
},
transcriber: {
provider: 'deepgram',
model: 'nova-2',
language: 'en-US',
},
// Function calling for real-time actions during the call
functions: [
{
name: 'classify_call',
description: 'Classify the call as emergency or routine based on caller description',
parameters: {
type: 'object',
properties: {
classification: {
type: 'string',
enum: ['emergency', 'routine', 'spam'],
},
urgency_reasoning: {
type: 'string',
description: 'Brief explanation of classification decision',
},
service_type: {
type: 'string',
enum: ['plumbing', 'hvac', 'electrical', 'general', 'unknown'],
},
},
required: ['classification', 'urgency_reasoning', 'service_type'],
},
},
{
name: 'collect_caller_details',
description: 'Store structured caller information extracted during conversation',
parameters: {
type: 'object',
properties: {
caller_name: { type: 'string' },
phone_number: { type: 'string' },
address: { type: 'string' },
issue_description: { type: 'string' },
service_type: { type: 'string' },
additional_notes: { type: 'string' },
},
required: ['caller_name', 'phone_number', 'issue_description'],
},
},
{
name: 'route_to_oncall',
description: 'Trigger emergency routing to the on-call technician',
parameters: {
type: 'object',
properties: {
caller_name: { type: 'string' },
caller_phone: { type: 'string' },
address: { type: 'string' },
issue_summary: { type: 'string' },
safety_instruction_given: { type: 'string' },
},
required: ['caller_name', 'caller_phone', 'issue_summary'],
},
},
{
name: 'check_availability',
description: 'Check calendar for next available appointment slot',
parameters: {
type: 'object',
properties: {
preferred_date: { type: 'string' },
preferred_time: { type: 'string', enum: ['morning', 'afternoon', 'any'] },
service_type: { type: 'string' },
},
},
},
{
name: 'book_appointment',
description: 'Book an appointment on the calendar',
parameters: {
type: 'object',
properties: {
caller_name: { type: 'string' },
caller_phone: { type: 'string' },
address: { type: 'string' },
date: { type: 'string' },
time: { type: 'string' },
service_type: { type: 'string' },
issue_description: { type: 'string' },
},
required: ['caller_name', 'caller_phone', 'date', 'time'],
},
},
],
serverUrl: process.env.VAPI_SERVER_URL, // webhook for function calls
endCallFunctionEnabled: true,
recordingEnabled: true,
firstMessage: `Hi, thanks for calling ${params.businessName}. This is ${params.aiName}, ${params.contractorName}'s after-hours assistant. How can I help you tonight?`,
silenceTimeoutSeconds: 10,
maxDurationSeconds: 300, // 5 min max per call
backgroundDenoisingEnabled: true,
});
return assistant;
}
```
#### System Prompt
```
You are {{AI_NAME}}, the after-hours virtual assistant for {{BUSINESS_NAME}},
a {{BUSINESS_TYPE}} company owned by {{CONTRACTOR_NAME}} serving
{{SERVICE_AREA}}.
Your job is to answer after-hours calls, determine whether the caller
has an emergency or a routine request, collect their information, and
either route emergencies to the on-call technician or schedule routine
requests for morning follow-up.
PERSONALITY:
- Warm, direct, and competent. You sound like a friendly office manager
who knows the trade.
- You are not corporate. You are not stiff. You are the kind of person
who says "got it" and "no problem" instead of "certainly" and
"I understand your concern."
- Keep responses short. This is a phone call, not an essay.
- Never say "I'm an AI" or "I'm a virtual assistant" unless directly
asked. If asked, say: "I'm {{CONTRACTOR_NAME}}'s after-hours
assistant. I help handle calls when the office is closed."
CLASSIFICATION RULES:
EMERGENCY (route to on-call immediately):
- Active water leak, flooding, burst pipe, sewage backup
- Gas smell or CO detector alarm
- No heat when temperatures are freezing
- No AC with elderly, infants, or medical conditions present
- Electrical sparking, burning smell, exposed wires
- Caller says "emergency" or "need someone tonight" about a
safety-related issue
ROUTINE (collect info, offer booking, log for morning):
- Maintenance, tune-ups, inspections
- Minor repairs (dripping faucet, running toilet, slow drain)
- Quote or estimate requests
- Scheduling inquiries
- General questions about services or pricing
SPAM (end politely, do not log):
- Marketing pitches, vendor sales calls, robocalls
SAFETY INSTRUCTIONS (give these ONLY for emergencies):
- Gas smell: "Leave the house with your family. Don't turn on lights
or appliances. Wait outside."
- Flooding: "If you can safely reach the main water shutoff, turn it
off."
- Freezing/no heat: "Open your faucets to a slow drip to prevent
pipe freezing."
- Electrical: "Stay away from the area. Don't touch anything near it.
If you smell smoke, call 911 first."
SERVICES OFFERED: {{SERVICES_LIST}}
BUSINESS HOURS: {{BUSINESS_HOURS}}
ON-CALL TECH: {{ON_CALL_TECH_NAME}}
When in doubt about emergency vs routine, ask the caller: "Is this
something that needs attention tonight, or can it wait until morning?"
Trust their answer.
Never quote prices. If asked about cost, say: "Pricing depends on the
specifics. {{CONTRACTOR_NAME}} can give you an exact number. Let's
get your info so they can follow up."
After collecting information, always repeat back the key details for
confirmation: name, number, address, and the issue.
```
#### Webhook Handler (Function Calling)
```typescript
// api/vapi/webhook.ts
import { db } from '../lib/database';
export async function handleVapiWebhook(req: Request) {
const payload = await req.json();
const { message } = payload;
console.log(`[Vapi Webhook] Type: ${message.type}`);
if (message.type === 'function-call') {
const { functionCall } = message;
console.log(`[Function Call] ${functionCall.name}`, functionCall.parameters);
switch (functionCall.name) {
case 'classify_call':
return handleClassification(functionCall.parameters, payload);
case 'collect_caller_details':
return handleDetailCollection(functionCall.parameters, payload);
case 'route_to_oncall':
return handleEmergencyRouting(functionCall.parameters, payload);
case 'check_availability':
return handleAvailabilityCheck(functionCall.parameters, payload);
case 'book_appointment':
return handleBooking(functionCall.parameters, payload);
}
}
if (message.type === 'end-of-call-report') {
return handleCallComplete(message, payload);
}
return new Response(JSON.stringify({ ok: true }));
}
```
Option B: Synthflow (No-Code Alternative)
Synthflow provides a visual builder for voice agents. The logic is the same; the implementation is drag-and-drop instead of code.
Setup steps:
1. Create a Synthflow account at synthflow.ai
2. Create a new Assistant. Name it "[Business Name] After-Hours"
3. Set the voice (recommended: "Professional Female" or "Friendly Male")
4. Paste the system prompt from above into the assistant instructions
5. Create Actions (Synthflow's equivalent of function calling):
- "Route Emergency" action with webhook URL pointing to your backend
- "Collect Details" action that stores caller info
- "Book Appointment" action with calendar integration
6. Connect to Twilio under Synthflow's Phone Numbers settings
7. Set the assistant as the handler for your Twilio number
Synthflow vs Vapi comparison for this recipe:
| Vapi | Synthflow | |
|---|---|---|
| Setup time | 2-3 hours (code) | 1-2 hours (visual) |
| Function calling | Full control | Action-based (limited) |
| Voice quality | 11Labs integration | Built-in voices |
| Pricing | ~$0.05/min | ~$0.08/min |
| Best for | Developer building for multiple contractors | Single contractor self-deploying |
---
6. Twilio Integration
Twilio handles two jobs: forwarding after-hours calls to the voice agent and sending SMS alerts/confirmations.
Call Forwarding Setup
The contractor keeps their existing business number. They configure "no-answer forwarding" or "time-based forwarding" in their phone system to route calls to the Twilio number after hours.
#### Twilio Number Provisioning
```typescript
// services/twilio-setup.ts
import twilio from 'twilio';
const client = twilio(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!
);
async function provisionAfterHoursNumber(areaCode: string) {
console.log(`[Twilio] Provisioning number for area code ${areaCode}`);
// Search for available local numbers
const available = await client.availablePhoneNumbers('US')
.local.list({ areaCode, limit: 1 });
if (available.length === 0) {
throw new Error(`No numbers available for area code ${areaCode}`);
}
// Purchase and configure
const number = await client.incomingPhoneNumbers.create({
phoneNumber: available[0].phoneNumber,
voiceUrl: `${process.env.APP_URL}/api/twilio/incoming-call`,
voiceMethod: 'POST',
statusCallback: `${process.env.APP_URL}/api/twilio/call-status`,
statusCallbackMethod: 'POST',
recordingStatusCallback: `${process.env.APP_URL}/api/twilio/recording-status`,
recordingStatusCallbackMethod: 'POST',
});
console.log(`[Twilio] Provisioned: ${number.phoneNumber} (SID: ${number.sid})`);
return number;
}
```
#### Incoming Call Handler
When a call arrives at the Twilio number, the handler checks if the current time falls within after-hours and routes accordingly.
```typescript
// api/twilio/incoming-call.ts
import { twiml } from 'twilio';
export async function handleIncomingCall(req: Request) {
const body = await req.formData();
const callerNumber = body.get('From') as string;
const calledNumber = body.get('To') as string;
console.log(`[Incoming Call] From: ${callerNumber} To: ${calledNumber}`);
// Look up config by Twilio number
const { rows: [config] } = await db.query(
`SELECT * FROM after_hours_config
WHERE twilio_number = $1 LIMIT 1`,
[calledNumber]
);
if (!config) {
console.error(`[Incoming Call] No config found for ${calledNumber}`);
const response = new twiml.VoiceResponse();
response.say('Sorry, this number is not configured. Please try again later.');
return new Response(response.toString(), {
headers: { 'Content-Type': 'text/xml' },
});
}
const isAfterHours = checkIfAfterHours(config.business_hours);
if (!isAfterHours && !config.always_active) {
// During business hours, let the call ring through (no AI)
const response = new twiml.VoiceResponse();
response.dial(config.business_number);
return new Response(response.toString(), {
headers: { 'Content-Type': 'text/xml' },
});
}
// After hours: route to Vapi voice agent
const response = new twiml.VoiceResponse();
response.redirect({
method: 'POST',
}, config.vapi_phone_url);
// Log the inbound call
await db.query(
`INSERT INTO after_hours_calls (caller_phone, called_at, is_after_hours, status)
VALUES ($1, $2, true, 'in_progress')`,
[callerNumber, new Date().toISOString()]
);
return new Response(response.toString(), {
headers: { 'Content-Type': 'text/xml' },
});
}
```
#### Recording Configuration
All after-hours calls are recorded. The recording serves three purposes: (1) dispute resolution, (2) quality review of AI performance, (3) backup if transcription misses something.
```typescript
// api/twilio/recording-status.ts
export async function handleRecordingStatus(req: Request) {
const body = await req.formData();
const recordingSid = body.get('RecordingSid') as string;
const recordingUrl = body.get('RecordingUrl') as string;
const callSid = body.get('CallSid') as string;
const duration = body.get('RecordingDuration') as string;
console.log(`[Recording] SID: ${recordingSid} Duration: ${duration}s`);
// Store recording URL on the call record
await db.query(
`UPDATE after_hours_calls
SET recording_url = $1, recording_duration = $2, recording_sid = $3
WHERE twilio_call_sid = $4`,
[`${recordingUrl}.mp3`, parseInt(duration), recordingSid, callSid]
);
}
```
Webhook URL Summary
| Endpoint | Twilio Config Field | Purpose |
|---|---|---|
| `/api/twilio/incoming-call` | Voice URL | Route incoming calls to Vapi or business line |
| `/api/twilio/call-status` | Status Callback | Track call state (ringing, in-progress, completed) |
| `/api/twilio/recording-status` | Recording Status Callback | Store recording URL and metadata |
| `/api/vapi/webhook` | Vapi Server URL | Handle function calls during AI conversation |
---
7. On-Call Routing
When the AI classifies a call as an emergency, it needs to reach the on-call technician immediately. Three routing methods, tried in order. This is an escalation ladder, not a single-point system.
Method 1: Warm Transfer (Preferred)
The AI attempts to transfer the caller directly to the on-call tech while still on the line.
```typescript
async function warmTransfer(callerPhone: string, onCallPhone: string, callSid: string) {
console.log(`[Warm Transfer] Connecting ${callerPhone} to on-call: ${onCallPhone}`);
// Use Twilio to bridge the caller to the on-call tech
await client.calls(callSid).update({
twiml: `
<Response>
<Say>Connecting you to our on-call technician now. Please hold.</Say>
<Dial timeout="30" action="/api/twilio/transfer-status">
<Number>${onCallPhone}</Number>
</Dial>
</Response>
`,
});
}
```
If on-call answers: Caller and tech are connected. AI drops off. The tech gets the full context via an SMS sent simultaneously (see Method 2).
If on-call does not answer within 30 seconds: Fall to Method 2.
Method 2: SMS Alert + Callback Request
Immediate SMS to the on-call tech with full call details.
```typescript
async function sendEmergencyAlert(details: EmergencyDetails) {
const message = `
EMERGENCY CALL - ${details.businessName}
Caller: ${details.callerName}
Phone: ${details.callerPhone}
Address: ${details.address || 'Not provided'}
Issue: ${details.issueSummary}
Called at: ${new Date().toLocaleTimeString()}
Call them back ASAP. They've been told you'll reach out within a few minutes.
`.trim();
console.log(`[Emergency SMS] Sending to on-call: ${details.onCallPhone}`);
await client.messages.create({
to: details.onCallPhone,
from: details.twilioNumber,
body: message,
});
console.log(`[Emergency SMS] Sent successfully`);
}
```
Method 3: Escalation Ladder
If the on-call tech does not respond (no warm transfer pickup, no SMS reply within 10 minutes), escalate to the next person.
| Step | Action | Timeout |
|---|---|---|
| 1 | Warm transfer to on-call tech | 30 seconds |
| 2 | SMS to on-call tech | Sent immediately if warm transfer fails |
| 3 | Phone call to on-call tech (Twilio auto-dial) | 60 seconds |
| 4 | SMS to backup contact (owner or second tech) | 10 min after step 2 |
| 5 | SMS to owner with "UNRESOLVED EMERGENCY" flag | 20 min after step 2 |
```typescript
async function escalationLadder(details: EmergencyDetails, config: OnCallConfig) {
console.log(`[Escalation] Starting ladder for call ${details.callId}`);
// Step 1: Warm transfer (already attempted by Vapi during the call)
// Step 2: SMS to on-call (fired during the call)
// Step 3: Auto-dial on-call
setTimeout(async () => {
const acknowledged = await checkAcknowledgment(details.callId);
if (!acknowledged) {
console.log(`[Escalation] Step 3: Auto-dialing on-call`);
await autoDialOnCall(details, config.onCallPhone);
}
}, 60_000); // 1 minute
// Step 4: SMS backup contact
setTimeout(async () => {
const acknowledged = await checkAcknowledgment(details.callId);
if (!acknowledged && config.backupPhone) {
console.log(`[Escalation] Step 4: SMS to backup ${config.backupPhone}`);
await sendEmergencyAlert({ ...details, onCallPhone: config.backupPhone });
}
}, 600_000); // 10 minutes
// Step 5: SMS owner with escalation flag
setTimeout(async () => {
const acknowledged = await checkAcknowledgment(details.callId);
if (!acknowledged) {
console.log(`[Escalation] Step 5: UNRESOLVED - SMS to owner`);
await sendUnresolvedAlert(details, config.ownerPhone);
}
}, 1_200_000); // 20 minutes
}
```
Acknowledgment
The on-call tech acknowledges the alert by replying to the SMS with any response ("got it," "on my way," "calling now"). The system marks the emergency as acknowledged and stops the escalation ladder.
```typescript
// api/twilio/sms-incoming.ts
export async function handleIncomingSms(req: Request) {
const body = await req.formData();
const from = body.get('From') as string;
const messageBody = body.get('Body') as string;
console.log(`[SMS Incoming] From: ${from} Body: ${messageBody}`);
// Check if this is an on-call tech responding to an emergency
const { rows: [pendingEmergency] } = await db.query(
`SELECT * FROM emergency_alerts
WHERE tech_phone = $1 AND acknowledged = false
ORDER BY created_at DESC LIMIT 1`,
[from]
);
if (pendingEmergency) {
console.log(`[Acknowledgment] Emergency ${pendingEmergency.id} acknowledged by ${from}`);
await db.query(
`UPDATE emergency_alerts
SET acknowledged = true, acknowledged_at = $1, acknowledgment_message = $2
WHERE id = $3`,
[new Date().toISOString(), messageBody, pendingEmergency.id]
);
// Reply confirmation
await client.messages.create({
to: from,
from: pendingEmergency.twilio_number,
body: `Got it. Caller info: ${pendingEmergency.caller_name} at ${pendingEmergency.caller_phone}. Issue: ${pendingEmergency.issue_summary}`,
});
}
}
```
---
8. Lead Card Generation
Every after-hours call generates a structured lead card. This is the atomic unit of data the dispatcher uses the next morning. No listening to audio files. No reading raw transcripts. One card per call with everything needed to act.
Lead Card Schema
| Field | Source | Example |
|---|---|---|
| Classification Badge | AI classification | EMERGENCY / ROUTINE / SPAM |
| Badge Color | Classification mapping | Red / Blue / Gray |
| Caller Name | AI extraction from conversation | Jim Kowalski |
| Phone Number | Caller ID + AI confirmation | (555) 041-5 |
| Address | AI extraction | 218 Oak Lane |
| Timestamp | Call metadata | Oct 15 at 9:48 PM |
| Summary | AI-generated, 1-2 sentences | Gas smell in basement near furnace. Family of 4, turned off furnace, opened windows. |
| Service Type | AI classification | HVAC / Plumbing / Electrical |
| Issue Detail | AI extraction | Gas leak inspection |
| Call Duration | Twilio metadata | 2:34 |
| Classification Reasoning | AI | EMERGENCY: gas smell + family with children + after hours |
| Safety Instruction Given | AI tracking | "Leave the house, don't use lights or appliances" |
| On-Call Outcome | Routing system | Acknowledged at 9:51 PM by Mike T. / Not reached / N/A |
| Appointment Booked | Booking function | Yes - Tuesday 10am / No |
| Full Transcript | Vapi transcript | Expandable, hidden by default |
| Recording URL | Twilio/Vapi | Play button to audio |
| Callback Status | CSR/Dispatcher updates | Not Called / Called - Booked / Called - No Answer / Resolved |
| Caller Tone | AI detection | Calm / Worried / Panicked |
Lead Card Display (Slack / Dashboard)
```
+----------------------------------------------+
| RED EMERGENCY 9:48 PM |
| Jim Kowalski (555) 041-5 C |
| |
| Gas smell in basement near furnace. |
| Family of 4. Turned off furnace, opened |
| windows. Told to evacuate. |
| |
| Service: HVAC - Gas Leak |
| On-Call: Mike T. acknowledged at 9:51 PM |
| [Play] [Transcript] [Mark Resolved] |
+----------------------------------------------+
+----------------------------------------------+
| BLUE ROUTINE 7:22 PM |
| Sarah Chen (555) 876-3 C |
| |
| Wants quote on tankless water heater. |
| Currently has 40-gal gas, 12 years old. |
| Available Thursday or Friday morning. |
| |
| Service: Plumbing - Water Heater Quote |
| Appointment: Not booked (no calendar linked) |
| [Play] [Transcript] [Call Back] |
+----------------------------------------------+
```
Lead Card Generation Code
```typescript
// services/lead-card.ts
interface LeadCardInput {
callId: string;
callerPhone: string;
calledAt: string;
classification: 'emergency' | 'routine' | 'spam';
callerName: string;
address?: string;
issueSummary: string;
issueDetail?: string;
serviceType: string;
classificationReasoning: string;
safetyInstructionGiven?: string;
callerTone: string;
transcript: string;
recordingUrl?: string;
callDuration: number;
appointmentBooked?: boolean;
appointmentDate?: string;
onCallOutcome?: string;
}
async function generateLeadCard(input: LeadCardInput) {
console.log(`[Lead Card] Generating for call ${input.callId}`);
console.log(`[Lead Card] Classification: ${input.classification}`);
console.log(`[Lead Card] Caller: ${input.callerName} | Issue: ${input.issueSummary}`);
const { rows: [data] } = await db.query(
`INSERT INTO lead_cards (
call_id, caller_phone, caller_name, address, called_at,
classification, issue_summary, issue_detail, service_type,
classification_reasoning, safety_instruction_given, caller_tone,
transcript, recording_url, call_duration, appointment_booked,
appointment_date, oncall_outcome, callback_status, included_in_digest
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'not_called',false)
RETURNING *`,
[
input.callId, input.callerPhone, input.callerName,
input.address, input.calledAt, input.classification, input.issueSummary,
input.issueDetail, input.serviceType, input.classificationReasoning,
input.safetyInstructionGiven, input.callerTone, input.transcript,
input.recordingUrl, input.callDuration, input.appointmentBooked || false,
input.appointmentDate, input.onCallOutcome,
]
);
console.log(`[Lead Card] Created: ${data.id}`);
return data;
}
```
---
9. Morning Summary
Every morning at 6am, the system compiles all overnight calls into a sorted digest and delivers it to the dispatcher via Slack, SMS, or email. This is the dispatcher's first screen of the day.
Digest Format
```
OVERNIGHT CALL DIGEST - Wed, Jan 16, 2026
7 calls | 5:00 PM Tue - 6:00 AM Wed
EMERGENCIES (2) - Handled in real time
1. Jim Kowalski - Gas smell near furnace, family of 4
(555) 041-5 | 9:48 PM | HVAC
On-call (Mike T.) acknowledged at 9:51 PM. Dispatched.
2. Linda Morrison - Water through kitchen ceiling, active leak
(555) 052-3 | 10:14 PM | Plumbing
On-call (Mike T.) acknowledged at 10:17 PM. Dispatched.
ROUTINE - BOOKED (1)
3. Rachel Torres - AC tune-up, booked Thu 10am
(555) 033-4 | 7:22 PM | HVAC
Appointment confirmed via AI.
ROUTINE - NEEDS CALLBACK (3)
4. Doug Patterson - Water heater quote, 40-gal gas, 15 yrs old
(555) 014-2 | 5:45 PM | Plumbing
5. Kevin Park - Furnace service, new homeowner, no rush
(555) 027-6 | 6:30 PM | HVAC
6. Maria Santos - Toilet running 3 days, jiggled handle
(555) 019-8 | 8:15 PM | Plumbing
SPAM / VENDOR (1)
7. Peak Performance Marketing - Google Ads pitch
(555) 079-9 | 6:15 AM | Auto-tagged
STATS: 2 emergencies handled. 1 appointment booked.
3 callbacks needed. 1 spam filtered.
Estimated revenue protected: $3,800 (2 emergency dispatches)
```
Digest Generation Code
```typescript
// services/morning-digest.ts
async function generateMorningDigest() {
const now = new Date();
const digestStart = new Date(now);
digestStart.setDate(digestStart.getDate() - 1);
digestStart.setHours(17, 0, 0, 0); // 5pm yesterday
const digestEnd = new Date(now);
digestEnd.setHours(6, 0, 0, 0); // 6am today
console.log(`[Digest] Generating morning digest`);
console.log(`[Digest] Window: ${digestStart.toISOString()} to ${digestEnd.toISOString()}`);
const { rows: cards } = await db.query(
`SELECT * FROM lead_cards
WHERE called_at >= $1 AND called_at <= $2
ORDER BY classification ASC, called_at ASC`,
[digestStart.toISOString(), digestEnd.toISOString()]
);
if (!cards || cards.length === 0) {
console.log(`[Digest] No overnight calls`);
return formatEmptyDigest(now);
}
const emergencies = cards.filter(c => c.classification === 'emergency');
const routineBooked = cards.filter(c => c.classification === 'routine' && c.appointment_booked);
const routineCallback = cards.filter(c => c.classification === 'routine' && !c.appointment_booked);
const spam = cards.filter(c => c.classification === 'spam');
console.log(`[Digest] ${emergencies.length} emergencies, ${routineBooked.length} booked, ${routineCallback.length} callbacks, ${spam.length} spam`);
// Mark all cards as included in digest
await db.query(
`UPDATE lead_cards SET included_in_digest = true, digest_sent_at = $1
WHERE id = ANY($2)`,
[now.toISOString(), cards.map(c => c.id)]
);
return formatDigest({
date: now,
emergencies,
routineBooked,
routineCallback,
spam,
totalCalls: cards.length,
});
}
```
Digest Delivery (6am CRON)
```typescript
// cron/morning-digest.ts
// Runs daily at 6:00 AM local time
async function sendMorningDigest() {
console.log(`[CRON] Morning digest job started at ${new Date().toISOString()}`);
const { rows: [config] } = await db.query(
`SELECT digest_channel, digest_slack_webhook, digest_email, digest_phone
FROM after_hours_config WHERE digest_enabled = true LIMIT 1`
);
if (!config) {
console.log(`[CRON] No digest config found or digest disabled`);
return;
}
const digest = await generateMorningDigest();
if (config.digest_channel === 'slack' && config.digest_slack_webhook) {
await sendSlackDigest(config.digest_slack_webhook, digest);
}
if (config.digest_channel === 'sms' && config.digest_phone) {
await sendSmsDigest(config.digest_phone, digest);
}
if (config.digest_channel === 'email' && config.digest_email) {
await sendEmailDigest(config.digest_email, digest);
}
console.log(`[CRON] Digest sent via ${config.digest_channel}`);
}
```
---
10. Data Model
```sql
-- ============================================================================
-- Recipe 021: After-Hours AI Voice Agent
-- Migration Script
-- Version: 1.0.0
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================================
-- ENUM TYPES
-- ============================================================================
CREATE TYPE call_classification_enum AS ENUM (
'emergency',
'routine',
'spam'
);
CREATE TYPE callback_status_enum AS ENUM (
'not_called',
'called_booked',
'called_no_answer',
'called_not_interested',
'resolved',
'no_callback_needed'
);
CREATE TYPE oncall_outcome_enum AS ENUM (
'acknowledged',
'dispatched',
'not_reached',
'escalated',
'not_applicable'
);
CREATE TYPE escalation_step_enum AS ENUM (
'warm_transfer',
'sms_oncall',
'auto_dial_oncall',
'sms_backup',
'sms_owner_unresolved'
);
CREATE TYPE digest_channel_enum AS ENUM (
'slack',
'sms',
'email'
);
CREATE TYPE caller_tone_enum AS ENUM (
'calm',
'concerned',
'frustrated',
'worried',
'panicked',
'neutral'
);
CREATE TYPE service_type_enum AS ENUM (
'plumbing',
'hvac',
'electrical',
'general',
'unknown'
);
-- ============================================================================
-- TABLE: after_hours_config
-- Configuration for the after-hours voice agent
-- ============================================================================
CREATE TABLE public.after_hours_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
twilio_number TEXT, -- Provisioned Twilio number (E.164)
twilio_number_sid TEXT, -- Twilio PhoneNumber SID
business_number TEXT, -- Contractor's existing business number
vapi_assistant_id TEXT, -- Vapi assistant ID
vapi_phone_url TEXT, -- Vapi phone number URL for call routing
ai_name TEXT NOT NULL DEFAULT 'Alex', -- Voice agent's name
business_hours JSONB NOT NULL DEFAULT '{"mon":{"start":"08:00","end":"17:00"},"tue":{"start":"08:00","end":"17:00"},"wed":{"start":"08:00","end":"17:00"},"thu":{"start":"08:00","end":"17:00"},"fri":{"start":"08:00","end":"17:00"},"sat":null,"sun":null}',
always_active BOOLEAN NOT NULL DEFAULT false, -- Run AI even during business hours
calendar_enabled BOOLEAN NOT NULL DEFAULT false,
calendar_config_id UUID, -- FK to calendar_config if shared
digest_enabled BOOLEAN NOT NULL DEFAULT true,
digest_channel digest_channel_enum NOT NULL DEFAULT 'slack',
digest_slack_webhook TEXT,
digest_email TEXT,
digest_phone TEXT,
digest_time TIME NOT NULL DEFAULT '06:00:00', -- When to send morning digest
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.after_hours_config IS 'After-hours voice agent configuration';
-- ============================================================================
-- TABLE: oncall_config
-- On-call technician roster and escalation settings
-- ============================================================================
CREATE TABLE public.oncall_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
primary_tech_name TEXT NOT NULL,
primary_tech_phone TEXT NOT NULL, -- E.164
backup_tech_name TEXT,
backup_tech_phone TEXT, -- E.164
owner_phone TEXT NOT NULL, -- E.164, final escalation
warm_transfer_enabled BOOLEAN NOT NULL DEFAULT true,
warm_transfer_timeout_seconds INT NOT NULL DEFAULT 30,
escalation_step2_delay_seconds INT NOT NULL DEFAULT 0, -- SMS immediately
escalation_step3_delay_seconds INT NOT NULL DEFAULT 60, -- Auto-dial after 1 min
escalation_step4_delay_seconds INT NOT NULL DEFAULT 600, -- Backup after 10 min
escalation_step5_delay_seconds INT NOT NULL DEFAULT 1200, -- Owner after 20 min
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.oncall_config IS 'On-call technician roster and emergency escalation ladder config';
-- ============================================================================
-- TABLE: after_hours_calls
-- Every inbound call during after-hours, regardless of classification
-- ============================================================================
CREATE TABLE public.after_hours_calls (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
caller_phone TEXT NOT NULL,
twilio_call_sid TEXT,
vapi_call_id TEXT,
called_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
call_duration_seconds INT,
is_after_hours BOOLEAN NOT NULL DEFAULT true,
recording_url TEXT,
recording_sid TEXT,
recording_duration INT,
transcript TEXT,
status TEXT NOT NULL DEFAULT 'in_progress', -- in_progress, completed, failed, abandoned
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_after_hours_calls_date
ON public.after_hours_calls (called_at DESC);
CREATE INDEX idx_after_hours_calls_caller
ON public.after_hours_calls (caller_phone, called_at DESC);
COMMENT ON TABLE public.after_hours_calls IS 'Raw call records for all after-hours inbound calls';
-- ============================================================================
-- TABLE: lead_cards
-- Structured lead card generated from each after-hours call
-- ============================================================================
CREATE TABLE public.lead_cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
call_id UUID NOT NULL REFERENCES public.after_hours_calls(id) ON DELETE CASCADE,
caller_phone TEXT NOT NULL,
caller_name TEXT NOT NULL DEFAULT 'Unknown',
address TEXT,
called_at TIMESTAMPTZ NOT NULL,
classification call_classification_enum NOT NULL,
issue_summary TEXT NOT NULL,
issue_detail TEXT,
service_type service_type_enum NOT NULL DEFAULT 'unknown',
classification_reasoning TEXT,
safety_instruction_given TEXT,
caller_tone caller_tone_enum DEFAULT 'neutral',
transcript TEXT,
recording_url TEXT,
call_duration INT, -- seconds
appointment_booked BOOLEAN NOT NULL DEFAULT false,
appointment_date TIMESTAMPTZ,
oncall_outcome oncall_outcome_enum DEFAULT 'not_applicable',
callback_status callback_status_enum NOT NULL DEFAULT 'not_called',
callback_notes TEXT,
included_in_digest BOOLEAN NOT NULL DEFAULT false,
digest_sent_at TIMESTAMPTZ,
crm_synced BOOLEAN NOT NULL DEFAULT false,
crm_sync_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lead_cards_date
ON public.lead_cards (called_at DESC);
CREATE INDEX idx_lead_cards_classification
ON public.lead_cards (classification, called_at DESC);
CREATE INDEX idx_lead_cards_callback_status
ON public.lead_cards (callback_status)
WHERE callback_status = 'not_called';
CREATE INDEX idx_lead_cards_digest
ON public.lead_cards (included_in_digest, called_at DESC)
WHERE included_in_digest = false;
COMMENT ON TABLE public.lead_cards IS 'Structured lead cards generated from after-hours AI voice conversations';
-- ============================================================================
-- TABLE: emergency_alerts
-- Tracks emergency routing attempts and acknowledgments
-- ============================================================================
CREATE TABLE public.emergency_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
call_id UUID NOT NULL REFERENCES public.after_hours_calls(id) ON DELETE CASCADE,
lead_card_id UUID NOT NULL REFERENCES public.lead_cards(id) ON DELETE CASCADE,
caller_name TEXT NOT NULL,
caller_phone TEXT NOT NULL,
address TEXT,
issue_summary TEXT NOT NULL,
tech_name TEXT NOT NULL,
tech_phone TEXT NOT NULL,
twilio_number TEXT NOT NULL, -- Number alerts are sent from
escalation_step escalation_step_enum NOT NULL,
acknowledged BOOLEAN NOT NULL DEFAULT false,
acknowledged_at TIMESTAMPTZ,
acknowledgment_message TEXT,
dispatched BOOLEAN NOT NULL DEFAULT false,
dispatched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_emergency_alerts_pending
ON public.emergency_alerts (tech_phone, acknowledged)
WHERE acknowledged = false;
CREATE INDEX idx_emergency_alerts_call
ON public.emergency_alerts (call_id);
COMMENT ON TABLE public.emergency_alerts IS 'Emergency routing attempts, escalation steps, and tech acknowledgments';
-- ============================================================================
-- TABLE: digest_history
-- Log of sent morning digests
-- ============================================================================
CREATE TABLE public.digest_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
digest_date DATE NOT NULL UNIQUE,
digest_start TIMESTAMPTZ NOT NULL,
digest_end TIMESTAMPTZ NOT NULL,
total_calls INT NOT NULL DEFAULT 0,
emergencies INT NOT NULL DEFAULT 0,
routine_booked INT NOT NULL DEFAULT 0,
routine_callback INT NOT NULL DEFAULT 0,
spam INT NOT NULL DEFAULT 0,
channel digest_channel_enum NOT NULL,
digest_content TEXT, -- Full digest text for audit
sent_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.digest_history IS 'Log of daily morning digest deliveries with call counts';
-- ============================================================================
-- UPDATED_AT TRIGGER
-- ============================================================================
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.after_hours_config
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.oncall_config
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.after_hours_calls
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.lead_cards
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
```
---
11. Feature Specs with VTCR
Feature 1: After-Hours Configuration
---
#### VTCR 1.1 -- After-Hours Toggle and Schedule Configuration
Visual:
The Settings page displays a section titled "After-Hours AI Voice Agent" with a large toggle switch. Below the toggle, a weekly schedule grid shows each day of the week with two time fields per row: "Office Closes" and "Office Opens." A color bar between the two times represents the window when the AI agent is active. Below the schedule grid, a checkbox reads "Always active (AI answers even during business hours)." The current status is shown in a badge: "Active -- AI answers calls from 5:00 PM to 8:00 AM" in green, or "Inactive" in gray.
Trigger:
The contractor toggles the after-hours agent on or modifies the business hours schedule.
Condition:
- The contractor has a Twilio number provisioned and a Vapi assistant configured (both stored in `after_hours_config`).
- If either is missing, the toggle is disabled with a message: "Complete setup to activate your after-hours agent."
Result:
- The `after_hours_config.is_active` field is updated.
- If toggled on, the system verifies the Twilio webhook is pointed at the incoming call handler and confirms Vapi assistant health via a test API call.
- The schedule grid saves to `after_hours_config.business_hours` as a JSONB object with start/end times per day.
- A toast notification confirms: "After-hours agent is live. Calls outside your business hours will be answered by AI."
---
#### VTCR 1.2 -- On-Call Roster Configuration
Visual:
A "On-Call Team" card displays two contact slots: "Primary On-Call" and "Backup On-Call." Each slot has fields for Name and Phone Number. Below both slots, an "Owner (Final Escalation)" field is pre-filled from the contractor profile. A section titled "Escalation Timing" shows four configurable delay fields: "SMS to on-call" (default: immediately), "Auto-dial on-call" (default: 1 min), "SMS to backup" (default: 10 min), "SMS to owner" (default: 20 min). A toggle for "Warm Transfer" is on by default with helper text: "AI will attempt to connect the caller directly to the on-call tech before falling back to SMS."
Trigger:
The contractor saves changes to the on-call roster or escalation timing.
Condition:
- At least the primary tech name, primary tech phone, and owner phone are provided.
- Phone numbers pass E.164 validation.
Result:
- The `oncall_config` record is created or updated.
- A test SMS is sent to the primary on-call number: "This is a test from [Business Name]'s after-hours system. You're set as the primary on-call contact. Reply OK to confirm." The result is shown on screen (delivered / failed).
- If warm transfer is enabled, the warm transfer timeout is saved (default 30 seconds).
---
#### VTCR 1.3 -- Voice Agent Personality Configuration
Visual:
A "Voice Agent Setup" card with: a text field for "Agent Name" (default: "Alex"), a dropdown for voice selection with a "Preview" play button next to each option, a large text area showing the greeting message with merge fields highlighted in blue (e.g., `{{BUSINESS_NAME}}`), and a "Test Call" button at the bottom. Below the greeting, a "Services & Knowledge" section shows checkboxes for services offered (pre-populated from contractor profile) and a text area for "Additional instructions" where the contractor can add specifics ("We don't do commercial work," "Our minimum service call is $89," etc.).
Trigger:
The contractor modifies the agent name, voice selection, greeting, or services list and clicks "Save."
Condition:
- A Vapi API key is stored and valid.
- The greeting field is not empty and contains `{{BUSINESS_NAME}}` at minimum.
Result:
- The Vapi assistant is updated via `PATCH /assistant/{id}` with the new first message, system prompt variables, and voice configuration.
- The contractor can click "Test Call" to place a call from the app to their personal phone, where the AI agent answers with the configured greeting. The test call auto-ends after 60 seconds.
- A `vapi_config_updated` event is logged with the changed fields.
---
Feature 2: Inbound Call Handling
---
#### VTCR 2.1 -- After-Hours Call Received and Routed to AI
Visual:
On the After-Hours Dashboard, a new row appears in the call log with a pulsing blue dot indicating an active call. The row shows: caller phone number, "In Progress" status badge, call start time, and a live duration counter. If the dashboard is not open, a browser notification fires: "Incoming after-hours call from (555) 234-5678."
Trigger:
An inbound call arrives at the Twilio number during after-hours (or anytime if `always_active` is true).
Condition:
- The `after_hours_config.is_active` flag is true.
- The current time falls outside the configured business hours OR `always_active` is true.
- The Vapi assistant is reachable (health check passed within the last hour).
Result:
- Twilio routes the call to the Vapi assistant.
- An `after_hours_calls` record is created with status `in_progress`.
- The AI agent delivers the configured greeting and begins the qualification flow.
- Call recording starts via Twilio.
- If Vapi is unreachable, the call falls back to Twilio voicemail with the contractor's recorded greeting. The voicemail is transcribed via Whisper and a lead card is generated from the transcript (degraded mode, still better than silence).
---
#### VTCR 2.2 -- Emergency Classification During Call
Visual:
When the AI agent classifies a call as an emergency during the conversation, the dashboard row for that call updates in real time: the pulsing dot turns red, the status badge changes to "EMERGENCY - Routing to On-Call," and a red alert banner appears at the top of the dashboard: "Emergency call in progress. On-call routing initiated."
Trigger:
The Vapi assistant calls the `classify_call` function with `classification: 'emergency'` during the conversation.
Condition:
- The `classify_call` function has been invoked with emergency classification.
- The `oncall_config` record exists with at least a primary tech phone.
Result:
- The emergency routing ladder starts (Section 7).
- If warm transfer is enabled, the AI tells the caller: "I'm connecting you to our on-call technician now. Please hold." Twilio bridges the call.
- Simultaneously, an SMS alert is sent to the on-call tech with caller details.
- An `emergency_alerts` record is created for each escalation step.
- The lead card is generated with `classification: 'emergency'`.
- If warm transfer succeeds (on-call answers), the dashboard updates: "EMERGENCY - Connected to [Tech Name]."
- If warm transfer fails (timeout), the AI returns to the caller: "I wasn't able to reach [Tech Name] directly, but I've sent them an urgent message with your information. They'll be calling you back within a few minutes."
---
#### VTCR 2.3 -- Routine Classification and Booking Offer
Visual:
The dashboard row shows a blue status badge: "Routine - Collecting Details." If the AI offers booking and the caller accepts, the status updates to "Routine - Appointment Booked" with the date/time visible on the row.
Trigger:
The Vapi assistant calls `classify_call` with `classification: 'routine'` and subsequently calls `collect_caller_details`.
Condition:
- The call is classified as routine.
- For booking: `after_hours_config.calendar_enabled` is true and Google Calendar is connected.
Result:
- The AI collects caller name, phone, address, and issue details via the conversation flow.
- If calendar is enabled, the AI calls `check_availability` and offers available slots.
- If the caller selects a slot, `book_appointment` creates a Google Calendar event and the AI confirms.
- The lead card is generated with `classification: 'routine'` and `appointment_booked: true/false`.
- A confirmation SMS is sent to the caller if an appointment is booked: "Confirmed: [Service] appointment with [Business Name] on [Date] at [Time]. Reply to this number if you need to reschedule."
---
#### VTCR 2.4 -- Call Completed and Lead Card Generated
Visual:
When the call ends, the dashboard row transitions from "In Progress" to the final state. The pulsing dot stops. The row displays: caller name (extracted by AI), phone number, classification badge (red EMERGENCY / blue ROUTINE / gray SPAM), call duration, and a summary snippet. Clicking the row expands the full lead card with all fields from Section 8.
Trigger:
Vapi sends an `end-of-call-report` webhook.
Condition:
- The call ended (caller hung up or AI ended the call).
- Vapi delivers the full transcript and function call results.
Result:
- The `after_hours_calls` record is updated with `status: 'completed'`, `ended_at`, `call_duration_seconds`, and `transcript`.
- The recording URL from Twilio is attached.
- A lead card is generated (or updated if created during the call) with all extracted fields.
- The lead card is queued for the next morning digest.
- If the call was < 10 seconds with no meaningful speech, it is auto-classified as abandoned and no lead card is generated.
---
#### VTCR 2.5 -- Spam/Vendor Call Handling
Visual:
The dashboard row shows a gray SPAM badge. The lead card is collapsed by default in the call log and sorted to the bottom of any view.
Trigger:
The AI classifies the call as spam (marketing pitch, robocall, vendor solicitation).
Condition:
- The `classify_call` function returns `classification: 'spam'`.
Result:
- The AI ends the call politely: "Thanks for calling, but we're not interested at this time. Have a good night."
- A minimal call record is logged (no full lead card generated).
- No emergency routing. No morning digest entry beyond the spam count.
- The call is flagged in the dashboard for the contractor to review if desired.
---
Feature 3: Morning Digest
---
#### VTCR 3.1 -- Daily Digest Generated and Delivered
Visual:
The dispatcher receives the digest at 6am via their configured channel (Slack, SMS, or email). In the app dashboard, a "Today's Digest" card appears at the top of the After-Hours page with a summary: "7 overnight calls: 2 emergencies (handled), 1 booked, 3 need callback, 1 spam."
Trigger:
The 6am CRON job fires.
Condition:
- `after_hours_config.digest_enabled` is true.
- At least one `lead_card` exists for the overnight window that has not yet been included in a digest.
- The configured delivery channel is operational (Slack webhook reachable, SMS number valid, email service up).
Result:
- The digest is generated with calls sorted by classification (emergencies first, then booked, then callbacks, then spam).
- Each entry includes caller name, phone, issue summary, service type, and disposition.
- Emergency entries note on-call acknowledgment status.
- The digest is delivered via the configured channel.
- All included lead cards are marked `included_in_digest: true`.
- A `digest_history` record is created with call counts and the full digest text.
- If zero calls came in overnight, a brief message is sent: "No overnight calls. All quiet."
---
#### VTCR 3.2 -- Digest Delivery Failure
Visual:
If the digest fails to send (Slack webhook down, SMS delivery failed, email bounced), a red alert banner appears on the After-Hours Dashboard: "Morning digest failed to send. [View Digest] [Retry]." The "View Digest" link shows the digest content in the app. The "Retry" button re-attempts delivery.
Trigger:
The delivery channel returns an error during the 6am CRON job.
Condition:
- The digest was generated successfully but delivery failed.
Result:
- The digest content is stored in `digest_history` regardless of delivery outcome.
- `digest_history.sent_at` is null for failed deliveries.
- The system retries automatically at 6:15 AM and 6:30 AM.
- After 3 failures, the alert banner persists until manually dismissed.
- The contractor can always view the digest in-app even if external delivery fails.
---
Feature 4: Callback Tracking
---
#### VTCR 4.1 -- CSR Updates Callback Status on Lead Card
Visual:
Each lead card in the dashboard has a "Callback Status" dropdown in the card's action bar. Options: Not Called (default), Called - Booked, Called - No Answer, Called - Not Interested, Resolved. When the CSR selects a status, the card's visual state updates: booked cards get a green check, no-answer cards get an orange retry icon, not-interested cards dim slightly.
Trigger:
The CSR or dispatcher changes the callback status dropdown on a lead card.
Condition:
- The lead card exists and the user has permission to update it.
Result:
- `lead_cards.callback_status` is updated.
- `lead_cards.callback_notes` is optionally updated if the CSR types a note.
- The dashboard's "Needs Callback" counter decrements.
- If the card is marked "Called - Booked," the system prompts to create an appointment (links to calendar booking flow).
---
12. Testing Scenarios
Emergency Classification Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 1 | Caller says "Water is pouring through my ceiling" | EMERGENCY. On-call alerted. Safety instruction: water shutoff. | |
| 2 | Caller says "I smell gas near my furnace" | EMERGENCY. On-call alerted. Safety instruction: evacuate. | |
| 3 | Caller says "No heat, it's 15 degrees, I have a newborn" | EMERGENCY. On-call alerted. Safety instruction: drip faucets. | |
| 4 | Caller says "My outlet is sparking and I smell burning" | EMERGENCY. On-call alerted. Safety instruction: stay away, call 911 if smoke. | |
| 5 | Caller at 2am says "My furnace just shut off" | EMERGENCY (time-of-day bias + no heat). On-call alerted. | |
| 6 | Panicked caller, crying, says "there's water everywhere" | EMERGENCY (tone override + flooding keywords). |
Routine Classification Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 7 | Caller says "I'd like to schedule an AC tune-up" | ROUTINE. Details collected. Booking offered. | |
| 8 | Caller says "Can I get a quote on a water heater?" | ROUTINE. Details collected. Lead card for morning. | |
| 9 | Caller says "My faucet has been dripping for a week" | ROUTINE. Not urgent, no safety risk. | |
| 10 | Caller at 6pm says "My toilet's been running for 3 days" | ROUTINE. Long-standing issue, not an emergency. |
Ambiguous Classification Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 11 | Caller says "My AC stopped working" (no vulnerable persons mentioned) | AI asks: "Does this need attention tonight or can it wait until morning?" Classify based on response. | |
| 12 | Caller says "Something is wrong with my furnace" (vague) | AI asks clarifying question. If "it's making a weird noise" = routine. If "it won't turn on and it's freezing" = emergency. |
Spam Detection Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 13 | "Hi, I'm calling from ABC Marketing about your Google Ads" | SPAM. Polite ending. No lead card. No routing. | |
| 14 | Robocall pattern (pre-recorded message, no human interaction) | SPAM. Auto-hang up. No lead card. |
On-Call Routing Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 15 | Emergency call, warm transfer, on-call answers | Caller connected to tech. SMS with details also sent. Lead card shows "Acknowledged." | |
| 16 | Emergency call, warm transfer times out (30s) | AI tells caller "I've sent an urgent message." SMS to on-call. Escalation ladder starts. | |
| 17 | Emergency SMS sent, on-call replies "got it" | Escalation stops. Emergency alert marked acknowledged. Tech gets confirmation with caller info. | |
| 18 | Emergency, no response after 10 minutes | SMS sent to backup contact. | |
| 19 | Emergency, no response after 20 minutes | SMS sent to owner with "UNRESOLVED EMERGENCY" flag. |
Conversation Flow Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 20 | Caller interrupts greeting mid-sentence | AI stops, listens, then responds to what the caller said. | |
| 21 | Caller asks "Are you a robot?" | AI responds: "I'm [Contractor]'s after-hours assistant. How can I help?" Does not volunteer "I'm AI." | |
| 22 | Caller asks for pricing | AI deflects: "Pricing depends on the specifics. Let me get your info so [Owner] can give you an exact number." | |
| 23 | Caller provides incomplete info (no address) | AI asks for address. If caller refuses, proceeds without it and notes "Address not provided" on lead card. | |
| 24 | Call exceeds 5 minutes | AI wraps up: "I think I have everything I need. Is there anything else?" Ends at max duration. | |
| 25 | Caller hangs up before providing any info | Call record logged as "abandoned." No lead card generated (< 10 seconds). If > 10 seconds, lead card generated with whatever was captured. |
Booking Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 26 | Routine call, calendar enabled, caller wants appointment | AI offers available slots. Caller selects one. Google Calendar event created. Confirmation SMS sent. | |
| 27 | Routine call, calendar not enabled | AI says "Our office will call you first thing in the morning to get you scheduled." | |
| 28 | Routine call, calendar enabled but no availability this week | AI says "We're fully booked this week. I'll make sure you're first on the callback list tomorrow morning." |
Morning Digest Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 29 | 5 overnight calls (2 emergency, 2 routine, 1 spam) | Digest at 6am, sorted: emergencies first with on-call outcome, then routine with callback status, then spam count. | |
| 30 | Zero overnight calls | Digest: "No overnight calls. All quiet." | |
| 31 | Digest delivery fails (Slack webhook down) | Alert banner in app. Digest viewable in app. Auto-retry at 6:15 and 6:30. |
Edge Case Tests
| # | Scenario | Expected Result | Pass? |
|---|---|---|---|
| 32 | Same caller calls twice in 30 minutes | Second call recognizes repeat caller. Classification bias bumps up one level. AI references: "I see you called a little while ago." | |
| 33 | Call arrives during business hours, always_active = false | Call passes through to business line. No AI. | |
| 34 | Vapi is down when a call arrives | Twilio falls back to voicemail recording. Whisper transcribes. Lead card generated from transcript. | |
| 35 | Caller speaks Spanish | AI responds in English: "I apologize, let me have [Owner] call you back." Logs language barrier note on lead card. |
---
13. Example: Overnight Session with 4 Calls
Thursday night in February. Metro Plumbing Co closes at 5pm. Owner is Dave. On-call tech is Mike. AI agent name is Alex. Four calls come in overnight.
Call 1: Emergency -- Burst Pipe (9:22 PM)
Caller: Jim Kowalski, (555) 041-5
The call:
```
Alex: "Hi, thanks for calling Metro Plumbing. This is Alex, Dave's
after-hours assistant. How can I help you tonight?"
Jim: "Yeah, uh, I've got water spraying out of a pipe in my basement.
It's everywhere. I don't know what to do."
Alex: "That sounds like it needs immediate attention. Let me get some
quick details so I can reach our on-call technician right away.
Can I get your name?"
Jim: "Jim Kowalski."
Alex: "And what's the address, Jim?"
Jim: "218 Oak Lane."
Alex: "Got it. And the best number to reach you is the one you're
calling from, ending in 041-5?"
Jim: "Yeah, that's right."
Alex: "OK Jim, I'm going to reach out to Mike, our on-call tech,
right now. He should be calling you back within a few minutes.
In the meantime, if you can safely reach the main water shutoff
valve, go ahead and turn it off. That'll stop the flow until
Mike gets there. Is there anything else I should let him know?"
Jim: "It's the pipe going to the water heater. The whole basement
is soaked."
Alex: "Got it. Pipe to the water heater, basement flooding. Mike will
be in touch shortly. Hang tight, Jim."
```
What happens behind the scenes:
| Time | Event |
|---|---|
| 9:22:00 PM | Call arrives. AI greets Jim. |
| 9:22:45 PM | `classify_call` fires: emergency (active flooding, "water spraying"). |
| 9:22:46 PM | Warm transfer attempted to Mike (555) 887-2. |
| 9:23:01 PM | Mike does not answer (15 seconds). Transfer falls back. |
| 9:23:02 PM | SMS sent to Mike: "EMERGENCY CALL - Metro Plumbing. Jim Kowalski (555) 041-5, 218 Oak Lane. Burst pipe to water heater, basement flooding. Call ASAP." |
| 9:23:10 PM | AI returns to Jim, gives shutoff instructions. |
| 9:23:40 PM | Call ends. Lead card generated. |
| 9:24:15 PM | Mike replies "on it." Escalation stops. |
| 9:27:00 PM | Mike calls Jim. Dispatches for emergency visit. |
Lead card:
- Classification: EMERGENCY
- Caller: Jim Kowalski, (555) 041-5, 218 Oak Lane
- Issue: Burst pipe to water heater, basement flooding
- Service: Plumbing
- Safety instruction: Main water shutoff
- On-call: Mike acknowledged at 9:24 PM, dispatched
- Call duration: 1:40
Job outcome: Mike arrives at 10:15 PM. Replaces burst fitting. $1,850 emergency service call.
---
Call 2: Routine -- Quote Request (7:45 PM)
Caller: Sarah Chen, (555) 876-3
The call:
```
Alex: "Hi, thanks for calling Metro Plumbing. This is Alex, Dave's
after-hours assistant. How can I help you this evening?"
Sarah: "Hi. I'm looking to get a quote on replacing my water heater.
It's a 40-gallon gas one, about 12 years old."
Alex: "Got it. I can help with that. Let me grab a few details. Can
I get your name?"
Sarah: "Sarah Chen."
Alex: "And the best number to reach you?"
Sarah: "You've got it. The one I'm calling from."
Alex: "And what's the address for the service?"
Sarah: "1447 Birch Street."
Alex: "Great. So you've got a 40-gallon gas water heater, 12 years
old, looking for a replacement quote. Is there anything else
going on with it, or are you just planning ahead?"
Sarah: "It's still working, just starting to make some noise. Figure
it's time before it goes."
Alex: "Smart move. I've got all your info. Our office opens at 8am
tomorrow. Someone will call you first thing to get you scheduled
for an estimate. Is there anything else?"
Sarah: "Nope, that's it. Thanks."
Alex: "Thanks for calling Metro Plumbing, Sarah. We'll take good
care of you. Have a good night."
```
Lead card:
- Classification: ROUTINE
- Caller: Sarah Chen, (555) 876-3, 1447 Birch Street
- Issue: Water heater replacement quote. 40-gal gas, 12 years old, making noise, still working.
- Service: Plumbing - Water Heater Quote
- Appointment: Not booked (calendar not linked)
- Callback status: Not Called
- Call duration: 1:15
---
Call 3: Routine -- Maintenance Request (8:30 PM)
Caller: Doug Patterson, (555) 014-2
The call:
```
Alex: "Hi, thanks for calling Metro Plumbing. This is Alex, Dave's
after-hours assistant. How can I help you tonight?"
Doug: "Hey, I need to get my furnace serviced. I'm a new homeowner,
just moved in, and I have no idea when this thing was last
looked at."
Alex: "No problem, we can help with that. Can I get your name?"
Doug: "Doug Patterson."
Alex: "And the address?"
Doug: "922 Maple Drive."
Alex: "Best number to reach you?"
Doug: "Yeah, this one's fine."
Alex: "Got it. Any issues with the furnace right now, or just looking
for a general tune-up?"
Doug: "No issues. It's working fine. Just want someone to look at it
since I don't know the history."
Alex: "Makes sense. Our office opens at 8am tomorrow. We'll give you
a call to get a service visit on the books. Anything else?"
Doug: "That's it. Thanks."
Alex: "Thanks for calling, Doug. Have a good night."
```
Lead card:
- Classification: ROUTINE
- Caller: Doug Patterson, (555) 014-2, 922 Maple Drive
- Issue: Furnace service. New homeowner, unknown maintenance history. No current issues.
- Service: HVAC - Furnace Tune-Up
- Appointment: Not booked
- Callback status: Not Called
- Call duration: 0:52
---
Call 4: Spam -- Marketing Pitch (6:10 AM)
Caller: (555) 079-9
The call:
```
Alex: "Hi, thanks for calling Metro Plumbing. This is Alex, Dave's
after-hours assistant. How can I help you this morning?"
Caller: "Hi there! I'm Sandra from Peak Performance Marketing. We help
plumbing companies like yours get to the top of Google with..."
Alex: "Thanks for calling, but we're not interested at this time.
Have a good morning."
[Call ends]
```
Result: Call logged as SPAM. No lead card generated. No routing. Appears in digest spam count only.
---
6:00 AM Friday -- Morning Digest
```
OVERNIGHT CALL DIGEST - Fri, Feb 14, 2026
4 calls | 5:00 PM Thu - 6:00 AM Fri
EMERGENCIES (1) - Handled in real time
1. Jim Kowalski - Burst pipe to water heater, basement flooding
(555) 041-5 | 9:22 PM | Plumbing | 218 Oak Lane
On-call (Mike T.) acknowledged at 9:24 PM. Dispatched at 9:27 PM.
ROUTINE - NEEDS CALLBACK (2)
2. Sarah Chen - Water heater replacement quote, 40-gal gas, 12 yrs
(555) 876-3 | 7:45 PM | Plumbing | 1447 Birch Street
3. Doug Patterson - Furnace service, new homeowner, no rush
(555) 014-2 | 8:30 PM | HVAC | 922 Maple Drive
SPAM / VENDOR (1)
4. Peak Performance Marketing - Marketing pitch
(555) 079-9 | 6:10 AM | Auto-tagged
STATS: 1 emergency dispatched ($1,850 job). 2 callbacks needed.
1 spam filtered. AI handled 4 calls in 0 minutes of human time.
```
What Would Have Happened Without the System
Jim's burst pipe voicemail sits until 8am Friday. By 7:15am, his wife has already called another plumber who answered a Google Voice number. That plumber dispatches at 7:30am. Metro Plumbing loses a $1,850 job.
Sarah's quote request voicemail is 5th in the queue Friday morning. The CSR gets to it at 8:40am. Sarah has already submitted a request through HomeAdvisor and has two other plumbers texting her. Metro Plumbing is now competing instead of being first.
Doug's tune-up request is a low-priority voicemail that the CSR gets to at 9am. He is still available, but by then three other HVAC companies have responded to his Nextdoor post asking for recommendations.
With the system: the emergency was dispatched in 5 minutes. The routine callers felt heard. The spam was filtered. The dispatcher opened Friday morning with a clean, sorted list and zero voicemail backlog.
---
Cost Summary
| Component | Per Call | Monthly (60 after-hours calls) |
|---|---|---|
| Vapi (avg 90 sec/call) | $0.075 | $4.50 |
| Twilio (call forwarding) | $0.02 | $1.20 |
| Twilio SMS (alerts + confirmations) | $0.01 | $0.60 |
| Twilio phone number | -- | $1.00 |
| PostgreSQL (database + storage) | -- | $0 (free tier) |
| Total | ~$0.10 | ~$7.30 |
Under $10/month to handle 60 after-hours calls. One recovered emergency call pays for 2 years of the system. Compare to an answering service at $300-$1,500/month that cannot tell a burst pipe from a dripping faucet.
---
Recipe 021 -- After-Hours AI Voice Agent
THE AI TRADES Platform
Difficulty: Replit Build | Setup: Half day | Category: Call & Lead Handling