Small field-service businesses miss after-hours calls and spend hours manually coordinating technician schedules, leading to lost jobs and double-bookings.
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 tutorial walks you through building a real-time voice agent that answers inbound phone calls from field-service customers, books appointments into Google Calendar, and intelligently routes callers to booking, rescheduling, or FAQ specialists — all while enforcing per-call budget limits and tracking observability. You’ll use Twilio for telephony, LiveKit’s Google plugin with Vertex AI (Gemini 2.5 Flash) for the voice brain, and a Next.js dashboard for monitoring.
Prerequisites
Node.js 22+ and pnpm 10+ installed
Twilio account with a phone number that supports voice (get your Account SID, Auth Token, and phone number)
Google Cloud Project with Vertex AI API and Calendar API enabled; a service account JSON key downloaded
LiveKit server (self-hosted or LiveKit Cloud) — note your server URL, API key, and API secret
Langfuse account (optional — tracing degrades gracefully to a no-op if keys are missing)
A terminal ready to paste commands
Step 1: Scaffold the project and install dependencies
Start in the recipe’s root directory. The scaffolding provides a Next.js 16 App Router shell with tsconfig.json, eslint.config.mjs, vitest.config.ts, and next.config.ts already configured. Install the project dependencies:
terminal
pnpm install
Open .env.example and fill in your real credentials, then copy it:
terminal
cp .env.example .env.local
Your .env.example should list every variable the system reads:
// src/types/index.tsexport type { CallStatus, CallSession, CallSessionCreate } from "./call.js";export type { AppointmentRequest, Appointment, TimeSlot } from "./appointment.js";export { SpecialistType } from "./agent.js";export type { CallBudgetConfig } from "./budget.js";export type { CallLogEntry, CostMetricEntry } from "./dashboard.js";
Expected output:pnpm typecheck passes with no errors.
Step 3: Build the in-memory store
The store holds call logs and cost metrics in memory — no database needed for development.
ts
// src/lib/store.tsimport type { CallLogEntry, CostMetricEntry } from "../types/index.js";const callLogs = new Map<string, CallLogEntry>();const costMetrics = new Map<string, CostMetricEntry[]>();export function addCallLog(entry: CallLogEntry): void { callLogs.set(entry.callSid, entry);}export function getCallLogs(filters?: { from?: string; fromDate?: string; toDate?: string;}): CallLogEntry[] { let entries = Array.from(callLogs.values()); if (filters?.from) { const fromVal = filters.from; entries = entries.filter((e) => e.callSid.includes(fromVal)); } if (filters?.fromDate) { const from = new Date(filters.fromDate).getTime(); entries = entries.filter((e) => new Date(e.timestamp).getTime() >= from); } if (filters?.toDate) { const to = new Date(filters.toDate).getTime(); entries = entries.filter((e) => new Date(e.timestamp).getTime() <= to); } return entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());}export function getCallLogById(id: string): CallLogEntry | undefined { return callLogs.get(id);}export function addCostMetric(metric: CostMetricEntry): void { const existing = costMetrics.get(metric.scopeKey) ?? []; existing.push(metric); costMetrics.set(metric.scopeKey, existing);}export function getAggregatedCosts(): { totalCost: number; perModel: Record<string, number>; totalCalls: number;} { let totalCost = 0; const perModel: Record<string, number> = {}; const seenScopeKeys = new Set<string>(); for (const [scopeKey, metrics] of costMetrics) { seenScopeKeys.add(scopeKey); for (const m of metrics) { totalCost += m.cost; perModel[m.modelId] = (perModel[m.modelId] ?? 0) + m.cost; } } return { totalCost, perModel, totalCalls: seenScopeKeys.size };}
Expected output:getAggregatedCosts() on an empty store returns { totalCost: 0, perModel: {}, totalCalls: 0 }.
Step 4: Add structured-output repair
LLM output can be messy. This repair function attempts to parse or coerce raw data before passing it to a Zod schema, with up to two repair attempts.
ts
// src/lib/structured-output-repair.tsimport { type ZodError, type ZodType } from "zod";export interface RepairResult<T> { success: true; data: T;}export interface RepairFailure { success: false; error: string;}export type RepairOutcome<T> = RepairResult<T> | RepairFailure;function tryParseJson(raw: unknown): unknown { if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return raw; } } return raw;}export function repairAndValidate<T>( raw: unknown, schema: ZodType<T>, maxRepairAttempts = 2,): RepairOutcome<T> { let current = raw; for (let attempt = 0; attempt < maxRepairAttempts; attempt++) { const parsed = tryParseJson(current); const result = schema.safeParse(parsed); if (result.success) { return { success: true, data: result.data }; } if (attempt === 0 && typeof parsed === "object" && parsed !== null) { const stringified = JSON.stringify(parsed); if (stringified !== "{}") { current = stringified; } } } const result = schema.safeParse(tryParseJson(current)); if (result.success) { return { success: true, data: result.data }; } const zodErr = result.error as ZodError; return { success: false, error: zodErr.message };}
Expected output: Passing a valid JSON object matching AppointmentRequestSchema returns { success: true, data }. Passing garbage like "not json" returns { success: false, error }.
Step 5: Wire the agent coordinator with handoffs
The @reaatech/agent-handoff package provides createHandoffConfig, TypedEventEmitter, and withRetry. The AgentCoordinator class uses these to route calls among booking, rescheduling, and FAQ specialists.
ts
// src/lib/agent-coordinator.tsimport { createHandoffConfig, TypedEventEmitter, withRetry,} from "@reaatech/agent-handoff";import type { AgentCapabilities, RoutingDecision, HandoffConfig, PrimaryRoute, HandoffPayload,} from "@reaatech/agent-handoff";import { SpecialistType } from "../types/index.js";interface AgentCoordinatorEvents { handoff_requested: { from: SpecialistType; to: SpecialistType; reason: string }; handoff_completed: { to: SpecialistType }; handoff_failed
Expected output: Creating an AgentCoordinator instance with default config works. Calling classifyAndRoute(SpecialistType.booking) returns a RoutingDecision with type: "primary".
Step 6: Add budget guard with cost control
The @reaatech/agent-budget-engine package provides BudgetController. The BudgetGuard wraps it with a cooldown mechanism and per-model pricing.
ts
// src/lib/budget-guard.tsimport { BudgetController } from "@reaatech/agent-budget-engine";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetScope,} from "@reaatech/agent-budget-types";import type { ThresholdBreachEvent, HardStopEvent,} from "@reaatech/agent-budget-engine";import type { SpendEntry } from "@reaatech/agent-budget-types";const MODEL_RATES: Record<string, { inputPer1K: number; outputPer1K: number }> = { "gemini-2.5-flash": { inputPer1K: 0.0000375, outputPer1K:
Expected output: Creating a BudgetGuard, defining a $0.50 call budget, and checking a call with estimated tokens returns { allowed: true, remaining: ... }.
Step 7: Integrate Google Calendar with idempotency
The @reaatech/idempotency-middleware package prevents duplicate calendar writes. The CalendarService wraps the Google Calendar API with repair and retry logic, supporting both creation and rescheduling.
ts
// src/lib/calendar.tsimport { google } from "googleapis";import { MemoryAdapter, IdempotencyMiddleware,} from "@reaatech/idempotency-middleware";import { repairAndValidate } from "./structured-output-repair.js";import { AppointmentRequestSchema } from "../types/schemas.js";import type { AppointmentRequest, Appointment, TimeSlot } from "../types/index.js";let appointmentCounter = 0;export class CalendarService { private calendar; private idempotency: IdempotencyMiddleware; private calendarId: string
Expected output:CalendarService initializes without throwing. Calling createAppointment with a valid request and an idempotency key returns an Appointment object with an eventId.
Step 8: Wire the Twilio voice webhook and Express server
The TwilioVoiceWebhook handles inbound calls from Twilio, validates the signature, creates a LiveKit room, and returns TwiML that connects the call.
Expected output: Starting the server with pnpm dev:server prints “Express webhook server listening on port 3001”. A POST to /twilio/voice with a valid Twilio payload returns TwiML.
Step 9: Build the LiveKit voice agent and specialists
Create the Vertex AI LLM client and realtime model:
ts
// src/voice/agent.tsimport { LLM, type LLMOptions } from "@livekit/agents-plugin-google";export function createVertexLLM(): LLM { const opts: LLMOptions = { model: "gemini-2.5-flash", vertexai: true, }; if (process.env.GOOGLE_CLOUD_PROJECT) { opts.project = process.env.GOOGLE_CLOUD_PROJECT; } if (process.env.GOOGLE_CLOUD_LOCATION) { opts.location = process.env.GOOGLE_CLOUD_LOCATION; } return new LLM(opts);}
ts
// src/voice/realtime.tsimport * as google from "@livekit/agents-plugin-google";export function createRealtimeModel(): google.beta.realtime.RealtimeModel { return new google.beta.realtime.RealtimeModel({ model: "gemini-2.5-flash-native-audio-preview-12-2025", });}
The intent classifier routes transcripts to the correct specialist:
// app/api/health/route.tsimport { NextResponse } from "next/server";export function GET(): NextResponse { return NextResponse.json({ status: "ok", uptime: process.uptime() });}
Expected output:POST /api/calls/incoming with a valid CallSession body returns 201. GET /api/health returns { status: "ok" }. GET /api/calls/logs returns { calls: [...] }.
Step 11: Add Langfuse observability and Next.js instrumentation
The observability module wraps Langfuse and falls back to a no-op tracer when keys are missing.
Replace the in-memory store with a persistent database (PostgreSQL or SQLite) so call logs and metrics survive restarts
Deploy the Express webhook behind a public URL (ngrok or a cloud provider) and point your Twilio phone number’s voice webhook to https://your-domain.com/twilio/voice
Add a real LiveKit server and replace the placeholder createLiveKitRoom with the LiveKit Server SDK to generate real participant tokens
Extend the dashboard with real-time WebSocket updates for live call status as calls progress through the pipeline
:
{ error
:
string
};
}
function specialistToAgent(specialist: SpecialistType): AgentCapabilities {