Skip to main content
ai voiceMarch 11, 202620 min read

How to Build an AI Voice Receptionist with Vapi.ai

Step-by-step guide to building a production AI voice receptionist that handles calls 24/7. Real case study from a plumbing business that never misses a lead.

Loic Bachellerie

Senior Product Engineer

The Problem That Started This

A plumbing business owner called me in frustration. His company was missing 40% of inbound calls. Emergencies were going to voicemail at 2 a.m. Potential customers were calling competitors instead. He had a full-time receptionist during business hours but nothing outside of that window. He was losing real money, every single night.

Three hours after we got on a call together, Captain Plumber had a fully deployed AI voice receptionist built on Vapi.ai. It now handles 200+ calls per month, routes emergencies to an on-call plumber within 90 seconds, captures lead data into a CRM automatically, and has reduced missed calls from 40% down to under 2%.

This guide is the complete walkthrough. I will show you the architecture, the Vapi.ai configuration, the TypeScript backend, the edge case handling, and the production deployment setup - everything I actually used. By the end, you will have a working AI voice receptionist you can deploy for any service business.


Why Voice AI Is the Highest-ROI Automation for Service Businesses

Before writing a line of code, it is worth understanding why voice specifically matters. Most automation projects I work on live in the "nice to have" category. Voice agents are different - they operate at the exact point where a business either wins or loses a customer.

When someone calls a plumber at 11 p.m. with a burst pipe, they are not going to try a second number if nobody picks up. They are searching again immediately. The business that answers owns the job.

Here is how voice AI compares to the alternatives most small businesses default to:

OptionAvailabilityCost/MonthLead CapturePersonalization
Human receptionistBusiness hours$2,500-$4,000ManualHigh
Voicemail24/7~$0Often abandonedNone
Call center24/7$800-$2,000InconsistentLow
AI voice agent24/7$50-$200AutomatedConfigurable

The AI voice agent is not just cheaper. It is structurally better at the task: it never has a bad day, it always follows the script, it captures the same fields every time, and it routes correctly at any hour.

For Captain Plumber, the ROI calculation was simple. One captured emergency job at $400-$800 covers the entire monthly cost of the AI system.


Architecture Overview

An AI voice receptionist built on Vapi.ai has four layers. Understanding all four before you start building will save you hours of debugging later.

AI Voice Receptionist - System Architecture

How a call flows from the caller through to your CRM

1

Phone Layer - Twilio

Caller dials your number. Twilio routes the call to Vapi via a webhook. You own the number and control the routing rules.

2

Voice Processing - Vapi.ai

Vapi handles Speech-to-Text (Deepgram), LLM orchestration (GPT-4 or Claude), and Text-to-Speech (ElevenLabs). Sub-1s latency.

3

Business Logic - Your Backend

A Next.js or Express API receives function calls from Vapi. Handles lead saves, emergency routing, appointment scheduling, and SMS confirmations.

4

Data Layer - CRM / Database

Every call produces structured data: caller name, address, issue type, urgency, preferred callback time. Synced to Airtable, HubSpot, or Supabase.

TwilioVapi.aiDeepgram STTOpenAI GPT-4ElevenLabs TTSAirtable CRM

The key insight: Vapi handles the complexity that would take months to build yourself - turn detection, interruption handling, latency optimization, and provider failover. Your job is to configure the agent behavior and write the business logic endpoints.


Prerequisites and Stack

Here is everything you need before starting:

  • Vapi.ai account - free tier gives you $10 in credits, enough to test thoroughly
  • Twilio account - for a dedicated phone number (~$1/month)
  • OpenAI API key - GPT-4o is the best latency/quality balance as of 2026
  • Node.js 18+ with TypeScript
  • A deployed backend - Vercel, Railway, or Render all work well

Estimated setup time: 3-4 hours for your first production deployment.


Step 1 - Account Setup and First Assistant

Install the SDK

npm install @vapi-ai/server-sdk @vapi-ai/web

Create Your First Assistant via the API

I prefer creating assistants programmatically rather than through the dashboard. It makes your configuration version-controlled and repeatable across environments.

// lib/vapi/create-assistant.ts
import { VapiClient } from "@vapi-ai/server-sdk";
 
const client = new VapiClient({ token: process.env.VAPI_API_KEY! });
 
export async function createPlumbingReceptionist() {
  const assistant = await client.assistants.create({
    name: "Captain Plumber - AI Receptionist",
    model: {
      provider: "openai",
      model: "gpt-4o",
      temperature: 0.4,
      systemPrompt: buildSystemPrompt(),
    },
    voice: {
      provider: "elevenlabs",
      voiceId: "21m00Tcm4TlvDq8ikWAM", // Rachel - warm, professional
      stability: 0.5,
      similarityBoost: 0.75,
    },
    firstMessage:
      "Thanks for calling Captain Plumber. I'm here to help. Can I get your name and tell me what's going on with your plumbing?",
    endCallMessage:
      "You're all set. A member of our team will be in touch shortly. Have a good one.",
    recordingEnabled: true,
    transcriptPlan: {
      enabled: true,
    },
    silenceTimeoutSeconds: 30,
    maxDurationSeconds: 600,
    serverUrl: process.env.VAPI_WEBHOOK_URL!, // your backend endpoint
  });
 
  console.log("Assistant created:", assistant.id);
  return assistant;
}
 
function buildSystemPrompt(): string {
  return `You are the AI receptionist for Captain Plumber, a licensed plumbing company serving the Greater Boston area.
 
## Your Role
You handle inbound calls to collect information, assess urgency, and route the caller appropriately. You are professional, warm, and efficient. You do not diagnose problems - you gather information and connect people to the right help.
 
## Information to Collect on Every Call
1. Caller full name
2. Service address (street, city)
3. Brief description of the issue
4. Best callback number (confirm if different from caller ID)
5. Whether the situation is urgent or can wait
 
## Emergency Classification
A call is an EMERGENCY if the caller mentions:
- Active water leak or flooding
- No hot water (in winter months)
- Sewage backup
- Gas smell (redirect to 911 first, then collect info)
- Pipe burst
 
## Conversation Rules
- Keep responses under 25 words when possible - this is a phone call
- Repeat back key details to confirm accuracy
- Never say "I don't know" - say "Let me make sure someone calls you about that"
- If a caller is distressed, acknowledge it before asking questions
- Confirm the callback number by reading it back digit by digit
 
## Routing Logic
- EMERGENCY: Tell them a plumber will call within 15 minutes. Trigger the emergency function immediately.
- NON-EMERGENCY: Offer available appointment slots for the next business day. Trigger the schedule function.
- INFORMATION ONLY: Answer basic questions from the FAQ, then offer to book an estimate.
 
## What You Never Do
- Quote prices over the phone
- Guarantee specific arrival times beyond the 15-minute emergency callback
- Collect payment information
- Make promises outside your defined scope`;
}

Why Temperature 0.4?

For a receptionist, you want consistency over creativity. Lower temperature (0.3-0.5) means the model sticks closely to its instructions. I have seen agents with temperature 0.8+ start improvising responses that conflict with the business rules. For professional contexts, keep it low.


Step 2 - Configuring Function Calling

Function calling is what separates a toy demo from a production system. Without it, your agent can only talk - it cannot actually do anything. Functions let the LLM trigger real actions in your systems during the call.

Define Your Functions in the Assistant

// lib/vapi/assistant-functions.ts
 
export const vapiTools = [
  {
    type: "function" as const,
    function: {
      name: "capture_lead",
      description:
        "Save the caller's information as a new lead in the CRM. Call this once you have collected name, address, issue, and callback number.",
      parameters: {
        type: "object",
        properties: {
          callerName: {
            type: "string",
            description: "Full name of the caller",
          },
          callbackPhone: {
            type: "string",
            description: "Phone number to call back, digits only",
          },
          serviceAddress: {
            type: "string",
            description: "Full service address including city",
          },
          issueDescription: {
            type: "string",
            description: "Brief description of the plumbing issue in the caller's own words",
          },
          isEmergency: {
            type: "boolean",
            description: "True if the caller described an active leak, flooding, or similar emergency",
          },
          preferredTime: {
            type: "string",
            description: "Preferred appointment window if not an emergency, e.g. 'tomorrow morning'",
          },
        },
        required: ["callerName", "callbackPhone", "serviceAddress", "issueDescription", "isEmergency"],
      },
    },
  },
  {
    type: "function" as const,
    function: {
      name: "route_emergency",
      description:
        "Immediately notify the on-call plumber via SMS and paging. Only call this when isEmergency is true.",
      parameters: {
        type: "object",
        properties: {
          callerName: { type: "string" },
          callbackPhone: { type: "string" },
          serviceAddress: { type: "string" },
          issueDescription: { type: "string" },
        },
        required: ["callerName", "callbackPhone", "serviceAddress", "issueDescription"],
      },
    },
  },
  {
    type: "function" as const,
    function: {
      name: "check_availability",
      description:
        "Check available appointment slots for the next 3 business days. Call this for non-emergency scheduling.",
      parameters: {
        type: "object",
        properties: {
          preferredDate: {
            type: "string",
            description: "Caller's preferred date if mentioned, e.g. 'tomorrow' or 'Thursday'",
          },
          timePreference: {
            type: "string",
            enum: ["morning", "afternoon", "any"],
            description: "Caller's time preference",
          },
        },
        required: [],
      },
    },
  },
];

Update the Assistant to Include Functions

// Add tools to the assistant create call
const assistant = await client.assistants.create({
  name: "Captain Plumber - AI Receptionist",
  model: {
    provider: "openai",
    model: "gpt-4o",
    temperature: 0.4,
    systemPrompt: buildSystemPrompt(),
    tools: vapiTools, // attach the functions here
  },
  // ... rest of config
});

Step 3 - Building the Webhook Handler

When the LLM decides to call a function, Vapi sends a POST request to your serverUrl. This is where your business logic lives.

// app/api/vapi/webhook/route.ts  (Next.js App Router)
import { NextRequest, NextResponse } from "next/server";
import { captureLeadInCRM } from "@/lib/crm/capture-lead";
import { routeEmergency } from "@/lib/notifications/route-emergency";
import { getAvailableSlots } from "@/lib/calendar/availability";
 
interface VapiFunctionCallPayload {
  message: {
    type: "function-call";
    functionCall: {
      name: string;
      parameters: Record<string, unknown>;
    };
    call: {
      id: string;
      phoneNumber?: { number: string };
    };
  };
}
 
export async function POST(req: NextRequest): Promise<NextResponse> {
  const body = (await req.json()) as VapiFunctionCallPayload;
  const { message } = body;
 
  if (message.type !== "function-call") {
    return NextResponse.json({ result: "ok" });
  }
 
  const { name, parameters } = message.functionCall;
  const callId = message.call.id;
 
  try {
    switch (name) {
      case "capture_lead":
        return await handleCaptureLead(parameters, callId);
 
      case "route_emergency":
        return await handleRouteEmergency(parameters, callId);
 
      case "check_availability":
        return await handleCheckAvailability(parameters);
 
      default:
        console.warn(`Unknown function called: ${name}`);
        return NextResponse.json({
          result: "Function not recognized. Apologize to the caller and offer a callback.",
        });
    }
  } catch (error) {
    console.error(`Webhook handler error for ${name}:`, error);
    // Return a graceful message - the agent will speak this to the caller
    return NextResponse.json({
      result: "There was a technical issue on our end. Please tell the caller a team member will call them back within the hour.",
    });
  }
}
 
async function handleCaptureLead(
  params: Record<string, unknown>,
  callId: string
): Promise<NextResponse> {
  const lead = {
    callerName: params.callerName as string,
    callbackPhone: params.callbackPhone as string,
    serviceAddress: params.serviceAddress as string,
    issueDescription: params.issueDescription as string,
    isEmergency: params.isEmergency as boolean,
    preferredTime: (params.preferredTime as string) ?? null,
    callId,
    source: "ai-receptionist",
    createdAt: new Date().toISOString(),
  };
 
  const savedLead = await captureLeadInCRM(lead);
 
  return NextResponse.json({
    result: `Lead saved successfully. Reference number ${savedLead.id}. Tell the caller their information has been recorded and confirm the callback number you collected.`,
  });
}
 
async function handleRouteEmergency(
  params: Record<string, unknown>,
  callId: string
): Promise<NextResponse> {
  await routeEmergency({
    callerName: params.callerName as string,
    callbackPhone: params.callbackPhone as string,
    serviceAddress: params.serviceAddress as string,
    issueDescription: params.issueDescription as string,
    callId,
  });
 
  return NextResponse.json({
    result: "Emergency alert sent. Tell the caller: an on-call plumber has been notified and will call them back within 15 minutes. Give them a confirmation.",
  });
}
 
async function handleCheckAvailability(
  params: Record<string, unknown>
): Promise<NextResponse> {
  const slots = await getAvailableSlots({
    preferredDate: params.preferredDate as string | undefined,
    timePreference: (params.timePreference as "morning" | "afternoon" | "any") ?? "any",
  });
 
  if (slots.length === 0) {
    return NextResponse.json({
      result: "No slots available this week. Offer the caller the first available slot next week or take their info for a callback when slots open.",
    });
  }
 
  const slotDescriptions = slots
    .slice(0, 3)
    .map((s) => s.label)
    .join(", ");
 
  return NextResponse.json({
    result: `Available slots: ${slotDescriptions}. Read these options to the caller and ask which works best.`,
  });
}

The Key Pattern: Return Instructions, Not Just Data

Notice that every function returns a result string that is phrased as an instruction to the agent. The LLM reads this result and uses it to formulate its spoken response. This gives you precise control over what the agent says after each action without needing to update the system prompt constantly.


Step 4 - Emergency Routing Logic

This is the part that had the biggest business impact for Captain Plumber. When someone calls at 2 a.m. with a burst pipe, the system needs to alert a human immediately.

// lib/notifications/route-emergency.ts
import twilio from "twilio";
 
const twilioClient = twilio(
  process.env.TWILIO_ACCOUNT_SID!,
  process.env.TWILIO_AUTH_TOKEN!
);
 
interface EmergencyAlert {
  callerName: string;
  callbackPhone: string;
  serviceAddress: string;
  issueDescription: string;
  callId: string;
}
 
export async function routeEmergency(alert: EmergencyAlert): Promise<void> {
  const oncallNumbers = getOnCallNumbers(); // rotate through your on-call schedule
  const messageBody = buildEmergencyMessage(alert);
 
  // SMS all on-call staff simultaneously
  const smsPromises = oncallNumbers.map((number) =>
    twilioClient.messages.create({
      body: messageBody,
      from: process.env.TWILIO_FROM_NUMBER!,
      to: number,
    })
  );
 
  // Also call the primary on-call number - SMS can be missed
  const callPromise = twilioClient.calls.create({
    twiml: buildEmergencyTwiML(alert),
    from: process.env.TWILIO_FROM_NUMBER!,
    to: oncallNumbers[0],
  });
 
  await Promise.all([...smsPromises, callPromise]);
 
  // Log for audit trail
  console.log(`Emergency routed for call ${alert.callId}`, {
    caller: alert.callerName,
    address: alert.serviceAddress,
    notifiedNumbers: oncallNumbers.length,
  });
}
 
function buildEmergencyMessage(alert: EmergencyAlert): string {
  return [
    "EMERGENCY CALL - Captain Plumber",
    `Caller: ${alert.callerName}`,
    `Phone: ${alert.callbackPhone}`,
    `Address: ${alert.serviceAddress}`,
    `Issue: ${alert.issueDescription}`,
    "Please call back within 15 min.",
  ].join("\n");
}
 
function buildEmergencyTwiML(alert: EmergencyAlert): string {
  return `<Response>
    <Say voice="Polly.Joanna">
      Emergency plumbing call. Caller ${alert.callerName} at ${alert.serviceAddress}
      reports ${alert.issueDescription}.
      Call back ${alert.callbackPhone} within 15 minutes.
      Press 1 to acknowledge.
    </Say>
    <Gather numDigits="1" />
  </Response>`;
}
 
function getOnCallNumbers(): string[] {
  // In production, pull this from your scheduling system or a simple env var
  const raw = process.env.ONCALL_NUMBERS ?? "";
  return raw.split(",").filter(Boolean);
}

Step 5 - CRM Integration

Every call should produce a clean record. I used Airtable for Captain Plumber because the owner was already using it for job tracking, but the pattern works with any CRM.

// lib/crm/capture-lead.ts
import Airtable from "airtable";
 
const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(
  process.env.AIRTABLE_BASE_ID!
);
 
interface LeadRecord {
  callerName: string;
  callbackPhone: string;
  serviceAddress: string;
  issueDescription: string;
  isEmergency: boolean;
  preferredTime: string | null;
  callId: string;
  source: string;
  createdAt: string;
}
 
interface SavedLead {
  id: string;
}
 
export async function captureLeadInCRM(lead: LeadRecord): Promise<SavedLead> {
  const record = await base("Leads").create({
    Name: lead.callerName,
    Phone: lead.callbackPhone,
    Address: lead.serviceAddress,
    Issue: lead.issueDescription,
    Emergency: lead.isEmergency,
    "Preferred Time": lead.preferredTime ?? "Flexible",
    "Call ID": lead.callId,
    Source: lead.source,
    Status: lead.isEmergency ? "Emergency - Needs Immediate Follow-up" : "New Lead",
    "Created At": lead.createdAt,
  });
 
  return { id: record.id };
}

SMS Confirmation to the Caller

After saving the lead, send the caller a confirmation. This single step increased Captain Plumber's post-call satisfaction significantly - callers feel like they dealt with a real business, not a bot.

// lib/notifications/send-caller-confirmation.ts
import twilio from "twilio";
 
const client = twilio(
  process.env.TWILIO_ACCOUNT_SID!,
  process.env.TWILIO_AUTH_TOKEN!
);
 
export async function sendCallerConfirmation(
  phone: string,
  callerName: string,
  isEmergency: boolean
): Promise<void> {
  const message = isEmergency
    ? `Hi ${callerName}, this is Captain Plumber. An on-call plumber has been notified and will call you within 15 minutes. Save this number: ${process.env.BUSINESS_PHONE}.`
    : `Hi ${callerName}, thanks for calling Captain Plumber. We've got your info and will be in touch to confirm your appointment. Questions? Call or text ${process.env.BUSINESS_PHONE}.`;
 
  await client.messages.create({
    body: message,
    from: process.env.TWILIO_FROM_NUMBER!,
    to: phone,
  });
}

Step 6 - Handling Edge Cases

Production voice agents fail in predictable ways. Here are the edge cases I handled for Captain Plumber, and how.

Caller Doesn't Speak

If someone calls and stays silent, the agent should handle it gracefully rather than hanging up abruptly.

// Add to assistant config
silenceTimeoutSeconds: 10,
// Vapi will end the call after 10s of silence
// Set firstMessage to prompt immediately so callers know someone is there
firstMessage: "Thanks for calling Captain Plumber. Can you hear me okay? Take your time and let me know what you need.",

Caller Says They Want to Speak to a Human

Add this to your system prompt:

If the caller explicitly asks to speak with a person, say: "Absolutely, I understand. Let me make sure I get your details first so our team can call you back with everything they need. What's the best number to reach you?"

Do not argue. Collect the info, then tell them: "Perfect, someone from our team will call you back shortly. We appreciate your patience."

This retains the lead capture even when the caller opts out of the AI interaction - which is the actual goal.

Unclear or Garbled Audio

If you cannot understand what the caller said, ask once for clarification: "Sorry, I missed that - could you repeat that for me?"

If you still cannot understand after a second attempt, say: "Let me make sure our team reaches out directly. Can I get your callback number?"

Fail gracefully to lead capture. Always.

Call Drops or Disconnects

Configure Vapi to handle call-end events and save whatever partial data was collected:

// app/api/vapi/call-end/route.ts
import { NextRequest, NextResponse } from "next/server";
 
interface CallEndPayload {
  message: {
    type: "end-of-call-report";
    call: { id: string };
    transcript: string;
    summary: string;
  };
}
 
export async function POST(req: NextRequest): Promise<NextResponse> {
  const body = (await req.json()) as CallEndPayload;
 
  if (body.message.type !== "end-of-call-report") {
    return NextResponse.json({ ok: true });
  }
 
  const { call, transcript, summary } = body.message;
 
  // Save the full transcript and summary to your database
  // Even if the lead capture function was never triggered, you have the transcript
  await saveCallRecord({
    callId: call.id,
    transcript,
    summary,
    endedAt: new Date().toISOString(),
  });
 
  return NextResponse.json({ ok: true });
}

Vapi sends an end-of-call report with a full transcript and a generated summary. For Captain Plumber, this has been invaluable - when a call drops midway, the owner can review the transcript and follow up manually.


Step 7 - Connecting Your Twilio Number

Once your assistant is created and your webhook is live, connect a phone number:

// lib/vapi/connect-phone-number.ts
import { VapiClient } from "@vapi-ai/server-sdk";
 
const client = new VapiClient({ token: process.env.VAPI_API_KEY! });
 
export async function connectPhoneNumber(
  twilioPhoneNumber: string,
  assistantId: string
): Promise<void> {
  await client.phoneNumbers.create({
    number: twilioPhoneNumber,
    twilioAccountSid: process.env.TWILIO_ACCOUNT_SID!,
    twilioAuthToken: process.env.TWILIO_AUTH_TOKEN!,
    assistantId,
    name: "Captain Plumber Main Line",
  });
 
  console.log(`Phone number ${twilioPhoneNumber} connected to assistant ${assistantId}`);
}

Call this once during setup. After this, every call to that Twilio number goes directly to your AI receptionist.

Fallback Routing

For Captain Plumber, I configured a fallback so that if Vapi is unreachable (rare but possible), Twilio falls back to a recorded message with the owner's cell number. Always have a fallback.

// Twilio Webhook fallback TwiML (paste in your Twilio console)
<Response>
  <Say>
    Thanks for calling Captain Plumber. We're experiencing a brief technical issue.
    Please call back at 617-555-0100 or leave a message and we'll return your call shortly.
  </Say>
  <Record maxLength="60" />
</Response>

Captain Plumber - Real-World Results

Here is what 90 days of production data looked like for this specific deployment.

Captain Plumber - 90-Day Results

Before and after AI voice receptionist deployment

98%

Calls answered

Was 60%

217

Calls handled/month

Avg 7.2 per day

34

Emergency routes/month

Avg response: 11 min

$127

Monthly AI cost

vs $3,200 receptionist

Lead quality

100% of leads captured with name, address, issue description, and callback number - compared to roughly 60% completeness from voicemail messages.

After-hours revenue

Emergency jobs booked between 9 p.m. and 7 a.m. increased by 3x in the first month. The owner estimates this alone covers more than 15x the monthly AI cost.

One number that surprised me: 34% of all calls came in outside business hours. Before the AI receptionist, those calls went to voicemail. Most were never returned because people had already found another plumber.


Production Deployment Checklist

Before going live, run through every item on this list:

Pre-Launch Checklist

Make 20+ test calls covering normal flows, edge cases, and emergencies
Verify webhook endpoint is reachable over HTTPS with a valid SSL cert
Confirm emergency SMS reaches all on-call numbers within 30 seconds
Verify CRM records are created correctly with all required fields
Test Twilio fallback by temporarily pointing serverUrl to a non-existent endpoint
Set up monitoring alerts for webhook errors and failed function calls
Review call recordings from test calls with the business owner
Add rate limiting to your webhook endpoint (Vapi sends retries on timeout)
Set spending limits in both Vapi and OpenAI dashboards to prevent runaway costs

Cost Breakdown at Scale

Understanding the cost model is important before you pitch this to a client or commit to a deployment.

ComponentUnit Cost200 calls/month (3 min avg)1,000 calls/month
Vapi platform~$0.05/min$30$150
OpenAI GPT-4o~$0.01/min equiv$6$30
ElevenLabs TTS~$0.003/min$1.80$9
Twilio (calls)$0.013/min$7.80$39
Twilio (SMS)$0.0079/msg$1.58 (200 SMS)$7.90
Total~$47/month~$236/month

At 200 calls per month, the all-in cost is roughly $50. A single converted emergency job at $500 gives a 10x return in the first month alone. As volume increases, you can optimize toward GPT-4o-mini for simpler interactions and cut costs significantly without noticeable quality loss.


System Prompt Engineering Tips

The system prompt is where most production issues originate. Here are the patterns that made the biggest difference on Captain Plumber:

1. Define the persona in the first two sentences. Do not bury the role description. The LLM's first priority should be clear immediately.

2. Use enumerated lists for rules, not paragraphs. When the model has to follow 10 rules, numbered lists are parsed more reliably than flowing prose.

3. Include anti-patterns explicitly. The instruction "do not quote prices" is more reliable than hoping the model infers it. Say exactly what you do not want.

4. Keep the prompt under 1,500 tokens. Longer prompts dilute focus. If your prompt is getting long, it usually means your agent is trying to do too many things. Split the responsibilities.

5. Version your prompts. Store them in your codebase as constants, not in the Vapi dashboard. When something breaks at 3 a.m., you want a git blame trail, not a mystery.


What This System Does Not Do

I want to be direct about the limitations so you set realistic expectations with clients.

The AI receptionist excels at structured information collection and routing. It does not negotiate prices, handle complex scheduling across multiple technicians, or handle hostile callers who escalate to abusive language (you should add explicit escalation paths for this).

For Captain Plumber, we set a clear guardrail: any caller who asked for a specific technician by name, or raised their voice, was routed immediately to a voicemail-to-text with a guaranteed 2-hour human callback. The AI is not the right tool for every situation - knowing when to hand off is part of good system design.


Next Steps

If you want to go further with this setup:

  • Add sentiment analysis on the call transcript to flag upset callers for priority follow-up
  • Connect Google Calendar instead of custom slot management for real-time availability
  • Build a dashboard using Vapi's call analytics API so the business owner can review call summaries without listening to recordings
  • A/B test voices - ElevenLabs has dozens of voice options, and the right one for a medical practice is different from the right one for a plumber
  • Integrate with Google My Business to pull in caller context before the conversation starts

The architecture in this guide is the same foundation I use for all of these extensions. Once the webhook handler and function calling pattern is in place, adding capabilities is straightforward.


Summary

Building an AI voice receptionist with Vapi.ai comes down to five things done well:

  1. A clear, constrained system prompt that defines the persona and the guardrails
  2. Function calling that connects the agent to your actual systems
  3. A webhook handler that returns actionable instructions, not just data
  4. Proper edge case handling for silence, escalations, and dropped calls
  5. An emergency routing path that works at 2 a.m. without fail

For Captain Plumber, this went from concept to production in one working session. The business now answers 98% of calls, captures every lead with complete information, and routes emergencies in under 90 seconds - around the clock, for under $130 per month.

Voice AI is not the future of customer service for small businesses. It is available right now, and the businesses that deploy it today are building a structural advantage over competitors who are still sending calls to voicemail.


Need help building an AI voice receptionist for your business? I build these systems end-to-end - from Vapi configuration to CRM integration to production deployment. Get in touch and we can talk through what the right setup looks like for your use case.

Share:

Get practical engineering insights

AI voice agents, automation workflows, and shipping fast. No spam, unsubscribe anytime.