After-Hours Voice Agent for a Plumber

Build a voice AI agent that triages emergencies and books jobs after hours.

Introduction

30 Second Summary

A plumber's phone rings 22 times after 10pm on a freeze weekend. He answers zero of those calls. Two callers hire a competitor. He never counts what the other twenty cost him.

In this project, you will audit a plumber's call log to prove what his missed calls cost him, then build and deploy a voice agent that answers his line after hours, triages emergencies with safety instructions, books routine jobs into his CRM, and pages his on-call tech for gas leaks and floods.

What You'll Build

You will call a real phone number, hear an AI voice identify itself, report a gas smell, and watch it deliver a safety instruction and page the on-call tech in under four seconds.

By the end of this project, you'll have:

  • A one-page revenue-leak report you can hand to the plumber in his truck that shows, in his own numbers, what missed after-hours calls cost him last quarter.
  • A live voice agent on a real phone number that triages emergencies (gas smell, flood, electrical, no-heat-during-freeze) with safety instructions and on-call paging, then books routine jobs into Airtable with an SMS confirmation to the caller.
  • A value ledger and signed attribution rule that records every call disposition so at 30 days you and the client can look at the same number and know whether the agent paid for itself.
  • Secret Mission: Run a 45-call seeded set, score it field-by-field against acceptance criteria, produce the client readout with booking accuracy and triage misses, write the runbook, and tag the release.

Are there any prerequisites?

You should be comfortable with Node.js and REST APIs. You will create accounts on Vapi, Anthropic, Twilio, Airtable, GitHub, and Linear during the project. All have free tiers or signup credits.

Before We Start

Before writing any agent code, the engineering process must exist. Every artifact in this project ships through a GitHub repo, and every decision is tracked as a Linear ticket.

In this step, you will create the repo, set up the project board, scaffold the Express server that handles the agent's tool calls, and verify connectivity to every service in the stack.

In this step, get ready to:
  • Create the GitHub repo and Linear project with five epics.
  • Scaffold the Express server and install dependencies.
  • Create and verify accounts for Vapi, Anthropic, Twilio, and Airtable.
Create the GitHub repo and Linear project

Every artifact you produce needs a home, and every decision needs a paper trail. The repo stores code and documents. Linear tracks what you shipped and when.

  • Open GitHub in your browser and click the + icon in the top-right corner.
  • Select New repository.
  • Set the repository name to after-hours-voice-agent.
  • Set visibility to Private.
  • Check Add a README file.
  • Under Add .gitignore, select the Node template.
  • Click Create repository.

You should see your new repository page with the README displayed.

  • Clone the repo to your Desktop by running this command:
cd ~/Desktop
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/after-hours-voice-agent.git
cd after-hours-voice-agent
  • Create the docs/ directory by running:
mkdir docs
  • Confirm the directory exists by running:
ls

You should see README.md and docs in the output.

Now set up the project board in Linear.

  • Open Linear in your browser and sign in (or create a free account).
  • Create a new project called After-Hours Voice Agent.
  • Inside the project, create five epics with these titles:
  • Call-Log Audit and Leak Model.
  • Triage Taxonomy and Safety Scripts.
  • Agent Build and CRM Integration.
  • Seeded Set and Validation.
  • Ledger, Attribution Rule, and Readout.
  • Add one placeholder story to each epic (e.g., "Set up fixture data" under the first epic). These will be refined as you work through the project.

Why Linear and not just GitHub Issues?

Linear gives you epics with proper hierarchy. You can close tickets from PR descriptions, which means every merge creates an auditable trail of what was shipped and why.

If you prefer GitHub Issues, the pattern is identical. The point is that a ticket exists before the code does.

Scaffold the Express server and install dependencies

The voice agent will call your server whenever it needs to do something (page a tech, book a job, send an SMS). That server needs to exist now so you can verify the full stack end-to-end.

  • Create package.json by running this in your after-hours-voice-agent directory:
npm init -y

You should see output confirming the file was created.

  • Open package.json in your editor and replace its entire contents with this:
{
  "name": "after-hours-voice-agent",
  "version": "1.0.0",
  "description": "After-hours voice agent backend for plumbing business",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "validate": "node scripts/validate-taxonomy.js",
    "audit": "node scripts/audit.js"
  },
  "dependencies": {
    "dotenv": "^16.4.7",
    "express": "^4.21.2",
    "twilio": "^5.4.3"
  }
}

What does this configure?

  • Three npm scripts: start runs the server, validate checks the triage taxonomy, and audit generates the revenue-leak report.
  • Three dependencies: express for the HTTP server, twilio for SMS paging, and dotenv for loading environment variables.
  • Install the dependencies by running:
npm install

You should see output showing the three packages installed successfully.

  • Create server.js in the project root and paste this code:
require("dotenv").config();
const express = require("express");

const app = express();
app.use(express.json());

// Health-check endpoint for verifying the server is running
app.get("/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`After-hours agent server running on port ${PORT}`);
});

What does this code do?

  • Loads environment variables from a .env file so your API keys stay out of the code.
  • Creates an Express server that parses JSON request bodies (required for Vapi's webhook payloads).
  • Exposes a /health endpoint you can hit to confirm the server is reachable.
  • Start the server by running:
node server.js

You should see: After-hours agent server running on port 3000.

  • Open a new terminal tab (keep the server running in the first one).
  • Create the .env file in your project root and paste these placeholders:
VAPI_API_KEY=your-vapi-api-key-here
ANTHROPIC_API_KEY=your-anthropic-api-key-here
TWILIO_ACCOUNT_SID=your-twilio-account-sid-here
TWILIO_AUTH_TOKEN=your-twilio-auth-token-here
TWILIO_PHONE_NUMBER=+15551234567
ON_CALL_TECH_NUMBER=+15559876543
AIRTABLE_TOKEN=your-airtable-personal-access-token-here
AIRTABLE_BASE_ID=your-airtable-base-id-here
BUSINESS_NAME=SA Plumbing Co
PORT=3000

What are these variables for?

  • VAPI_API_KEY and ANTHROPIC_API_KEY authenticate your voice agent platform and LLM backend.
  • TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN authenticate SMS paging via Twilio.
  • ON_CALL_TECH_NUMBER is the phone number that receives emergency pages.
  • AIRTABLE_TOKEN and AIRTABLE_BASE_ID connect to the CRM stand-in where jobs and ledger entries are written.
  • Start ngrok to expose your local server by running in your second terminal tab:
npx ngrok http 3000

Ngrok prints a forwarding URL (something like https://abc123.ngrok-free.app). Copy this URL. You will paste it into Vapi later.

  • Test the health endpoint through your tunnel by opening a third terminal tab and running:
curl https://[[NGROK_URL="your-ngrok-forwarding-url"]]/health

You should see JSON output: {"status":"ok","timestamp":"..."}.

Not seeing the JSON response?

  • Make sure node server.js is still running in your first terminal tab.
  • Confirm ngrok shows Forwarding with a live URL. If it expired, restart it with npx ngrok http 3000.
  • Make sure you are using https:// (not http://) in the curl URL.

Help me debug my ngrok tunnel

✔️ Awesome, I've got everything!

Great. Make sure you have saved both server.js and .env.

ⓧ I'd like to double check the full code

Here is the full server.js at this point:

require("dotenv").config();
const express = require("express");

const app = express();
app.use(express.json());

// Health-check endpoint for verifying the server is running
app.get("/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`After-hours agent server running on port ${PORT}`);
});

And the full .env:

VAPI_API_KEY=your-vapi-api-key-here
ANTHROPIC_API_KEY=your-anthropic-api-key-here
TWILIO_ACCOUNT_SID=your-twilio-account-sid-here
TWILIO_AUTH_TOKEN=your-twilio-auth-token-here
TWILIO_PHONE_NUMBER=+15551234567
ON_CALL_TECH_NUMBER=+15559876543
AIRTABLE_TOKEN=your-airtable-personal-access-token-here
AIRTABLE_BASE_ID=your-airtable-base-id-here
BUSINESS_NAME=SA Plumbing Co
PORT=3000
Create accounts and verify connectivity

The agent needs four external services. You will create each account now, grab the credentials, and verify they work before writing any agent logic.

  • Go to https://vapi.ai and sign up for a new account.
  • This is the client's account in a real engagement. The client owns it and pays the per-minute cost directly.
  • Confirm the Vapi dashboard loads and shows $10 in free credits.

Why does the client own the Vapi account?

If you ever stop working together, the client keeps the agent running on their own account. Never resell minutes. Never create a dependency that requires you to stay involved.

  • Go to https://console.anthropic.com/settings/keys and sign up for an Anthropic account (or log in if you already have one).
  • Click Create Key to generate a new API key.
  • Copy the key immediately. You will not be shown it again.
  • Paste it into your .env file as the value for ANTHROPIC_API_KEY.
  • Go to https://www.twilio.com/try-twilio and sign up for a free trial.
  • Verify your email and personal phone number when prompted.
  • In the Twilio Console, go to Products & Services then Numbers & senders then Overview.
  • Click Set up a new phone number. Twilio will assign you a trial number.
  • Copy your Account SID and Auth Token from the Console dashboard.
  • Paste them into your .env as TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN. Replace TWILIO_PHONE_NUMBER with the trial number you just received (in E.164 format like +15551234567).

Twilio trial sends only to verified numbers

During the trial, you can only send SMS to numbers you have verified (up to 5). Your signup phone number is automatically verified. For this project, that is enough to test paging yourself.

  • Go to https://airtable.com and sign up or log in.
  • Create a new base called After-Hours Agent.
  • Rename the default table to Jobs and add these fields (all Single line text unless noted): CallSID, Name, Address, Problem, AccessNotes, Urgency, TechAssigned, Status, CreatedAt.
  • Create a second table in the same base called Ledger with these fields: CallSID, Timestamp, CallerNumber, Disposition, AttributedValue (Number), Branch.
  • Generate an Airtable personal access token:
  • Click your profile icon in the top-right corner and select Developer Hub.
  • Click Create new token.
  • Name it after-hours-agent.
  • Under Scopes, add data.records:write.
  • Under Access, click Add a base and select your After-Hours Agent base.
  • Click Create token. Copy the token immediately. It is shown only once.
  • Paste the token into your .env as AIRTABLE_TOKEN.
  • Find your Airtable base ID: open the base in your browser and copy the string after airtable.com/ that starts with app. Paste it into .env as AIRTABLE_BASE_ID.

Now verify each service works.

  • Test Airtable by creating a record. Run this in your third terminal tab:
curl -X POST "https://api.airtable.com/v0/[[AIRTABLE_BASE_ID="your-airtable-base-id"]]/Jobs" \
  -H "Authorization: Bearer your-airtable-token-here" \
  -H "Content-Type: application/json" \
  -d '{"fields": {"CallSID": "test-001", "Name": "Test Record", "Status": "New"}}'

You should see a JSON response containing your new record with an id field. Open your Airtable base in the browser to confirm the row appeared in the Jobs table.

  • Test Twilio by sending yourself a test SMS. In the Twilio Console, go to Messaging then click Try SMS.
  • Follow the on-screen instructions to send a test message to your verified phone number.
  • Confirm the SMS arrives on your phone.

Airtable returning a 403?

  • Make sure your personal access token has the data.records:write scope.
  • Confirm you added the correct base under the token's Access settings. The token can only write to bases explicitly granted to it.
  • Double-check that AIRTABLE_BASE_ID starts with app (not the table ID that starts with tbl).

Help me fix my Airtable API authentication

  • Update your .env file with the real values you collected: TWILIO_PHONE_NUMBER (your trial number), and ON_CALL_TECH_NUMBER (your personal phone number for testing).
  • Save the .env file.
Verify the full stack

Before you think about what you expect to see, consider: you now have a running Express server, a public tunnel to it, and three external services with real credentials. What should a successful health check through the tunnel prove?

  • Verify your tools by running these commands in a fresh terminal:
node --version
npx ngrok --version

Both should print version numbers. Then confirm your GitHub repo has the README visible at its URL, and your Linear project shows all five epics.

  • Push your initial code to GitHub by running:
git add .
git commit -m "scaffold: express server, env template, project structure"
git push origin main

Open your GitHub repo in the browser. You should see server.js, package.json, and the docs/ directory listed alongside the README.

Keep .env out of Git

The Node .gitignore template you selected already excludes .env. Your secrets never leave your machine.

Your engineering infrastructure is ready. Every service can talk to your server through the tunnel. Next up, you will pull the plumber's call log and run the numbers that prove what his missed calls cost him.

Audit the Call Log and Model the Revenue Leak

Your server is running, accounts are wired, and the engineering process is in place. Now comes the first real deliverable.

The plumber does not know what his missed calls cost him. He answered 9 calls over a freeze weekend and let 22 ring. Two of those callers told him later they hired whoever answered first. This step produces the one-page report that shows him the number, in his own data, and justifies every dollar spent on the agent.

In this step, get ready to:
  • Load the plumber's freeze-weekend call log as fixture data.
  • Run an analysis script that models the revenue leak from missed calls.
  • Commit the report as a PR against the audit epic ticket.
Create the call-log fixture data

In production, you would pull this from the client's phone system or answering service. For this project, you will use a structured JSON fixture that models a real San Antonio freeze weekend: 31 calls over 60 hours, with timestamps, answer status, and problem hints.

  • Create a data directory by running:
mkdir -p data
  • Create data/call-log.json and paste the first portion of the call log (metadata and the first 15 calls):
{
  "metadata": {
    "business": "SA Plumbing Co",
    "location": "San Antonio, TX",
    "period": "2026-01-09T18:00:00Z to 2026-01-12T06:00:00Z",
    "event": "Freeze weekend",
    "avg_emergency_ticket": 2100,
    "avg_routine_ticket": 340,
    "routine_booking_rate": 0.6
  },
  "calls": [
    {"id": "c01", "timestamp": "2026-01-09T18:12:00Z", "answered": true, "problem_hint": "slow drain kitchen", "caller": "+12105550101"},
    {"id": "c02", "timestamp": "2026-01-09T18:45:00Z", "answered": true, "problem_hint": "water heater pilot out", "caller": "+12105550102"},
    {"id": "c03", "timestamp": "2026-01-09T19:10:00Z", "answered": true, "problem_hint": "toilet running", "caller": "+12105550103"},
    {"id": "c04", "timestamp": "2026-01-09T19:35:00Z", "answered": true, "problem_hint": "faucet drip", "caller": "+12105550104"},
    {"id": "c05", "timestamp": "2026-01-09T20:02:00Z", "answered": true, "problem_hint": "garbage disposal jammed", "caller": "+12105550105"},
    {"id": "c06", "timestamp": "2026-01-09T20:30:00Z", "answered": true, "problem_hint": "frozen pipe concern", "caller": "+12105550106"},
    {"id": "c07", "timestamp": "2026-01-09T21:15:00Z", "answered": true, "problem_hint": "burst pipe garage", "caller": "+12105550107"},
    {"id": "c08", "timestamp": "2026-01-09T21:50:00Z", "answered": true, "problem_hint": "no hot water", "caller": "+12105550108"},
    {"id": "c09", "timestamp": "2026-01-09T22:05:00Z", "answered": true, "problem_hint": "leak under sink", "caller": "+12105550109"},
    {"id": "c10", "timestamp": "2026-01-09T22:40:00Z", "answered": false, "problem_hint": "burst pipe basement", "caller": "+12105550110", "hired_competitor": true, "ticket_value": 2400},
    {"id": "c11", "timestamp": "2026-01-09T23:15:00Z", "answered": false, "problem_hint": "frozen pipe burst", "caller": "+12105550111"},
    {"id": "c12", "timestamp": "2026-01-09T23:50:00Z", "answered": false, "problem_hint": "no heat furnace", "caller": "+12105550112"},
    {"id": "c13", "timestamp": "2026-01-10T00:20:00Z", "answered": false, "problem_hint": "toilet overflow", "caller": "+12105550113"},
    {"id": "c14", "timestamp": "2026-01-10T00:55:00Z", "answered": false, "problem_hint": "water heater leaking", "caller": "+12105550114", "hired_competitor": true, "ticket_value": 1800},
    {"id": "c15", "timestamp": "2026-01-10T01:30:00Z", "answered": false, "problem_hint": "slab leak suspicion", "caller": "+12105550115"}
  ]
}

What is this data?

The metadata section holds the plumber's own averages from his completed-job history. These are not industry benchmarks. avg_emergency_ticket ($2,100) and avg_routine_ticket ($340) come from what he actually charges. routine_booking_rate (0.6) is his historical conversion from inquiry to booked job.

Each call record has a timestamp, whether it was answered, and a problem_hint field representing what the caller said or later reported. Two missed calls are marked hired_competitor: true with their confirmed ticket values.

  • The file is not complete yet. Add the remaining 16 calls below the last entry (after c15). Replace the closing ] and } from the first paste with the following:
    {"id": "c16", "timestamp": "2026-01-10T02:10:00Z", "answered": false, "problem_hint": "burst pipe upstairs", "caller": "+12105550116"},
    {"id": "c17", "timestamp": "2026-01-10T02:45:00Z", "answered": false, "problem_hint": "frozen outdoor spigot", "caller": "+12105550117"},
    {"id": "c18", "timestamp": "2026-01-10T03:20:00Z", "answered": false, "problem_hint": "no water at all", "caller": "+12105550118"},
    {"id": "c19", "timestamp": "2026-01-10T04:00:00Z", "answered": false, "problem_hint": "ceiling dripping", "caller": "+12105550119"},
    {"id": "c20", "timestamp": "2026-01-10T05:15:00Z", "answered": false, "problem_hint": "drain backup sewage smell", "caller": "+12105550120"},
    {"id": "c21", "timestamp": "2026-01-10T18:30:00Z", "answered": false, "problem_hint": "faucet replacement quote", "caller": "+12105550121"},
    {"id": "c22", "timestamp": "2026-01-10T22:10:00Z", "answered": false, "problem_hint": "flood from water heater", "caller": "+12105550122"},
    {"id": "c23", "timestamp": "2026-01-10T23:45:00Z", "answered": false, "problem_hint": "no heat house freezing", "caller": "+12105550123"},
    {"id": "c24", "timestamp": "2026-01-11T00:30:00Z", "answered": false, "problem_hint": "pipe burst kitchen wall", "caller": "+12105550124"},
    {"id": "c25", "timestamp": "2026-01-11T01:15:00Z", "answered": false, "problem_hint": "slow drain bathtub", "caller": "+12105550125"},
    {"id": "c26", "timestamp": "2026-01-11T02:00:00Z", "answered": false, "problem_hint": "toilet wont flush", "caller": "+12105550126"},
    {"id": "c27", "timestamp": "2026-01-11T03:30:00Z", "answered": false, "problem_hint": "water heater no hot water", "caller": "+12105550127"},
    {"id": "c28", "timestamp": "2026-01-11T04:45:00Z", "answered": false, "problem_hint": "frozen pipes no water", "caller": "+12105550128"},
    {"id": "c29", "timestamp": "2026-01-11T05:20:00Z", "answered": false, "problem_hint": "leak garage ceiling", "caller": "+12105550129"},
    {"id": "c30", "timestamp": "2026-01-11T19:00:00Z", "answered": false, "problem_hint": "shower valve replacement", "caller": "+12105550130"},
    {"id": "c31", "timestamp": "2026-01-11T23:00:00Z", "answered": false, "problem_hint": "gas smell near water heater", "caller": "+12105550131"}
  ]
}
  • Save data/call-log.json.
  • Verify the fixture loads correctly by running:
node -e "const d = require('./data/call-log.json'); console.log(d.calls.length + ' calls loaded')"

You should see 31 calls loaded in the terminal.

Seeing a JSON parse error?

Check that the two code blocks are joined correctly. The last entry from the first paste (c15) should end with a comma, and c16 from the second paste follows immediately after. Make sure the file has exactly one opening { and one closing } with the array closed by ] before it.

help me fix a JSON parse error in my call-log.json file

Write the audit script

The audit script reads the call log, classifies missed calls as emergency-pattern or routine, applies the plumber's own averages, and outputs a one-page markdown report he can read in his truck.

  • Create scripts/audit.js and paste the data processing logic:
const fs = require("fs");
const path = require("path");

const callLog = JSON.parse(
  fs.readFileSync(path.join(__dirname, "../data/call-log.json"), "utf8")
);

const totalCalls = callLog.calls.length;
const answered = callLog.calls.filter((c) => c.answered);
const missed = callLog.calls.filter((c) => !c.answered);
const competitorHires = missed.filter((c) => c.hired_competitor);

const emergencyKeywords = ["burst", "flood", "heater", "no-heat", "frozen", "no heat"];
const emergencyMissed = missed.filter((c) =>
  emergencyKeywords.some((kw) => c.problem_hint.toLowerCase().includes(kw))
);
const routineMissed = missed.filter(
  (c) => !emergencyKeywords.some((kw) => c.problem_hint.toLowerCase().includes(kw))
);

What does this code do?

It reads the fixture, splits calls into answered vs missed, and identifies emergency-pattern missed calls using keywords the plumber's own call history shows up in urgent situations: burst, flood, heater, no-heat, frozen, no heat. Anything that does not match is classified as routine.

  • Add the revenue calculations and the report template opening below the existing code:
const avgEmergencyTicket = callLog.metadata.avg_emergency_ticket;
const avgRoutineTicket = callLog.metadata.avg_routine_ticket;
const routineBookingRate = callLog.metadata.routine_booking_rate;

const emergencyLoss = emergencyMissed.length * avgEmergencyTicket;
const routineLoss = routineMissed.length * avgRoutineTicket * routineBookingRate;
const confirmedLoss = competitorHires.reduce((sum, c) => sum + c.ticket_value, 0);
const modeledWeekendLeak = emergencyLoss + routineLoss;
const modeledQuarterlyLeak = modeledWeekendLeak * 6;

const report = `# Revenue Leak Report: Freeze Weekend Call Log Audit

## Summary

| Metric | Value |
|--------|-------|
| Total calls (Fri 6pm - Mon 6am) | ${totalCalls} |
| Answered | ${answered.length} |
| Missed | ${missed.length} |
| Confirmed competitor hires | ${competitorHires.length} |
| Confirmed lost revenue | $${confirmedLoss.toLocaleString()} |
| Emergency-pattern missed calls | ${emergencyMissed.length} |
| Routine missed calls | ${routineMissed.length} |

What does the report template do?

The template literal builds a markdown document using the plumber's own numbers. emergencyLoss multiplies emergency-pattern missed calls by his average emergency ticket. routineLoss does the same for routine calls but factors in his historical booking rate. The quarterly projection multiplies by 6 freeze-event weekends per San Antonio winter quarter.

  • Add the modeled-leak section and the closing backtick that completes the template literal:

## Modeled Revenue Leak (estimate)

Assumptions: emergency-pattern calls valued at your average emergency ticket ($${avgEmergencyTicket}, from your completed-job history). Routine calls valued at your average routine ticket ($${avgRoutineTicket}) times your historical booking rate (${(routineBookingRate * 100).toFixed(0)}%). Quarterly projection assumes 6 freeze-event weekends per quarter (San Antonio winter pattern).

- Emergency missed x your avg ticket: $${emergencyLoss.toLocaleString()}
- Routine missed x your avg ticket x your booking rate: $${routineLoss.toLocaleString()}
- Modeled weekend leak: $${modeledWeekendLeak.toLocaleString()}
- Modeled quarterly leak (6 freeze weekends): $${modeledQuarterlyLeak.toLocaleString()}

## Answer Rate by Window

${generateWindowTable(callLog.calls)}

## What This Means

You answered 9 of 31 calls, all between 6pm-10pm. After 10pm, zero calls were answered. Two callers confirmed they hired a competitor. The modeled quarterly leak from freeze weekends alone is $${modeledQuarterlyLeak.toLocaleString()}.

These are estimates based on your own averages and your own call log. The real number is higher or lower depending on how many of those 22 callers would have booked. The only way to know is to answer the phone and measure.
`;
  • Add the generateWindowTable function below the template literal:
function generateWindowTable(calls) {
  const windows = [
    "6pm-8pm", "8pm-10pm", "10pm-12am",
    "12am-2am", "2am-4am", "4am-6am",
  ];
  const windowHours = [18, 20, 22, 0, 2, 4];

  let table = "| Window | Total | Answered | Missed | Answer Rate |\n";
  table += "|--------|-------|----------|--------|-------------|\n";

  for (let i = 0; i < windows.length; i++) {
    const startHour = windowHours[i];
    const endHour = i < windows.length - 1 ? windowHours[i + 1] : 6;
    const inWindow = calls.filter((c) => {
      const hour = new Date(c.timestamp).getHours();
      if (startHour < endHour) return hour >= startHour && hour < endHour;
      return hour >= startHour || hour < endHour;
    });
    const answeredInWindow = inWindow.filter((c) => c.answered).length;
    const rate = inWindow.length > 0
      ? Math.round((answeredInWindow / inWindow.length) * 100)
      : 0;
    table += `| ${windows[i]} | ${inWindow.length} | ${answeredInWindow} | ${inWindow.length - answeredInWindow} | ${rate}% |\n`;
  }

  return table;
}

What does generateWindowTable do?

It splits the 60-hour window into 2-hour blocks and computes the answer rate for each. This surfaces the pattern the plumber cannot see from his phone: he answers everything before 10pm and nothing after. The table makes the drop-off impossible to ignore.

  • Complete the script by adding the file-write logic after the function:
const outputPath = path.join(__dirname, "../docs/leak-report.md");
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, report);
console.log(`Leak report written to ${outputPath}`);
  • Save scripts/audit.js.

Before you run this, what do you expect the report to show for the 10pm-6am window?

  • Run the audit script:
npm run audit

You should see:

Leak report written to /path/to/your/project/docs/leak-report.md
  • Open docs/leak-report.md and confirm it shows: 31 total calls, 9 answered, 22 missed, 2 confirmed competitor hires, and a modeled quarterly leak figure clearly labeled as an estimate.

Script errors or empty report?

If you see SyntaxError: Unexpected token, the template literal is not closed properly. Check that the closing backtick and semicolon (`;) appear on their own line after the last paragraph of the report.

If generateWindowTable is not defined, make sure the function definition appears below the template literal, not above it. JavaScript hoists function declarations, but verify the function is present in your file.

help me debug my audit.js script that is failing to generate the leak report

✔️ Awesome, I've got everything!

Great. Double check you have saved both data/call-log.json and scripts/audit.js, and that docs/leak-report.md was generated.

ⓧ I'd like to double check the full code

Scroll up and confirm these chunks are present in order in scripts/audit.js:

  • Chunk 1: imports, file read, call filtering, emergency keyword matching.
  • Chunk 2: revenue calculations (avgEmergencyTicket, emergencyLoss, modeledQuarterlyLeak) and the report template opening through the summary table.
  • Chunk 3: modeled-leak section, answer-rate-by-window section, and the closing backtick.
  • Chunk 4: generateWindowTable function.
  • Chunk 5: file output (outputPath, fs.mkdirSync, fs.writeFileSync, console.log).

For data/call-log.json, confirm you have a metadata object and a calls array with exactly 31 entries (c01 through c31).

Push the branch and close the Linear ticket

Every deliverable ships through the repo as a branch-per-ticket PR. This makes the work auditable and gives the client a paper trail.

  • Create a branch and stage the new files by running:
git checkout -b audit/leak-model
  • Commit the new files by running:
git add data/call-log.json scripts/audit.js docs/leak-report.md
git commit -m "Add call-log fixture and revenue leak report"
  • Push the branch to GitHub by running:
git push -u origin audit/leak-model
  • Open your GitHub repo in the browser. You should see a banner prompting you to create a Pull Request from audit/leak-model.
  • Click Compare & pull request.
  • In the PR description, reference your Linear ticket ID for the audit epic (e.g. "Closes PROJ-1" or paste the ticket link).
  • Click Create pull request, then Merge pull request, then Confirm merge.
  • In Linear, open the audit epic ticket and mark it as Done.

Why branch-per-ticket?

Each PR maps to one Linear ticket. The client (or a future auditor) can trace any deliverable back to the decision that motivated it. If something breaks, you know exactly which merge introduced it and what the reasoning was.

🙋‍♀️ Push rejected or permission error?

If git push fails with a permission error, confirm you cloned the repo via HTTPS with your credentials or SSH with your key. Run git remote -v to check the remote URL matches your GitHub repo.

help me fix a git push permission error to my GitHub repo

You now have a one-page deliverable that earns the engagement. The plumber can see his own numbers on paper. Next up, you will write the decision document and triage taxonomy that governs every choice the agent will make.

Write the Decision Document and Triage Taxonomy

Your leak report proves the problem in dollars. Now you need to answer the question that comes next: what exactly will the agent do, and what will it never do?

Every decision that governs the agent's behaviour must be written down and agreed before the first line of agent code. A gas leak routed to a Tuesday appointment is a liability, not a feature. This step produces two consulting artifacts: a decision document that locks in the rules, and a triage taxonomy that encodes the emergency categories the agent must escalate before any booking flow runs.

In this step, get ready to:
  • Write a five-section decision document covering platform choice, triage rules, disclosure, no-quoting, and attribution.
  • Build a JSON triage taxonomy with four emergency categories and safety instructions.
  • Create a validation script and confirm the taxonomy passes schema checks.
Write the decision document

The decision document is the artifact you hand the client before writing a single line of agent code. It records what the system will do, what it will never do, and who owns what.

  • Create a new file called docs/decisions.md inside your docs/ directory (the same folder that already holds leak-report.md).
  • Add the first three decision sections by pasting the following content:
# Decision Document: After-Hours Voice Agent

## 1. Platform Choice, Tradeoff, and Account Ownership

Platform: Vapi (provider-agnostic orchestration at ~$0.05/min plus provider costs).

Why Vapi for this client: He may want to change the LLM or voice later and should not be locked to one vendor's model.

Alternative: For a client where voice quality is the deciding factor and they will never switch, use a single-vendor platform (ElevenLabs Conversational AI or Retell).

Account ownership: The account belongs to the client. The client pays the per-minute cost directly. Never resell minutes. The client must be able to fire the builder and keep the agent running.

## 2. Triage Before Booking (Fails Closed)

Emergency triage runs first. Any ambiguity routes to a human, never to the booking flow. This is non-negotiable.

A gas leak routed to a Tuesday appointment is a liability, not a feature.

## 3. AI Disclosure and Consent

The agent identifies itself as an AI assistant in the first sentence and obtains verbal consent to proceed.

What do these three sections cover?

  • Section 1 locks in account ownership. The client pays Vapi directly and can fire the builder without losing the agent.
  • Section 2 is the safety gate. The agent never routes an ambiguous call into the booking flow.
  • Section 3 is a legal and trust requirement. The caller knows they are speaking to AI before the conversation continues.
  • Now add the final two sections below the content you just pasted:
## 4. No-Quoting Rule

The agent never states a price. It answers pricing questions with one honest sentence: "Pricing depends on what the tech finds on site." Then it books a diagnostic visit.

A quote the tech cannot honour costs more than the missed call did.

## 5. Attribution Rule

A job is attributed to the agent if the caller's phone number matches a completed job in the CRM within 7 days of the agent-created record, verified against bank deposits.

This rule is agreed in writing before the first call. Never argue attribution afterwards. The ledger is the contract and the client can audit it.

Why write the attribution rule before writing code?

Attribution is where engagements die. If you wait until the 30-day review to define what counts as a recovered job, you and the client will argue feelings instead of numbers. The ledger is the contract. Both sides agree on the rule before the first call touches the line.

  • Save docs/decisions.md.
  • Confirm the file exists by running:
ls docs/decisions.md

You should see docs/decisions.md printed in your terminal output.

File not found?

  • Make sure you created the file inside the docs/ folder, not in the project root.
  • Check that your terminal is in the project root directory (where package.json lives).

Why isn't my decisions.md showing up?

Build the triage taxonomy and validation script

The triage taxonomy encodes the four emergency categories the agent must catch before any booking flow runs. Each category carries the exact safety instruction the agent will speak, and a validation script ensures the taxonomy stays well-formed as the team edits it.

  • Create a new file called docs/triage-taxonomy.json and paste the following content:
[
  {
    "id": "gas_smell",
    "trigger_phrases": ["gas smell", "smell gas", "smells like gas", "gas leak", "rotten egg smell near"],
    "safety_instruction": "If you smell gas, please leave the house immediately. Do not use any light switches or electronics. Once you are safely outside, call your gas company emergency line or 911. I am paging our on-call technician right now.",
    "escalation_action": "page_on_call"
  },
  {
    "id": "active_flood",
    "trigger_phrases": ["flooding", "water everywhere", "burst pipe", "pipe burst", "water pouring", "water shooting", "gushing water", "house is flooding"],
    "safety_instruction": "I need you to locate your main water shutoff valve and turn it off if you can safely reach it. This will stop the flow while our tech gets to you. I am paging the on-call technician now.",
    "escalation_action": "page_on_call"
  },
  {
    "id": "electrical_burning_smell",
    "trigger_phrases": ["burning smell", "electrical fire", "smoke from outlet", "sparking", "burning near water heater", "smoke from panel"],
    "safety_instruction": "If you smell burning or see smoke, please leave the house immediately and call 911. Do not touch any electrical panels or water near the source. I am paging our on-call technician right now.",
    "escalation_action": "page_on_call"
  },
  {
    "id": "freeze_no_heat",
    "trigger_phrases": ["no heat", "heat is out", "furnace not working", "house is freezing", "pipes might freeze", "no heat and its freezing"],
    "safety_instruction": "With temperatures below freezing, your pipes are at risk of bursting. If you have space heaters, place them near exposed pipes in the garage or crawl space. Open cabinet doors under sinks on exterior walls. I am paging the on-call technician now to get heat restored as quickly as possible.",
    "escalation_action": "page_on_call"
  }
]

What makes these categories non-negotiable?

Each category represents a situation where delay creates property damage or physical danger. Gas leaks can ignite. Active floods cause thousands in damage per hour. Electrical faults can start fires. Freeze conditions burst pipes in minutes.

The safety_instruction field is what the agent actually says to the caller. In production, the client's senior tech reviews and signs off on these scripts before they go live.

  • Save docs/triage-taxonomy.json.
  • Verify the file parses as valid JSON by running:
node -e "const t = require('./docs/triage-taxonomy.json'); console.log(t.length + ' categories loaded')"

You should see 4 categories loaded in the output.

Seeing a JSON parse error?

  • Check for trailing commas after the last object in the array. JSON does not allow trailing commas.
  • Confirm every string uses double quotes, not single quotes.
  • Make sure the file is saved before running the command.

Help me fix a JSON parse error in my triage taxonomy file.

Now you need a script that validates the taxonomy schema stays correct as the team edits it. This becomes the CI check that prevents broken categories from reaching production.

  • Create a new file called scripts/validate-taxonomy.js and add the file setup and initial checks:
const fs = require("fs");
const path = require("path");

const taxonomyPath = path.join(__dirname, "../docs/triage-taxonomy.json");

try {
  const raw = fs.readFileSync(taxonomyPath, "utf8");
  const taxonomy = JSON.parse(raw);

  if (!Array.isArray(taxonomy)) {
    throw new Error("Taxonomy must be an array of emergency categories");
  }

  const requiredFields = [
    "id",
    "trigger_phrases",
    "safety_instruction",
    "escalation_action",
  ];
  const errors = [];

What does this setup do?

  • It reads triage-taxonomy.json from disk and parses it.
  • It immediately checks that the parsed value is an array. If someone accidentally wraps the categories in an object, the script catches it.
  • It defines the four required fields every category must have, and an errors array to collect problems.
  • Below the code you just added, paste the field validation loop:
  for (const category of taxonomy) {
    for (const field of requiredFields) {
      if (!category[field]) {
        errors.push(`Category "${category.id || "unknown"}": missing "${field}"`);
      }
    }
    if (category.trigger_phrases && category.trigger_phrases.length === 0) {
      errors.push(`Category "${category.id}": trigger_phrases is empty`);
    }
    if (
      category.safety_instruction &&
      category.safety_instruction.length < 10
    ) {
      errors.push(
        `Category "${category.id}": safety_instruction too short (must be a real instruction)`
      );
    }
  }

What does this loop check?

  • For each category, it confirms all four required fields are present and non-empty.
  • It catches empty trigger_phrases arrays. A category with no triggers would never fire.
  • It rejects safety instructions shorter than 10 characters. A real instruction tells the caller what to do. A placeholder like "TBD" would slip through without this check.
  • Finally, add the required-categories check and the reporting logic below the for loop:
  const requiredCategories = [
    "gas_smell",
    "active_flood",
    "electrical_burning_smell",
    "freeze_no_heat",
  ];
  for (const reqId of requiredCategories) {
    if (!taxonomy.find((c) => c.id === reqId)) {
      errors.push(`Missing required category: "${reqId}"`);
    }
  }

  if (errors.length > 0) {
    console.error("Taxonomy validation FAILED:");
    errors.forEach((e) => console.error(`  - ${e}`));
    process.exit(1);
  }

  console.log(
    `Taxonomy validation PASSED: ${taxonomy.length} categories, all fields present.`
  );
  process.exit(0);
} catch (error) {
  console.error(`Taxonomy validation ERROR: ${error.message}`);
  process.exit(1);
}

What does this final section do?

  • It hard-codes the four required category IDs. If someone accidentally deletes gas_smell, the script catches it before deployment.
  • If any errors accumulated, it prints them all and exits with code 1 (failure). A CI system treats exit code 1 as a blocked merge.
  • On success, it prints a confirmation and exits with code 0.
  • Save scripts/validate-taxonomy.js.
  • Before you run the validator, what do you think the output will be? The taxonomy has all four categories, each with all required fields.
  • Run the validation by executing:
npm run validate

You should see:

Taxonomy validation PASSED: 4 categories, all fields present.

Seeing "validation FAILED" or a syntax error?

  • If you see a syntax error, check that all three code sections are pasted in order with no gaps or duplicated lines between them.
  • If validation fails with a missing field error, open docs/triage-taxonomy.json and confirm every category has all four keys: id, trigger_phrases, safety_instruction, escalation_action.
  • Confirm your package.json has "validate": "node scripts/validate-taxonomy.js" in its scripts section.

Help me debug why my taxonomy validation is failing.

✔️ Awesome, I've got everything!

Great. Double check you have saved both docs/triage-taxonomy.json and scripts/validate-taxonomy.js.

ⓧ I'd like to double check the full code

Your scripts/validate-taxonomy.js should contain all three sections you pasted above, in order. Scroll up and confirm:

  • Section 1: the require statements, file read, array check, and requiredFields definition.
  • Section 2: the for (const category of taxonomy) loop with three inner checks.
  • Section 3: the requiredCategories array, check loop, error reporting, success log, and the catch block.
Commit as a PR and close the Linear ticket

Both artifacts are ready. Time to commit them on a dedicated branch so the decision trail is auditable in the repo history.

  • Create the branch and commit by running these commands:
git checkout -b design/decisions-triage
git add docs/decisions.md docs/triage-taxonomy.json scripts/validate-taxonomy.js
git commit -m "Add decision document and triage taxonomy with validator"
  • Push the branch by running:
git push origin design/decisions-triage
  • Open GitHub and create a Pull Request from design/decisions-triage into main.
  • In the PR description, reference the Linear ticket ID from the "Triage Taxonomy and Safety Scripts" epic.
  • Note in the PR description: in production, the senior tech reviews and signs off on the safety instructions before merge.
  • Merge the PR.
  • Return to Linear and close the ticket for the design/triage epic.

Confirm your work: open docs/decisions.md and docs/triage-taxonomy.json in your repo on GitHub. The decision document shows all five rules including the platform tradeoff. The taxonomy JSON has 4 categories, each with safety_instruction and escalation_action. The Linear ticket is closed.

Push rejected or branch conflict?

  • If git push is rejected, pull the latest main first with git pull origin main --rebase then push again.
  • If you get a merge conflict in the docs/ folder, accept both changes (your new files and the existing leak-report.md).

Help me push my design/decisions-triage branch to GitHub.

Your rules are locked in and your emergency taxonomy has a schema guard. Next up, you will build the voice agent itself and wire it to the triage gate so you can hear it work on a real phone call.

Build the Voice Agent

Your triage gate and capture flow exist as documents. The taxonomy defines what counts as an emergency. The decision doc defines how the agent behaves. Now the agent needs to speak them.

A voice agent that tries to call tools with no backend listening is just a voice making promises it cannot keep. In this step, you will configure the Vapi Assistant with the full system prompt, register the three tool schemas, and stub the webhook handler so the agent can complete an entire conversation end-to-end.

In this step, get ready to:
  • Create the Vapi Assistant with the triage system prompt and AI disclosure opener.
  • Register three function tools and point them at your Express server.
  • Build a stub webhook handler that logs tool calls and returns placeholder results.
Create the Vapi Assistant with the system prompt

The Assistant is the core entity in Vapi. It holds the model choice, the voice, the system prompt that governs every conversation, and the first message the caller hears.

  • Open dashboard.vapi.ai and click Assistants in the left sidebar.
  • Click Create Assistant.
  • Select Blank Template as your starting point.
  • Change the assistant name to SA Plumbing After-Hours.
  • Under Model, select provider Anthropic and model claude-sonnet-4-20250514.
  • Choose a natural-sounding voice from the available options (a calm, professional voice works well for a plumbing service).

Now set the First Message field. This is what the caller hears immediately when the agent picks up:

Hi, you've reached SA Plumbing Co's after-hours service. I'm an AI assistant helping route calls tonight. Is it okay if I help you, or would you prefer a callback from our team in the morning?

Why disclose it is AI in the first sentence?

Your decisions document requires AI disclosure and verbal consent before proceeding. Starting with this upfront prevents surprise and builds trust. Callers who want a human hear it immediately and can say so.

Next, paste the following into the System Prompt field. This is the first half of the prompt covering triage:

You are an after-hours answering service for SA Plumbing Co in San Antonio, TX. You are an AI assistant, not a human.

TRIAGE GATE (check FIRST before any booking):
If the caller mentions any emergency below, deliver the safety instruction and call page_on_call immediately. Do NOT proceed to booking.
- Gas smell: "If you smell gas, please leave the house immediately. Do not use any light switches or electronics. Once safely outside, call your gas company or 911. I am paging our on-call technician right now."
- Active flood/burst pipe: "Locate your main water shutoff valve and turn it off if you can safely reach it. I am paging the on-call technician now."
- Electrical/burning smell: "If you smell burning or see smoke, leave the house immediately and call 911. I am paging our on-call technician right now."
- No heat during freeze: "Your pipes are at risk. Place space heaters near exposed pipes and open cabinet doors under sinks on exterior walls. I am paging the on-call technician now."

What does this triage section do?

The triage gate runs FIRST on every call. If the caller mentions gas, flooding, burning, or no heat during a freeze, the agent delivers the safety instruction and pages the on-call tech immediately. It never proceeds to booking for emergencies.

Now paste the second half of the system prompt directly below the triage section (still in the same System Prompt field):

CAPTURE FLOW (only after triage clears):
Collect: caller name, service address, problem description, access notes (gate code, dog, etc.), urgency. Then call book_job. After booking, call confirm_caller to send SMS confirmation.

CONFIDENCE CHECK:
If unsure about any info, or caller asks for a person, say: "Let me have someone from our team call you back shortly." Do NOT book.

NO-QUOTING RULE:
Never state a price. If asked, say: "Pricing depends on what the tech finds on site. We can schedule a diagnostic visit." Then continue with booking.

What does the full system prompt encode?

The prompt implements all five rules from your decision document. The triage gate runs first and fails closed to a human page. The capture flow only activates when triage clears. The confidence check prevents the agent from guessing. The no-quoting rule protects the tech from being held to a price the agent invented.

  • Set the Server URL field to your ngrok tunnel URL followed by /api/tools (e.g. https://abc123.ngrok.io/api/tools).
  • Click Publish to save the assistant.
  • Click Talk to Assistant to quickly test the greeting.

You should hear the AI disclosure greeting play back. The agent will ask what the problem is. You can say anything to confirm the conversation flows, but tools will not work yet.

Not hearing anything?

Make sure you selected an Anthropic model and a voice provider. If the assistant has no voice configured, the Talk to Assistant button will not produce audio.

help me debug

Register tool schemas on the Assistant

Vapi tools are function definitions the LLM can call mid-conversation. When the model decides to call a tool, Vapi POSTs the call to your server URL. You need three tools that match the triage and capture flows.

  • In your Vapi dashboard, click Tools in the left sidebar.
  • Click Create Tool.
  • Select Function as the tool type.
  • Set the function name to page_on_call.
  • Add the description: "Page the on-call technician for an emergency. Use when triage identifies gas, flood, electrical, or freeze emergency."
  • Add four parameters: caller_number (string, required), problem_summary (string, required), urgency (string, required), address (string, required).
  • Set the tool's server URL to your ngrok endpoint followed by /api/tools.

Repeat the process for the second tool:

  • Click Create Tool again.
  • Select Function type.
  • Set function name to book_job.
  • Add the description: "Book a routine service job in the CRM after triage clears and all fields are captured."
  • Add seven parameters: caller_name (string, required), address (string, required), problem_summary (string, required), access_notes (string, optional), urgency (string, required), caller_number (string, required), call_sid (string, required).
  • Set the tool's server URL to the same ngrok endpoint.

And the third tool:

  • Click Create Tool one more time.
  • Select Function type.
  • Set function name to confirm_caller.
  • Add the description: "Send an SMS confirmation to the caller after a job is booked."
  • Add three parameters: caller_number (string, required), job_summary (string, required), estimated_window (string, required).
  • Set the tool's server URL to the same ngrok endpoint.

Now attach all three tools to your assistant:

  • Click Assistants in the left sidebar.
  • Select SA Plumbing After-Hours from the list.
  • Scroll down to the Tools section and expand it.
  • Add page_on_call, book_job, and confirm_caller.
  • Click Publish to save your changes.

Finally, assign a phone number so you can call the agent from your real phone:

  • Click Phone Numbers in the left sidebar.
  • Click Create Phone Number.
  • Keep Free Vapi Number selected.
  • Enter your preferred area code (e.g. 210 for San Antonio).
  • Under Inbound Settings, find the Assistant dropdown and select SA Plumbing After-Hours.
  • Changes save automatically. Note down the phone number that appears.

Why a free Vapi number?

Vapi provides free numbers for testing that use your $10 signup credits. In production, the client's business line would forward to this number after hours. For now, you call it directly to test.

Stub the webhook handler for testing

When the agent decides to call a tool, Vapi POSTs a request to your server URL. Your server must respond with HTTP 200 and a results array, or the agent will tell the caller the tool failed.

  • In server.js, find the line app.use(express.json()); and add the Twilio require and client initialization directly below it:
const twilio = require('twilio');

// Initialize Twilio client for SMS paging (used in next step)
const twilioClient = twilio(
  process.env.TWILIO_ACCOUNT_SID,
  process.env.TWILIO_AUTH_TOKEN
);

Why import Twilio now?

The next step wires the real paging and SMS integrations. Adding the client initialization here keeps the file ready for that work. If your .env credentials are set, the client connects on startup.

  • Now add the stub tool handler below the /health route by pasting this code:
// Handle tool calls from Vapi
app.post('/api/tools', async (req, res) => {
  const { message } = req.body;

  // Only process tool-calls events from Vapi
  if (message.type !== 'tool-calls') {
    return res.status(200).json({ received: true });
  }

  const results = [];

  for (const toolCall of message.toolCallList) {
    const { id, name, arguments: args } = toolCall;
    console.log(`Tool called: ${name}`, args);
    // Return stub results until real integrations are wired
    let result;
    switch (name) {
      case 'page_on_call': result = 'Paging sent successfully'; break;
      case 'book_job': result = 'Job booked, confirmation pending'; break;
      case 'confirm_caller': result = 'SMS confirmation sent'; break;
      default: result = 'Unknown tool';
    }
    results.push({ toolCallId: id, result: String(result) });
  }

  // Vapi requires HTTP 200 with a results array
  res.status(200).json({ results });
});

What does this handler do?

Vapi sends a POST with message.type set to tool-calls and a toolCallList array. Each item has an id, name, and arguments.

The handler logs each tool call to your terminal (so you can see the agent is working) and returns a stub result string. The response must always be HTTP 200 with a results array where each entry has toolCallId and a result string.

The stubs are enough for the agent to complete conversations. In the next step, you will replace these stubs with real Twilio SMS and Airtable writes.

  • Save server.js.
  • Restart your server by pressing Ctrl+C in the terminal running it, then start it again by running:
node server.js

You should see the startup message confirming the server is running on port 3000.

Server crashing on startup?

If you see an error about Twilio credentials, make sure your .env file has values for TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN. The Twilio client validates these on initialization.

help me debug

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and the server restarted without errors.

ⓧ I'd like to double check the full code

Your server.js should now have these sections in order. Scroll up and confirm each is present:

  • The require('dotenv').config(), express, and twilio requires at the top.
  • The twilioClient initialization using your env vars.
  • The app.get('/health', ...) route.
  • The app.post('/api/tools', ...) handler with the switch statement returning stub results.
  • The app.listen(PORT, ...) at the bottom.
Test the full conversation loop

Before you run this test, think about what you expect to happen. You are calling an AI agent with a triage gate. You will say you smell gas. What should the agent do before anything else?

  • Call the Vapi phone number from your personal phone.
  • Listen for the AI disclosure greeting.
  • Say "yes" when asked if you would like help.
  • Say "I smell gas near my water heater."
  • Watch your terminal where server.js is running.

The agent should deliver the gas-smell safety instruction ("leave the house immediately, do not use light switches") and say it is paging the on-call tech. In your terminal, you should see a log line like:

Tool called: page_on_call { caller_number: '+1...', problem_summary: '...', urgency: 'emergency', address: '...' }

That log line proves the full loop is working: caller speaks, the model triages, triggers the correct tool, your server receives the call, and returns a result the agent reads back.

Not seeing tool calls in your terminal?

Confirm your ngrok tunnel is still running and the URL in the Vapi tool server URL matches. Ngrok URLs change each time you restart it unless you have a paid plan.

Also check that you clicked Publish after adding the tools to the assistant. Unpublished changes are not active on phone calls.

help me troubleshoot

  • Create a new branch and push your work by running:
git checkout -b agent/build
git add .
git commit -m "Add Vapi tool handler stub for after-hours agent"
git push -u origin agent/build
  • Open a pull request on GitHub. In the PR description, reference your Linear agent-build epic ticket.
  • Merge the PR and close the Linear ticket.

Your agent can now have a full conversation, triage an emergency, and hit your backend. Next up, you will replace those stub results with real SMS paging, real CRM writes, and a value ledger that tracks every call disposition.

Wire the Backend and Value Ledger

Your agent can hold a conversation, triage a gas smell, and capture a caller's details. But when the call ends, nothing actually happened. No SMS went out, no CRM record was created, and the stub handler just returned a fake string.

This step replaces the stubs with real integrations. Every tool call will trigger an actual SMS via Twilio, write a real job to Airtable, and record a row in the value ledger so attribution is possible at the 30-day review.

In this step, get ready to:
  • Wire the emergency paging handler to send a real SMS and log attribution.
  • Wire the job booking handler with idempotent CRM writes and caller confirmation.
  • Wire the value ledger so every call disposition is recorded, including calls that end with no action.
Add the Airtable constants and update the tool router

Every handler you build in this step needs to talk to the Airtable API. You need a base URL and authorization header defined once at the top of the file, rather than repeated inside each function.

  • In server.js, find the line that creates the Twilio client:
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
  • Add the Airtable configuration constants directly below that line:
// Airtable API config (used by all handlers)
const AIRTABLE_BASE_URL = `https://api.airtable.com/v0/${process.env.AIRTABLE_BASE_ID}`;
const AIRTABLE_HEADERS = {
  Authorization: `Bearer ${process.env.AIRTABLE_TOKEN}`,
  "Content-Type": "application/json",
};

What do these constants do?

Every Airtable API call needs the base URL (containing your base ID) and an Authorization header with your personal access token. Defining them once here keeps each handler clean.

  • Now replace the entire app.post("/api/tools", ...) route (the stub version) with this updated router that dispatches to real handler functions:
app.post("/api/tools", async (req, res) => {
  const { message } = req.body;
  if (message.type !== "tool-calls") return res.status(200).json({ received: true });

  const results = [];
  for (const toolCall of message.toolCallList) {
    const { id, name, arguments: args } = toolCall;
    let result;
    try {
      switch (name) {
        case "page_on_call":
          result = await handlePageOnCall(args, message.call);
          break;
        case "book_job":
          result = await handleBookJob(args, message.call);
          break;
        case "confirm_caller":
          result = await handleConfirmCaller(args);
          break;
        default:
          result = "Unknown tool";
      }
    } catch (error) {
      console.error(`Tool ${name} failed:`, error.message);
      result = `Error: ${error.message}`;
    }
    results.push({ toolCallId: id, result: String(result) });
  }
  res.status(200).json({ results });
});

What changed from the stub?

The try/catch block around each tool call means a single handler failure returns an error string to Vapi instead of crashing the server. The switch now calls real async functions you will define next.

Vapi expects HTTP 200 with a results array even for errors. The error string gets spoken to the caller so they know something went wrong.

  • Save server.js and restart your server by running:
node server.js

You should see After-hours agent server running on port 3000 in your terminal. The server starts because the handler functions are called at runtime, not at startup.

Server crashing on startup?

  • Check for missing commas or brackets in the new router code. A single missing } will cause a syntax error.
  • Make sure you deleted the entire old app.post("/api/tools", ...) block before pasting the new one. Duplicate route registrations cause Express to throw.

Help me fix a syntax error in my updated server.js tool router.

Implement the emergency paging handler

When the agent detects a gas smell or active flood, it calls the page_on_call tool. Right now that tool references a function that does not exist yet. You need it to send a real SMS to the on-call tech and record the call in the ledger.

  • Below the router, add the handlePageOnCall function by copying this code:
async function handlePageOnCall(args, call) {
  const { caller_number, problem_summary, urgency, address } = args;

  // Build the emergency page SMS body
  const smsBody =
    `EMERGENCY PAGE - ${problem_summary} at ${address}. ` +
    `Caller: ${caller_number}. Call back immediately.`;

  // Send SMS to the on-call tech
  await twilioClient.messages.create({
    body: smsBody,
    from: process.env.TWILIO_PHONE_NUMBER,
    to: process.env.ON_CALL_TECH_NUMBER,
  });

  // Record this call in the value ledger
  await writeLedgerRow({
    CallSID: call ? call.id : "unknown",
    Timestamp: new Date().toISOString(),
    CallerNumber: caller_number,
    Disposition: "emergency_escalation",
    AttributedValue: 2100,
    Branch: "triage_emergency",
  });

  return "On-call tech paged successfully via SMS";
}

What does this code do?

When the agent identifies an emergency, this function fires an SMS to the on-call tech with the caller's number, problem, and address. It also writes a ledger row attributing $2,100 (the client's average emergency ticket value from his own job history) to this call.

The call.id comes from the Vapi call object included in the webhook payload. It serves as the unique identifier tying the ledger row to this specific call.

  • The paging handler calls writeLedgerRow which does not exist yet. Add it directly below handlePageOnCall:
async function writeLedgerRow(fields) {
  // POST a new row to the Ledger table (append-only)
  const response = await fetch(`${AIRTABLE_BASE_URL}/Ledger`, {
    method: "POST",
    headers: AIRTABLE_HEADERS,
    body: JSON.stringify({ fields }),
  });

  if (!response.ok) {
    const errText = await response.text();
    console.error("Ledger write failed:", errText);
  }
}

Why POST and not PATCH here?

The ledger is append-only. Every call creates a new row. Unlike jobs (which must be idempotent), duplicate ledger writes are acceptable because each represents a real event. A simple POST to the Airtable create-record endpoint is all you need.

  • Save server.js and restart your server:
node server.js

Before you test, think about what you expect to happen when you tell the agent you smell gas. Will an SMS arrive on your on-call number? Will a row appear in the Ledger table?

  • Call your Vapi phone number from your personal phone.
  • When the agent asks what the problem is, say "I smell gas near my water heater."
  • The agent should deliver the safety instruction and tell you it is paging the on-call tech.

Check the phone number you set as ON_CALL_TECH_NUMBER in your .env file. You should see an SMS that starts with "EMERGENCY PAGE" followed by the problem and address.

  • Open your Airtable base and check the Ledger table. You should see a new row with Disposition emergency_escalation and AttributedValue 2100.

No SMS arrived?

  • Twilio trial accounts can only send SMS to verified numbers. Make sure ON_CALL_TECH_NUMBER is a number you verified in the Twilio console under Verified Caller IDs.
  • Check your terminal for a "Tool page_on_call failed" log. If you see an auth error, double-check TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN in .env.
  • Make sure ngrok is still running and the Vapi assistant's tool server URL matches your current ngrok tunnel URL.

Help me debug why my Twilio SMS is not arriving from the page_on_call handler.

Implement the job booking handler with idempotency

Routine calls (slow drain, faucet drip, toilet replacement) go through the booking flow. The agent captures caller details and calls book_job. This handler must create a CRM record, confirm to the caller via SMS, and log attribution.

There is a critical requirement: if Vapi retries the tool call (network hiccup, timeout), the handler must not create a duplicate job. Airtable's performUpsert with fieldsToMergeOn solves this by merging on the unique call ID.

  • In server.js, add the first half of handleBookJob below writeLedgerRow. This part handles the Airtable upsert:
async function handleBookJob(args, call) {
  const {
    caller_name, address, problem_summary,
    access_notes, urgency, caller_number, call_sid,
  } = args;
  // Use call_sid from args or fall back to Vapi's call ID
  const callId = call_sid || (call ? call.id : `call-${Date.now()}`);

  // Upsert job to Airtable (idempotent on CallSID)
  const jobResponse = await fetch(`${AIRTABLE_BASE_URL}/Jobs`, {
    method: "PATCH",
    headers: AIRTABLE_HEADERS,
    body: JSON.stringify({
      performUpsert: { fieldsToMergeOn: ["CallSID"] },
      records: [
        {
          fields: {
            CallSID: callId,
            Name: caller_name,
            Address: address,
            Problem: problem_summary,
            AccessNotes: access_notes || "",
            Urgency: urgency || "standard",
            TechAssigned: "On-call",
            Status: "New",
            CreatedAt: new Date().toISOString(),
          },
        },
      ],
    }),
  });

What does performUpsert do?

The PATCH request with performUpsert tells Airtable: if a record with this CallSID already exists, update it. If not, create it. This means the same call replayed five times still produces exactly one job record.

The fieldsToMergeOn: ["CallSID"] tells Airtable which field is the unique key. The call ID from Vapi guarantees uniqueness per call.

  • Continue the handleBookJob function directly below the previous code. This part handles the error check, caller SMS, and ledger write:
  if (!jobResponse.ok) {
    const errText = await jobResponse.text();
    throw new Error(`Airtable job creation failed: ${errText}`);
  }

  // Send confirmation SMS to the caller
  await twilioClient.messages.create({
    body:
      `Your service request has been received. A tech will contact you ` +
      `to confirm your appointment window. - ${process.env.BUSINESS_NAME}`,
    from: process.env.TWILIO_PHONE_NUMBER,
    to: caller_number,
  });

  // Record in the value ledger
  await writeLedgerRow({
    CallSID: callId,
    Timestamp: new Date().toISOString(),
    CallerNumber: caller_number,
    Disposition: "booked",
    AttributedValue: 340,
    Branch: "capture_routine",
  });

  return "Job booked in CRM and confirmation SMS sent to caller";
}

Why $340 attributed value?

The $340 comes from the client's own average routine ticket value, pulled from his completed-job history. The $2,100 for emergencies is his emergency average. These are not industry benchmarks. They are his numbers, auditable against his bank deposits.

  • Save server.js and restart the server:
node server.js
  • Call your Vapi phone number again.
  • This time, report a routine problem: "I have a slow drain in my kitchen at 123 Main St. My name is Garcia."
  • Let the agent complete the booking flow.

Open your Airtable base and check the Jobs table. You should see a new record with Name "Garcia", Address "123 Main St", and Problem mentioning "slow drain".

  • Check the phone you used to call. You should receive an SMS confirming the service request.

Airtable returning an error?

  • Verify your AIRTABLE_TOKEN has data.records:write scope. Check this on the Airtable personal access token settings page.
  • Make sure your Jobs table has fields named exactly CallSID, Name, Address, Problem, AccessNotes, Urgency, TechAssigned, Status, CreatedAt. Field names are case-sensitive.
  • Check that AIRTABLE_BASE_ID in your .env matches the base ID shown in your Airtable URL (starts with app).

Help me debug why my Airtable upsert is failing in the book_job handler.

Wire the end-of-call hook and confirm idempotency

Two gaps remain. The confirm_caller tool still references an undefined function. And calls that end without any tool call (caller hangs up, agent cannot complete capture) vanish from the record. Every call that hits the agent must appear in the ledger.

  • In server.js, add the handleConfirmCaller function below handleBookJob (and above writeLedgerRow):
async function handleConfirmCaller(args) {
  const { caller_number, job_summary, estimated_window } = args;

  // Send a follow-up confirmation SMS to the caller
  await twilioClient.messages.create({
    body:
      `Confirmed: ${job_summary}. ` +
      `Expected window: ${estimated_window}. - ${process.env.BUSINESS_NAME}`,
    from: process.env.TWILIO_PHONE_NUMBER,
    to: caller_number,
  });

  return "Confirmation SMS sent to caller";
}

What does this code do?

This tool sends a follow-up confirmation to the caller with their job summary and expected service window. The tech uses this when they have confirmed availability and want the system to notify the customer.

  • Now add the end-of-call hook. Place it after writeLedgerRow and before app.listen:
app.post("/api/end-of-call", async (req, res) => {
  const { message } = req.body;

  if (message.type === "end-of-call-report") {
    const call = message.call || {};
    const customerNumber =
      call.customer && call.customer.number
        ? call.customer.number
        : "unknown";

    await writeLedgerRow({
      CallSID: call.id || "unknown",
      Timestamp: new Date().toISOString(),
      CallerNumber: customerNumber,
      Disposition: "call_ended_no_action",
      AttributedValue: 0,
      Branch: "no_tool_called",
    });
  }

  res.status(200).json({ received: true });
});

Why a separate route for end-of-call?

Vapi's end-of-call-report event fires after the call disconnects. It is not a tool call. It arrives as a separate webhook POST with message.type set to "end-of-call-report". By catching these on a dedicated endpoint, you record abandoned calls, hang-ups, and any call where the agent could not complete the flow.

The AttributedValue: 0 means this call produced no revenue. But it still matters. At 30 days, if 40% of calls end with no action, the triage scripts or the opening prompt need work.

  • You need to tell Vapi to send end-of-call events to this new endpoint. In the Vapi dashboard, open your Assistant's settings.
  • Click the Advanced tab.
  • Set the Server URL field to your ngrok URL followed by /api/end-of-call.
  • Make sure end-of-call-report is enabled in Server Messages.

Where should end-of-call events go?

Vapi routes events by priority: Function URL > Assistant URL > Phone Number URL > Account URL. Tool calls already go to your function-level /api/tools endpoint. Setting the Assistant-level server URL to /api/end-of-call catches everything else, including the end-of-call report.

  • Save server.js and restart the server:
node server.js

Before you test, think about what should happen if you call the agent, report the same slow drain at 123 Main St as Garcia from the same phone number. Do you expect one job in Airtable or two?

  • Call the Vapi number again from the same phone and report the same problem: "slow drain at 123 Main St, name is Garcia."
  • After the call completes, open your Airtable Jobs table.

You should see exactly one job record for Garcia at 123 Main St. The performUpsert matched on CallSID and updated instead of creating a duplicate. This is idempotency working as designed.

  • Check the Ledger table. You should see rows for your emergency call (disposition emergency_escalation) and your booking calls (disposition booked).

Seeing duplicate jobs in Airtable?

  • Make sure the CallSID field in your Jobs table is a "Single line text" field type. Number or auto-number types will not match on upsert.
  • Verify the agent is passing call_sid in the tool arguments. Check your terminal logs for the tool call arguments.
  • If call_sid is undefined, the fallback uses call.id from the Vapi payload. Each new call from the same number gets a new ID, so two separate calls will correctly create two jobs. That is expected behavior.

Help me understand why I am seeing duplicate jobs despite using performUpsert.

  • Create a new branch for this work by running:
git checkout -b agent/backend
  • Stage and commit your changes:
git add server.js
git commit -m "Wire real integrations: Twilio SMS, Airtable upsert, value ledger"
  • Push the branch:
git push origin agent/backend
  • Open a PR in GitHub, merge it, and close the backend/ledger epic ticket in Linear.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and your branch is pushed.

ⓧ I'd like to double check the full code

Your server.js is built from the following chunks, in order. Scroll up and confirm each is present:

  • Requires and setup (dotenv, express, twilio, app.use(express.json())).
  • Twilio client and Airtable constants (twilioClient, AIRTABLE_BASE_URL, AIRTABLE_HEADERS).
  • Health check route (GET /health).
  • Tool router (POST /api/tools with try/catch dispatching to handlePageOnCall, handleBookJob, handleConfirmCaller).
  • handlePageOnCall function (SMS to on-call tech + ledger write with emergency_escalation).
  • handleBookJob function (Airtable upsert + SMS to caller + ledger write with booked).
  • handleConfirmCaller function (confirmation SMS).
  • writeLedgerRow helper (POST to Airtable Ledger).
  • End-of-call route (POST /api/end-of-call with ledger write for call_ended_no_action).
  • app.listen on PORT.

Every call that hits the agent now leaves a trace. Emergencies page the tech. Routine work lands in the CRM. The ledger records every outcome. Next up, you will prove this works at scale by running a seeded call set and scoring the results.

Secret mission

Seeded Call Set, Acceptance Scoring, and Client Readout

The agent works on a phone call. But trust is not a feeling. Before a single real caller touches this system, you run 45 adversarial test scenarios, score field-by-field against ground truth, produce the client readout with the actual numbers, write the runbook, and tag the release.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses paid services (Vapi and Twilio) that charge ongoing fees if left active.

Cost warning

Vapi charges approximately $0.05/min in orchestration fees plus provider costs (all-in ~$0.13-$0.31/min) for every inbound call the agent handles. Twilio charges ~$1.50/mo for the phone number plus $0.0079 per SMS segment.

If you leave the Vapi assistant active and the phone number forwarding calls, charges accrue with every call. Pause or delete if you are done testing.

Resources you used:

  • Vapi assistant and phone number (client's account, ~$0.15/min per answered call after $10 free credits).
  • Twilio phone number (~$1.50/mo + $0.0079/SMS segment).
  • Airtable base with Jobs and Ledger tables (free tier, up to 1,000 records).
  • Anthropic API key (usage billed per token through Vapi, included in per-minute cost).
  • ngrok tunnel (free tier, session-limited, stops when your terminal closes).
  • Local project folder on your Desktop.
  • GitHub repository (private, with tagged release).
  • Linear project with closed tickets.

Keep everything running

No action needed. Choose this if you are still actively building or plan to deploy the agent to the client's production line.

  • The Vapi assistant remains active on its phone number. Any incoming calls will be answered and charges will accrue against the Vapi account balance.
  • The Twilio phone number continues to incur its monthly fee ($1.50/mo) whether or not it sends SMS messages.
  • Your ngrok tunnel is session-limited. If you restart your machine, you will need to start ngrok again before the agent can reach your server.
  • For real client deployment, replace ngrok with a hosted server (Railway, Render, or similar) and swap Airtable for the client's actual field-service management tool (ServiceTitan, Jobber, Housecall Pro).

Pause - I'll come back to this later

Shut down running processes to stop charges from accruing while keeping all configuration intact.

  • In the Vapi dashboard, click Phone Numbers in the left sidebar. Select your phone number and disable it. The agent configuration persists but no calls are answered and no charges accrue.
  • Stop your Express server (Ctrl+C in the terminal running node server.js).
  • Stop your ngrok tunnel (Ctrl+C in the ngrok terminal).
  • Your Airtable base, GitHub repo, Linear project, and local files remain untouched and ready when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Total charges during the project should be under $10 if you stayed within Vapi's free credits.

  • Delete the Vapi assistant: In the Vapi dashboard, click Assistants in the left sidebar. Select your assistant and delete it.
  • Delete the Vapi phone number: Click Phone Numbers in the left sidebar. Select your number and delete it.
  • Release your Twilio phone number: In the Twilio Console, navigate to Phone Numbers then Active Phone Numbers. Click your number, open the Properties tab, and release the number. There is no charge to release.
  • Delete the Airtable base: Open your Airtable workspace, right-click (or use the dropdown on) the base tile, and select Delete base. This removes both the Jobs and Ledger tables.
  • Revoke your Anthropic API key: Go to console.anthropic.com, navigate to Settings then API Keys, and delete the key you created for this project.
  • Delete the GitHub repository: Open the repo on GitHub. Go to Settings, scroll to the bottom (Danger Zone), and click Delete this repository.
  • Delete the Linear project: Open the project in Linear, go to project Settings, and delete the project.
  • Delete your local project folder by running this command:

macOS

rm -rf ~/Desktop/after-hours-voice-agent

Windows

rmdir /s /q %USERPROFILE%\Desktop\after-hours-voice-agent

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-ready after-hours voice agent for a real trades business, proved its value from the client's own numbers, and shipped the whole engagement through a proper engineering process.

You've learned how to:

  • Audit a client's call log and produce a revenue-leak model that proves what missed calls cost them in their own numbers, then build the decision document (platform choice, triage-before-booking, AI disclosure, no-quoting, attribution rule) that governs every agent behaviour before a line of code is written.
  • Build and deploy a Vapi voice agent with a triage gate that escalates emergencies with safety instructions and on-call paging via Twilio, books routine jobs into Airtable with idempotent writes, and records every call disposition to a value ledger so attribution is auditable.
  • Ship through a real engineering process: GitHub repo with branch-per-ticket PRs, Linear tickets tracking every epic, CI validation of the triage taxonomy, and a tagged v1.0.0 release.
  • Secret Mission: ran a 45-call seeded set scored field-by-field against acceptance criteria, produced the value proof from the client's own numbers, priced the engagement (fixed or value-based with the ledger as the contract), wrote the runbook, and set a 30-day review against the ledger.

Ready to quiz yourself?