Field service companies lose after-hours calls from customers needing emergency dispatch; an AI agent that answers calls, captures job details, and creates work orders in ServiceTitan without human intervention would recover lost revenue.
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.
In this tutorial, you’ll build an AI-powered voice agent that answers phone calls for field service businesses, converts voice instructions into ServiceTitan work orders, and handles the full dispatch workflow automatically. The agent uses OpenAI for LLM orchestration, Deepgram for speech-to-text, Cartesia for text-to-speech, and Twilio for telephony. You’ll wire up a confidence router that classifies caller intent, an MCP client for tool execution, and a session store that keeps conversation context across turns.
Prerequisites
Node.js 22+ and pnpm 10+ installed
A Twilio account with a phone number that has voice capability and Media Streams enabled
A Deepgram account with an API key (for real-time STT)
A Cartesia account with an API key (for TTS)
An OpenAI API key
A ServiceTitan developer account with client credentials (client ID, client secret, tenant ID, app key)
Familiarity with TypeScript, Next.js App Router, and REST APIs
Step 1: Create the Next.js project and install dependencies
Start by creating a new Next.js project with the App Router and installing all dependencies.
Expected output: The file .env exists with all placeholders filled with your actual credentials. The .env.example file lists every variable so contributors know what’s needed.
Step 3: Create the ServiceTitan OAuth2 client
This typed client handles authentication and all CRUD operations against the ServiceTitan REST API. It manages OAuth2 token refresh automatically and retries on 401 responses.
Expected output:src/lib/servicetitan-client.ts exports ServiceTitanClient, ServiceTitanConfig, ServiceTitanWorkOrder, ServiceTitanCustomer, ServiceTitanToken, and ServiceTitanError.
Step 4: Create the session store with in-memory storage
The session store uses @reaatech/session-continuity’s SessionManager with an in-memory storage adapter and a simple token counter. It keeps conversation context across multiple turns during a call.
Create src/lib/session-store.ts:
ts
import { SessionManager, IStorageAdapter, TokenCounter, SessionNotFoundError, ConcurrencyError, type Session, type Message, type SessionId, type MessageId, type HealthStatus, type SessionFilters, type MessageQueryOptions, type UpdateSessionOptions } from "@reaatech/session-continuity";export class InMemoryStorageAdapter implements IStorageAdapter { private sessions: Map<SessionId, Session> = new Map(); private messages: Map<SessionId, Message[]> = new Map(); private nextSequence: Map<SessionId,
Expected output:src/lib/session-store.ts exports createSessionStore, InMemoryStorageAdapter, SimpleTokenCounter, and types.
Step 5: Create the confidence router for intent classification
The confidence router uses keyword classification to determine whether the caller wants to create a work order, check status, reschedule, or cancel. Ambiguous requests trigger a clarification prompt; unrecognizable input falls back to human escalation.
Create src/lib/confidence-router.ts:
ts
import { ConfidenceRouter, KeywordClassifier, type RoutingDecision } from "@reaatech/confidence-router";export function createDispatchRouter(): ConfidenceRouter { const router = new ConfidenceRouter({ routeThreshold: 0.8, fallbackThreshold: 0.3, clarificationEnabled: true, }); router.registerClassifier(new KeywordClassifier([ { label: "create_work_order", keywords: ["dispatch", "send", "emergency", "repair", "fix", "plumber", "electrician", "hvac", "broken", "leaking", "flooded", "install"], weight: 6 }, { label: "check_status", keywords: ["status", "check", "update", "when", "eta", "arriving", "progress"], weight: 6 }, { label: "reschedule", keywords: ["reschedule", "change", "different time", "move", "later", "tomorrow"], weight: 6 }, { label: "cancel", keywords: ["cancel", "not needed", "call off", "already fixed"], weight: 6 }, ])); return router;}export async function classifyDispatchIntent(router: ConfidenceRouter, utterance: string): Promise<RoutingDecision> { if (!utterance || utterance.trim().length === 0) { return { type: "FALLBACK", confidence: 0 }; } try { return await router.process(utterance); } catch { return { type: "CLARIFY", prompt: "Did you mean create_work_order, check_status, reschedule, or cancel?", options: ["create_work_order", "check_status", "reschedule", "cancel"] }; }}export function handleAmbiguousIntent(decision: RoutingDecision): string { if (decision.type === "CLARIFY") { return decision.prompt ?? "Could you please clarify your request?"; } return "";}export function handleFallbackIntent(): string { return "I'm sorry, I couldn't understand your request. Let me transfer you to our dispatch team.";}
Expected output:src/lib/confidence-router.ts exports createDispatchRouter, classifyDispatchIntent, handleAmbiguousIntent, and handleFallbackIntent.
Step 6: Create the ServiceTitan MCP tool definitions
These tool definitions bridge the ServiceTitan client into the voice agent’s MCP tool execution pipeline. Each tool is wrapped with error handling that catches ServiceTitanError instances and returns a clean error result instead of crashing the pipeline.
Expected output:src/lib/servicetitan-tools.ts exports createServiceTitanTools which returns four tool definitions: lookup_customer_by_phone, create_work_order, list_job_types, and check_customer_history.
Step 7: Create the dispatch agent orchestrator
The dispatch agent ties everything together. It initializes the OpenAI client, the ServiceTitan client, the session store, and the confidence router, then routes each utterance through the classification pipeline. For routed intents, it delegates to the MCP client; for clarifications, it returns a clarification prompt; for fallbacks, it returns the human-escalation message.
Create src/lib/dispatch-agent.ts:
ts
import OpenAI from "openai";import { MCPClient } from "@reaatech/voice-agent-mcp-client";import { ConfidenceRouter } from "@reaatech/confidence-router";import { ServiceTitanClient, type ServiceTitanConfig } from "./servicetitan-client.js";import type { ServiceTitanWorkOrder } from "./servicetitan-client.js";import { createSessionStore, type SessionManager } from "./session-store.js";import { createDispatchRouter, classifyDispatchIntent, handleAmbiguousIntent, handleFallbackIntent } from "./confidence-router.js";export interface DispatchAgentConfig { openAiApiKey: string; model?: string;
Expected output:src/lib/dispatch-agent.ts exports DispatchAgent and DispatchAgentConfig.
Step 8: Create the pipeline configuration
The pipeline config assembles the Deepgram STT provider, the Cartesia TTS provider, the latency budget enforcer, and the Twilio handler into a single configuration object that the voice agent pipeline consumes at runtime.
Create src/lib/pipeline-config.ts:
ts
import { createLatencyBudget, LatencyBudgetEnforcer } from "@reaatech/voice-agent-core";import { createTwilioHandler } from "@reaatech/voice-agent-telephony";import { createSimulator } from "@reaatech/voice-agent-simulator";import { DeepgramClient } from "@deepgram/sdk";import Cartesia from "@cartesia/cartesia-js";export interface PipelineConfig { sttProvider: DeepgramClient; ttsProvider: Cartesia; latencyEnforcer: LatencyBudgetEnforcer; twilioHandler: ReturnType<typeof createTwilioHandler>;}export function buildPipelineConfig(): PipelineConfig { const deepgramApiKey = process.env.DEEPGRAM_API_KEY; if (!deepgramApiKey) { throw new Error("DEEPGRAM_API_KEY environment variable is required"); } const cartesiaApiKey = process.env.CARTESIA_API_KEY; if (!cartesiaApiKey) { throw new Error("CARTESIA_API_KEY environment variable is required"); } const sttProvider = new DeepgramClient({ apiKey: deepgramApiKey }); const ttsProvider = new Cartesia({ apiKey: cartesiaApiKey }); const budget = createLatencyBudget({ target: 800, hardCap: 1200, stt: 200, mcp: 400, tts: 200, }); const latencyEnforcer = new LatencyBudgetEnforcer(budget); const twilioHandler = createTwilioHandler(); void createSimulator; return { sttProvider, ttsProvider, latencyEnforcer, twilioHandler };}
Expected output:src/lib/pipeline-config.ts exports buildPipelineConfig and PipelineConfig.
Step 9: Enable instrumentation with OpenTelemetry
Next.js’s register() function runs at server startup. This instrumentation hooks into OpenTelemetry for distributed tracing.
First, update next.config.ts to enable the instrumentation hook:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, },};export default nextConfig;
Expected output:next.config.ts has experimental.instrumentationHook: true. src/instrumentation.ts exports register() that calls initializeObservability only in the Node.js runtime.
Step 10: Create the Twilio voice webhook parser
When Twilio receives an incoming call, it sends a POST request to your webhook URL with form-encoded call metadata. This parser extracts CallSid, From, To, and CallStatus from the form data.
Create src/api/twilio/voice.ts:
ts
import { NextRequest } from "next/server";export interface TwilioVoiceRequest { callSid: string; from: string; to: string; callStatus: string;}export async function parseVoiceWebhook(request: NextRequest): Promise<TwilioVoiceRequest> { const formData = await request.formData(); const callSid = formData.get("CallSid") as string; const from = formData.get("From") as string; const to = formData.get("To") as string; const callStatus = formData.get("CallStatus") as string; return { callSid, from, to, callStatus };}
Expected output:src/api/twilio/voice.ts exports parseVoiceWebhook and TwilioVoiceRequest.
Step 11: Create the Twilio voice webhook route
This route handler responds to Twilio’s incoming call webhook with TwiML XML. The TwiML plays a welcome message and opens a bidirectional audio stream over WebSocket.
Create app/api/twilio/voice/route.ts:
ts
import { NextRequest } from "next/server";import { parseVoiceWebhook } from "../../../../src/api/twilio/voice.js";export async function POST(request: NextRequest) { const parsed = await parseVoiceWebhook(request); if (!parsed.callSid) { return Response.json({ error: "Missing CallSid" }, { status: 400 }); } const host = request.headers.get("host") ?? "localhost:3000"; const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response> <Say>Welcome to emergency dispatch. How can we help?</Say> <Connect> <Stream url="wss://${host}/api/twilio/stream"/> </Connect></Response>`; return new Response(twiml, { status: 200, headers: { "Content-Type": "text/xml" }, });}
Expected output: A POST to /api/twilio/voice returns status 200 with Content-Type: text/xml and TwiML containing <Connect><Stream>.
Step 12: Create the WebSocket stream route
This route handles the real-time audio stream from Twilio. It uses createTwilioHandler from @reaatech/voice-agent-telephony to manage the WebSocket connection, wiring up event handlers for call lifecycle, audio decoding, barge-in detection, DTMF input, and error handling.
Expected output: A GET to /api/twilio/stream returns a health-check JSON. The handleWebSocketConnection function wires up all event handlers and accepts the WebSocket connection.
Step 13: Create the ServiceTitan OAuth callback route
When ServiceTitan redirects back after authorization, this route receives the authorization code, exchanges it for an OAuth token using the ServiceTitan client, and returns a confirmation.
Expected output: A GET to /api/servicetitan/oauth?code=abc123 returns { ok: true } with status 200. Missing code returns 400. Token exchange failures return 502.
Step 14: Write the tests
Now you’ll write the test suite. Tests use MSW for HTTP mocking and vitest for the test runner.
Expected output: All test files under tests/ exist with happy-path, error-path, and boundary test cases.
Step 15: Run typecheck, lint, and tests
Now verify everything compiles and all tests pass with coverage.
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:
pnpm typecheck exits 0 with no TypeScript errors
pnpm lint exits 0 with no warnings or errors
pnpm test exits 0 with all tests passing and coverage thresholds at 90% or above
Next steps
Add a persistent database adapter for the session store — swap InMemoryStorageAdapter for PostgreSQL or Redis so sessions survive server restarts
Implement multi-language support in the confidence router by adding localized keyword classifiers for Spanish, French, or other languages common in field service
Integrate calendar availability — before creating a work order, check the technician schedule via the ServiceTitan API and suggest available appointment slots to the caller