The AI Trades
Call & Lead Handling

After-Hours AI Voice Agent

Replit Build Half day
TwilioVapiSynthflow

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

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.

ReplacesAnswering service ($300-1,500/mo)

Importable Templates

Make.comVoiceflow SMS chatbot with Twilio + Make
ZapierOpenPhone to Slack notifications

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

1. Recipe Overview

2. Strategy Brief

3. Emergency vs Routine Classification Logic

4. Voice Agent Conversation Flow

5. Vapi/Synthflow Configuration

6. Twilio Integration

7. On-Call Routing

8. Lead Card Generation

9. Morning Summary

10. Data Model

11. Feature Specs with VTCR

12. Testing Scenarios

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 = voicemailAfter-hours call = live AI conversation
Emergency sits until morningEmergency reaches on-call tech within 2 minutes
Routine caller hangs up, calls competitorRoutine caller is booked or logged instantly
No data on overnight call volumeFull call log with transcripts and classifications
Owner checks voicemail at 8am, plays catch-upDispatcher opens sorted digest at 6am, starts calling
Answering service fumbles trade-specific questionsAI 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.

CategoryKeyword Triggers
Waterflooding, burst pipe, water pouring, sewage backup, water everywhere, water coming through ceiling, water spraying, sump pump failed, sewer backing up
Gasgas 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
Electricalsparking, sparks, burning smell, electrical fire, exposed wires, shock, outlet smoking, panel buzzing, arcing
Safetydangerous, 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.

CategoryKeyword Triggers
Maintenancetune-up, maintenance, inspection, seasonal service, annual check, filter replacement
Minor repairdripping faucet, running toilet, slow drain, thermostat issue, outlet not working
Quotes/Estimatesestimate, quote, bid, how much, pricing, cost of, ballpark
Schedulingschedule, appointment, book, available, next week, when can you come
General inquirydo you offer, do you service, service area, question about, hours
Non-servicewarranty, 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 WindowClassification Bias
5pm - 10pmNeutral. Classify based on content.
10pm - 5amLean emergency. If ambiguous, classify as emergency and let on-call tech make the final call.
5am - 8amNeutral. 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 SignalAI Interpretation
Calm, measured, patientSupports routine classification
Frustrated but controlledSupports same-day priority, not emergency
Worried, rapid speech, elevated pitchUpgrade consideration toward emergency
Panicked, crying, shoutingOverride 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:

VapiSynthflow
Setup time2-3 hours (code)1-2 hours (visual)
Function callingFull controlAction-based (limited)
Voice quality11Labs integrationBuilt-in voices
Pricing~$0.05/min~$0.08/min
Best forDeveloper building for multiple contractorsSingle 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

EndpointTwilio Config FieldPurpose
`/api/twilio/incoming-call`Voice URLRoute incoming calls to Vapi or business line
`/api/twilio/call-status`Status CallbackTrack call state (ringing, in-progress, completed)
`/api/twilio/recording-status`Recording Status CallbackStore recording URL and metadata
`/api/vapi/webhook`Vapi Server URLHandle 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.

StepActionTimeout
1Warm transfer to on-call tech30 seconds
2SMS to on-call techSent immediately if warm transfer fails
3Phone call to on-call tech (Twilio auto-dial)60 seconds
4SMS to backup contact (owner or second tech)10 min after step 2
5SMS to owner with "UNRESOLVED EMERGENCY" flag20 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

FieldSourceExample
Classification BadgeAI classificationEMERGENCY / ROUTINE / SPAM
Badge ColorClassification mappingRed / Blue / Gray
Caller NameAI extraction from conversationJim Kowalski
Phone NumberCaller ID + AI confirmation(555) 041-5
AddressAI extraction218 Oak Lane
TimestampCall metadataOct 15 at 9:48 PM
SummaryAI-generated, 1-2 sentencesGas smell in basement near furnace. Family of 4, turned off furnace, opened windows.
Service TypeAI classificationHVAC / Plumbing / Electrical
Issue DetailAI extractionGas leak inspection
Call DurationTwilio metadata2:34
Classification ReasoningAIEMERGENCY: gas smell + family with children + after hours
Safety Instruction GivenAI tracking"Leave the house, don't use lights or appliances"
On-Call OutcomeRouting systemAcknowledged at 9:51 PM by Mike T. / Not reached / N/A
Appointment BookedBooking functionYes - Tuesday 10am / No
Full TranscriptVapi transcriptExpandable, hidden by default
Recording URLTwilio/VapiPlay button to audio
Callback StatusCSR/Dispatcher updatesNot Called / Called - Booked / Called - No Answer / Resolved
Caller ToneAI detectionCalm / 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

#ScenarioExpected ResultPass?
1Caller says "Water is pouring through my ceiling"EMERGENCY. On-call alerted. Safety instruction: water shutoff.
2Caller says "I smell gas near my furnace"EMERGENCY. On-call alerted. Safety instruction: evacuate.
3Caller says "No heat, it's 15 degrees, I have a newborn"EMERGENCY. On-call alerted. Safety instruction: drip faucets.
4Caller says "My outlet is sparking and I smell burning"EMERGENCY. On-call alerted. Safety instruction: stay away, call 911 if smoke.
5Caller at 2am says "My furnace just shut off"EMERGENCY (time-of-day bias + no heat). On-call alerted.
6Panicked caller, crying, says "there's water everywhere"EMERGENCY (tone override + flooding keywords).

Routine Classification Tests

#ScenarioExpected ResultPass?
7Caller says "I'd like to schedule an AC tune-up"ROUTINE. Details collected. Booking offered.
8Caller says "Can I get a quote on a water heater?"ROUTINE. Details collected. Lead card for morning.
9Caller says "My faucet has been dripping for a week"ROUTINE. Not urgent, no safety risk.
10Caller at 6pm says "My toilet's been running for 3 days"ROUTINE. Long-standing issue, not an emergency.

Ambiguous Classification Tests

#ScenarioExpected ResultPass?
11Caller 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.
12Caller 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

#ScenarioExpected ResultPass?
13"Hi, I'm calling from ABC Marketing about your Google Ads"SPAM. Polite ending. No lead card. No routing.
14Robocall pattern (pre-recorded message, no human interaction)SPAM. Auto-hang up. No lead card.

On-Call Routing Tests

#ScenarioExpected ResultPass?
15Emergency call, warm transfer, on-call answersCaller connected to tech. SMS with details also sent. Lead card shows "Acknowledged."
16Emergency call, warm transfer times out (30s)AI tells caller "I've sent an urgent message." SMS to on-call. Escalation ladder starts.
17Emergency SMS sent, on-call replies "got it"Escalation stops. Emergency alert marked acknowledged. Tech gets confirmation with caller info.
18Emergency, no response after 10 minutesSMS sent to backup contact.
19Emergency, no response after 20 minutesSMS sent to owner with "UNRESOLVED EMERGENCY" flag.

Conversation Flow Tests

#ScenarioExpected ResultPass?
20Caller interrupts greeting mid-sentenceAI stops, listens, then responds to what the caller said.
21Caller asks "Are you a robot?"AI responds: "I'm [Contractor]'s after-hours assistant. How can I help?" Does not volunteer "I'm AI."
22Caller asks for pricingAI deflects: "Pricing depends on the specifics. Let me get your info so [Owner] can give you an exact number."
23Caller provides incomplete info (no address)AI asks for address. If caller refuses, proceeds without it and notes "Address not provided" on lead card.
24Call exceeds 5 minutesAI wraps up: "I think I have everything I need. Is there anything else?" Ends at max duration.
25Caller hangs up before providing any infoCall record logged as "abandoned." No lead card generated (< 10 seconds). If > 10 seconds, lead card generated with whatever was captured.

Booking Tests

#ScenarioExpected ResultPass?
26Routine call, calendar enabled, caller wants appointmentAI offers available slots. Caller selects one. Google Calendar event created. Confirmation SMS sent.
27Routine call, calendar not enabledAI says "Our office will call you first thing in the morning to get you scheduled."
28Routine call, calendar enabled but no availability this weekAI says "We're fully booked this week. I'll make sure you're first on the callback list tomorrow morning."

Morning Digest Tests

#ScenarioExpected ResultPass?
295 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.
30Zero overnight callsDigest: "No overnight calls. All quiet."
31Digest delivery fails (Slack webhook down)Alert banner in app. Digest viewable in app. Auto-retry at 6:15 and 6:30.

Edge Case Tests

#ScenarioExpected ResultPass?
32Same caller calls twice in 30 minutesSecond call recognizes repeat caller. Classification bias bumps up one level. AI references: "I see you called a little while ago."
33Call arrives during business hours, always_active = falseCall passes through to business line. No AI.
34Vapi is down when a call arrivesTwilio falls back to voicemail recording. Whisper transcribes. Lead card generated from transcript.
35Caller speaks SpanishAI 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:

TimeEvent
9:22:00 PMCall arrives. AI greets Jim.
9:22:45 PM`classify_call` fires: emergency (active flooding, "water spraying").
9:22:46 PMWarm transfer attempted to Mike (555) 887-2.
9:23:01 PMMike does not answer (15 seconds). Transfer falls back.
9:23:02 PMSMS 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 PMAI returns to Jim, gives shutoff instructions.
9:23:40 PMCall ends. Lead card generated.
9:24:15 PMMike replies "on it." Escalation stops.
9:27:00 PMMike 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

ComponentPer CallMonthly (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

RolesOwnerCSRDispatcher
IndustriesHVACPlumbingElectrical
PrinciplesSpeed Wins the JobLook 10x Your Size