Home service businesses miss after-hours calls and struggle to convert leads efficiently, as manual booking from voicemails and web forms causes double-booking and lost opportunities.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This recipe builds an AI-powered 24/7 phone lead intake system for home service businesses using Jobber. When a customer calls after hours, Vertex AI (Gemini 2.5 Flash) classifies the lead intent, extracts caller details, and automatically creates a client record and booking request in Jobber — all without a human dispatcher. Low-confidence calls get handed off to a live agent. You’ll wire together Twilio telephony, Vertex AI classification, Jobber’s REST API, and six REAA packages for session management, confidence routing, agent handoff, and cost telemetry.
The result is a Next.js app that receives Twilio webhooks, processes call transcripts through an LLM-powered pipeline, and books jobs — even while you’re asleep.
Prerequisites
Node.js 22+ and pnpm 10 installed
A Google Cloud project with Vertex AI and billing enabled
A Twilio account with a purchased phone number and Voice API access
A Jobber account with OAuth app credentials (client ID and secret)
A LiveKit server or cloud account for real-time audio streaming
Basic familiarity with TypeScript, Next.js App Router, and environment configuration
Step 1: Scaffold the project and install dependencies
Create a new Next.js project and install all the dependencies you’ll need. The project uses the App Router, Zod for runtime config validation, and the @google/genai package for Vertex AI.
Expected output: pnpm resolves all dependencies and creates a pnpm-lock.yaml file. The six @reaatech/* packages provide the building blocks for voice pipelines, session continuity, confidence routing, agent handoff, cost telemetry, and evaluation gates.
Step 2: Configure environment variables
Create .env.example with placeholder values for every integration you’ll wire up:
env
# Env vars used by vertex-ai-lead-intake-for-jobber-field-service-booking.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentTWILIO_ACCOUNT_SID=<your-twilio-account-sid>TWILIO_AUTH_TOKEN=<your-twilio-auth-token>TWILIO_PHONE_NUMBER=<your-twilio-phone-number>GOOGLE_CLOUD_PROJECT=<your-gcp-project-id>GOOGLE_CLOUD_LOCATION=us-central1GOOGLE_GENAI_USE_VERTEXAI=trueGOOGLE_APPLICATION_CREDENTIALS=<path-to-service-account-key.json>JOBBER_API_URL=<https://api.getjobber.com/api/>JOBBER_CLIENT_ID=<your-jobber-client-id>JOBBER_CLIENT_SECRET=<your-jobber-client-secret>LANGKFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGKFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGKFUSE_HOST=<https://cloud.langfuse.com>LIVEKIT_API_KEY=<your-livekit-api-key>LIVEKIT_API_SECRET=<your-livekit-api-secret>LIVEKIT_URL=<wss://your-livekit-server.com>OTEL_SERVICE_NAME=vertex-ai-lead-intake
Copy it to .env and fill in your actual values:
terminal
cp .env.example .env
Expected output: The .env file contains real credentials. The GOOGLE_GENAI_USE_VERTEXAI=true flag tells the @google/genai SDK to use Vertex AI endpoints instead of the default Google AI endpoints.
Step 3: Parse configuration with Zod
Runtime config validation catches missing environment variables before your app makes expensive API calls. Create src/lib/config.ts with a Zod schema that validates every required variable and applies sensible defaults:
Expected output:getConfig() returns a validated EnvConfig object. If TWILIO_ACCOUNT_SID is missing, it throws a ZodError immediately — catching misconfiguration at startup instead of mid-call.
Step 4: Define shared TypeScript types
Create src/lib/types.ts with the interfaces that flow through your entire intake pipeline:
Expected output: The IntakeResult discriminated union captures three possible outcomes — booked (successfully created in Jobber), handed_off (escalated to a human), or clarify_needed (the lead was too ambiguous).
Step 5: Classify leads with Vertex AI Gemini
This is the brain of the system. Create src/lib/vertex-client.ts using @google/genai to connect to Gemini 2.5 Flash on Vertex AI:
ts
import { GoogleGenAI } from "@google/genai";import type { ClassificationResult } from "./types.js";import { getConfig } from "./config.js";function parseClassification(text: string): ClassificationResult { try { const raw = JSON.parse(text) as Record<string, unknown>; const label = typeof raw.label === "string" ? raw.label : "unknown"; const confidence = typeof raw.confidence === "number" ? raw.confidence : 0; const extractedFields = raw.extractedFields as Record<string, string> | undefined; return { predictions: [{ label, confidence }], extractedFields, }; } catch { return { predictions: [{ label: "unknown", confidence: 0 }], extractedFields: {}, }; }}export class VertexAIClient { private ai: GoogleGenAI; constructor() { const config = getConfig(); this.ai = new GoogleGenAI({ vertexai: true, project: config.GOOGLE_CLOUD_PROJECT, location: config.GOOGLE_CLOUD_LOCATION, }); } async generateResponse(systemPrompt: string, userMessage: string): Promise<string> { const response = await this.ai.models.generateContent({ model: "gemini-2.5-flash", contents: userMessage, config: { systemInstruction: systemPrompt, }, }); return response.text ?? ""; } async classifyLead(transcript: string): Promise<ClassificationResult> { const prompt = `Extract lead information from this call transcript.Return a JSON object with:- label: the lead type (new_installation, repair, estimate, or follow_up)- confidence: a number between 0 and 1- extractedFields: an object with name, phone, address, description if foundTranscript: ${transcript}`; const response = await this.ai.models.generateContent({ model: "gemini-2.5-flash", contents: prompt, config: { temperature: 0.1, }, }); const text = response.text ?? ""; return parseClassification(text); } async summarizeConversation(messages: Array<{ role: string; content: string }>): Promise<string> { const transcript = messages.map((m) => `${m.role}: ${m.content}`).join("\n"); const response = await this.ai.models.generateContent({ model: "gemini-2.5-flash", contents: `Summarize this conversation in 2-3 sentences:\n\n${transcript}`, config: { temperature: 0.3, }, }); return response.text ?? ""; }}
Expected output:classifyLead("I need a new furnace installed") returns a ClassificationResult with predictions[{ label: "new_installation", confidence: 0.92 }] and any extracted name, phone, or address fields. The parseClassification helper handles malformed JSON gracefully, defaulting to "unknown" with zero confidence.
Step 6: Handle Twilio voice calls
Create src/integrations/twilio-client.ts to generate TwiML that connects inbound calls to your LiveKit audio stream:
Expected output:generateVoiceTwiML("wss://livekit.example.com/ws") returns XML with a <Connect><Stream> element. When Twilio receives this TwiML, it opens a bidirectional audio stream to your LiveKit endpoint for real-time speech processing.
Step 7: Book jobs in Jobber
Create src/integrations/jobber-client.ts to authenticate with OAuth and create clients and job requests via the Jobber REST API:
ts
import type { JobberCreateClientPayload, JobberCreateJobPayload } from "../lib/types.js";import { getConfig } from "../lib/config.js";export class JobberAuthError extends Error { constructor(message: string) { super(message); this.name = "JobberAuthError"; }}export class JobberApiError extends Error { status: number; constructor(message: string, status: number) { super(message); this.name = "JobberApiError"; this.status =
Expected output: Calling createClient({ name: "Jane Doe", phone: "+155****4567" }) authenticates via OAuth client credentials, creates a client in Jobber, and returns { id: "client-123" }. The client handles 401 token expiry, 429 rate limits with retry, and 400 validation errors with typed exceptions.
Step 8: Manage conversation sessions
Create src/services/session-manager.ts using @reaatech/session-continuity to track call state across turns:
Expected output: Each inbound call creates a session keyed by its Twilio CallSid. Utterances are added as messages in the conversation. When the token budget is exceeded (past 4,096 tokens), the sliding_window compression strategy trims older messages — keeping context relevant without hitting Vertex AI’s context window.
Step 9: Route leads by confidence
Create src/services/router.ts using @reaatech/confidence-router to classify lead intent with keyword patterns:
Expected output: Given predictions from Vertex AI, decide() returns a RoutingDecision — either ROUTE (high confidence → auto-book), FALLBACK (low confidence → hand off to human), or CLARIFY (ambiguous → ask more questions). The thresholds are configurable: 0.8 for auto-routing, 0.3 for fallback. Use getConfig() and updateConfig() to inspect or adjust thresholds at runtime.
Step 10: Monitor LLM costs
Create src/services/cost-telemetry.ts using @reaatech/llm-cost-telemetry to track per-session and daily Vertex AI spend:
Expected output: Each Vertex AI call records a CostSpan. getSessionCost("CA123") sums all spans for that call. getTotalCostToday() returns the running daily total — useful for enforcing budget caps.
Step 11: Orchestrate the full intake pipeline
Create src/services/lead-intake.ts — the central orchestrator that wires everything together:
ts
import { createPipeline, createLatencyBudget, initializeSessionManager, LatencyBudgetEnforcer, MockSTTProvider, MockTTSProvider, MockMCPClient,} from "@reaatech/voice-agent-core";import type { VoiceAgentKitConfig } from "@reaatech/voice-agent-core";import { IntakeSessionManager } from "./session-manager.js";import { LeadRouterService } from "./router.js";import { HandoffService } from "./handoff.js";import { CostTelemetryService } from "./cost-telemetry.js";import { VertexAIClient } from "../lib/vertex-client.js";import { JobberClient } from "../integrations/jobber-client.js";
Expected output: Calling processCall("I need a new water heater installed", "CA123") starts a pipeline session, classifies the transcript via Vertex AI, routes the high-confidence lead to Jobber client/job creation, and returns { disposition: "booked", jobberClientId: "client-123", jobberJobId: "job-456" }. The pipeline:turn:end listener automatically records cost telemetry on every turn. The seven getter methods expose each sub-service for testing and inspection.
Step 12: Expose Twilio webhook endpoints
Create the inbound call handler at src/api/calls/inbound.ts and the status callback at src/api/calls/status.ts, then expose them as Next.js App Router routes.
Inbound call handler (src/api/calls/inbound.ts):
ts
import type { TwilioClient } from "../../integrations/twilio-client.js";import type { EnvConfig } from "../../lib/config.js";export function handleInboundCall( config: EnvConfig, twilioClient: TwilioClient, livekitUrl: string): string { return twilioClient.generateVoiceTwiML(livekitUrl);}
Status callback handler (src/api/calls/status.ts):
ts
import { LeadIntakeService } from "../../services/lead-intake.js";import type { IntakeResult } from "../../lib/types.js";export interface StatusCallbackParams { CallSid: string; CallStatus: string; RecordingUrl?: string; Duration?: string; From: string;}const nonTerminalStatuses = new Set(["ringing", "in-progress", "queued"]);export async function handleStatusCallback( params: StatusCallbackParams, leadIntake: LeadIntakeService): Promise<IntakeResult | null> { if (nonTerminalStatuses.has(params.CallStatus)) { return null; } const transcript = `Call from ${params.From} regarding service request. Duration: ${params.Duration ?? "unknown"}s.`; const result = await leadIntake.processCall(transcript, params.CallSid); return result;}
App Router route for inbound calls (app/api/calls/inbound/route.ts):
ts
import { type NextRequest, NextResponse } from "next/server";import { getConfig } from "../../../../src/lib/config.js";import { TwilioClient } from "../../../../src/integrations/twilio-client.js";import { handleInboundCall } from "../../../../src/api/calls/inbound.js";export async function POST(req: NextRequest): Promise<NextResponse> { try { const formData = await req.formData(); const callSid = formData.get("CallSid") as string | null; if (!callSid) { return new NextResponse("Missing CallSid", { status: 400 }); } const config = getConfig(); const twilioClient = new TwilioClient(); const livekitUrl = config.LIVEKIT_URL ?? "wss://default-livekit.example.com"; const twiml = handleInboundCall(config, twilioClient, livekitUrl); return new NextResponse(twiml, { status: 200, headers: { "Content-Type": "text/xml" }, }); } catch { return new NextResponse( `<?xml version="1.0" encoding="UTF-8"?><Response><Say>An error occurred.</Say></Response>`, { status: 200, headers: { "Content-Type": "text/xml" }, } ); }}
App Router route for status callbacks (app/api/calls/status/route.ts):
Expected output: Configure Twilio’s Voice webhook to POST to /api/calls/inbound for incoming calls and /api/calls/status for status callbacks. The inbound route returns TwiML that connects the call to LiveKit; the status route fires after the call ends, triggering the full lead intake pipeline.
Step 13: Add OpenTelemetry instrumentation
Create src/instrumentation.ts to initialize observability so every pipeline span is traceable:
ts
import { initializeObservability } from "@reaatech/voice-agent-core";export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME !== "nodejs") return; const serviceName = process.env.OTEL_SERVICE_NAME ?? "vertex-ai-lead-intake"; await initializeObservability({ serviceName, serviceVersion: "0.1.0", enabled: true, });}
Expected output: On server startup, register() runs in the Node.js runtime and initializes OpenTelemetry tracing with the service name "vertex-ai-lead-intake". Every pipeline turn emits spans that you can view in your observability backend.
Step 14: Set up nightly evaluation
Create src/eval/intake-eval.ts using @reaatech/agent-eval-harness-gate to regression-test the intake flow:
Expected output: Running runNightlyEval() evaluates five scenarios — two high-confidence leads (new installation, repair), one ambiguous case, one after-hours call, and one non-service junk call. It returns a GateEvaluationSummary with pass/fail per scenario and overall pass rate. Add this to your CI pipeline to catch regressions.
Step 15: Run the tests
Run the full test suite with coverage:
terminal
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
All 78 tests should pass with coverage above 90% across all metrics. Type-checking and linting should also pass cleanly:
terminal
pnpm typecheckpnpm lint
Expected output:pnpm typecheck exits 0 with no errors, pnpm lint exits 0 with no warnings.
Next steps
Deploy to production — Set up a LiveKit server, generate a service account key for Vertex AI, and configure Twilio’s Voice webhook URL to point at your deployed Next.js app.
Add real STT/TTS — Replace the MockSTTProvider and MockTTSProvider with Deepgram (STT) and ElevenLabs (TTS) for production-quality voice interaction.
Integrate with a CRM — After booking in Jobber, sync the lead to HubSpot or Salesforce via their REST APIs for a unified sales pipeline.
status;
}
}
export class JobberRateLimitError extends Error {
retryAfter: number;
constructor(retryAfter: number) {
super(`Rate limited, retry after ${String(retryAfter)}s`);