Vertex AI Multi-Agent Handoff for ServiceTitan Dispatch Automation
An AI dispatch mesh that triages field service requests, schedules appointments, and assigns technicians in ServiceTitan via REST, reducing manual coordination.
Home service companies on ServiceTitan rely on human dispatchers to juggle calls, schedule jobs, and update statuses; missed tasks and double bookings hurt customer satisfaction and 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.
This tutorial walks you through building a multi-agent dispatch mesh that classifies incoming field service requests, routes them through a confidence gate, and dispatches technicians via the ServiceTitan REST API. You’ll wire up Vertex AI (Gemini) for classification and content generation, use @reaatech/agent-mesh-classifier for intent detection, @reaatech/agent-mesh-confidence for gating, and @reaatech/agent-handoff-routing for agent selection. By the end you’ll have a working Next.js App Router project with two API endpoints and a full test suite.
Prerequisites
Node.js >= 22 and pnpm 10.x installed
A Google Cloud project with the Vertex AI API enabled and a service account key
A ServiceTitan tenant with OAuth2 client credentials (client ID + secret)
A Langfuse account (free tier works) for observability tracing
Basic familiarity with TypeScript and Next.js App Router
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router, then install the required dependencies.
"test": "vitest run --coverage --reporter=json --outputFile=vitest-report.json"
Expected output: A Next.js project with all dependencies installed. You can verify with pnpm ls --depth=0.
Step 2: Configure environment variables
Create a .env.example file with placeholder entries for every runtime variable the project reads.
env
# Vertex AI configurationGOOGLE_CLOUD_PROJECT=<your-gcp-project-id>GOOGLE_CLOUD_LOCATION=us-central1GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json# ServiceTitan API (OAuth2 client_credentials)SERVICETITAN_CLIENT_ID=<your-servicetitan-client-id>SERVICETITAN_CLIENT_SECRET=<your-servicetitan-client-secret>SERVICETITAN_TENANT_ID=<your-servicetitan-tenant-id>SERVICETITAN_API_BASE_URL=https://api.servicetitan.io# Langfuse observabilityLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=https://cloud.langfuse.comNODE_ENV=development
Copy this to .env and fill in your real values before running the server.
Expected output: A .env file with all the environment variables the project needs, plus a .env.example you can commit safely.
Now create the environment validation module at src/lib/env.ts. You’ll use Zod to parse and validate every env var at startup so misconfigurations fail fast.
ts
import { env as meshEnv, type Env as MeshEnv } from "@reaatech/agent-mesh";import { z } from "zod";void meshEnv;const urlSchema = z.string().refine((v) => { try { new URL(v); return true; } catch { return false; }}, "Must be a valid URL");const envSchema = z.object({ GOOGLE_CLOUD_PROJECT: z.string().min(1, "GOOGLE_CLOUD_PROJECT is required"), GOOGLE_CLOUD_LOCATION: z.string().min(1, "GOOGLE_CLOUD_LOCATION is required"), SERVICETITAN_CLIENT_ID: z.string().min(1, "SERVICETITAN_CLIENT_ID is required"), SERVICETITAN_CLIENT_SECRET: z.string().min(1, "SERVICETITAN_CLIENT_SECRET is required"), SERVICETITAN_TENANT_ID: z.string().min(1, "SERVICETITAN_TENANT_ID is required"), SERVICETITAN_API_BASE_URL: urlSchema, LANGFUSE_PUBLIC_KEY: z.string().min(1, "LANGFUSE_PUBLIC_KEY is required"), LANGFUSE_SECRET_KEY: z.string().min(1, "LANGFUSE_SECRET_KEY is required"), LANGFUSE_BASE_URL: urlSchema, NODE_ENV: z.enum(["development", "production", "test"]).default("development"),});let _recipeEnv: z.infer<typeof envSchema> | null = null;export function loadEnv(): z.infer<typeof envSchema> { const parsed = envSchema.safeParse(process.env); if (!parsed.success) { throw new Error("Environment validation failed: " + parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")); } _recipeEnv = parsed.data; return _recipeEnv;}function getEnv(): z.infer<typeof envSchema> { if (!_recipeEnv) { return loadEnv(); } return _recipeEnv;}export function getRecipeEnv(): z.infer<typeof envSchema> { return getEnv();}export const recipeEnv = getEnv();export type RecipeEnv = z.infer<typeof envSchema> & Partial<MeshEnv>;
Expected output: A validated env module that throws on startup if any required variable is missing.
Step 3: Define shared types and schemas
Create src/types/index.ts to hold all the shared TypeScript interfaces and Zod schemas used across the mesh. You’ll re-export types from the REAA packages alongside your own application types.
ts
import { IncomingRequestSchema, type IncomingRequest, AgentResponseSchema, type AgentResponse, ClassifierOutputSchema, type ClassifierOutput, AgentConfigSchema, type AgentConfig, ConfidenceDecisionSchema, type ConfidenceDecision, ContextPacketSchema, type ContextPacket,} from "@reaatech/agent-mesh";import { createHandoffConfig, type HandoffPayload, type AgentCapabilities, type HandoffConfig, type HandoffResult, HandoffError, type RoutingDecision } from "@reaatech/agent-handoff";import { z } from "zod";// Re-export everythingexport { IncomingRequestSchema, type IncomingRequest, AgentResponseSchema, type AgentResponse, ClassifierOutputSchema, type ClassifierOutput, AgentConfigSchema, type AgentConfig, ConfidenceDecisionSchema, type ConfidenceDecision, ContextPacketSchema, type ContextPacket };export { createHandoffConfig, type HandoffPayload, type AgentCapabilities, type HandoffConfig, type HandoffResult, HandoffError, type RoutingDecision };// Local typesexport interface ServiceTitanConfig { tenantId: string; clientId: string; clientSecret: string; baseUrl: string;}export const ServiceRequestSchema = z.object({ id: z.string(), customerName: z.string(), issueDescription: z.string(), priority: z.enum(["low", "medium", "high", "urgent"]), source: z.enum(["web", "sms", "voice"]), timestamp: z.string(),});export type ServiceRequest = z.infer<typeof ServiceRequestSchema>;export interface Technician { id: string; name: string; skills: string[]; certifications: string[]; currentLoad: number; maxLoad: number; availability: "available" | "busy" | "offline";}export interface AppointmentSlot { slotId: string; technicianId: string; startTime: string; endTime: string; status: "open" | "booked";}export interface DispatchResult { status: "scheduled" | "pending" | "failed"; jobId?: string; technicianId?: string; scheduledTime?: string; reason?: string;}export interface DispatchAction { action: "schedule" | "reschedule" | "cancel" | "status"; serviceRequestId: string; technicianId?: string; scheduledTime?: string;}
Expected output: A types module that re-exports all REAA mesh types and defines Technician, AppointmentSlot, DispatchResult, DispatchAction, and ServiceRequest for your application logic.
Step 4: Create the Vertex AI LLM adapter
Create src/lib/llm.ts to wrap the Google Cloud Vertex AI SDK. This adapter provides three operations: generateContent (single-turn generation), generateContentStream (streaming), and countTokens (token counting). All three use p-retry with isRateLimitError detection so rate-limited calls retry automatically.
Expected output: An LLM module that lazily initializes a Vertex AI client, caches the Gemini model instance, and wraps all three operations with p-retry using isRateLimitError as the retry condition.
Step 5: Build the ServiceTitan REST client
Create src/integration/servicetitan.ts to encapsulate all ServiceTitan API interactions. The client handles OAuth2 token acquisition and caching, retries on transient failures, and exposes typed methods for each endpoint.
ts
import pRetry, { AbortError } from "p-retry";import { recipeEnv } from "../lib/env";import type { Technician, AppointmentSlot } from "../types";import { z } from "zod";const TokenResponseSchema = z.object({ access_token: z.string(), expires_in: z.number(),});interface TokenCache { accessToken: string; expiresAt: number;}let tokenCache: TokenCache | null = null
Expected output: A ServiceTitan client with OAuth2 token lifecycle (acquisition, caching with 5-minute buffer, automatic refresh on 401), pRetry for transient failures, AbortError for non-retryable status codes, and typed methods for technicians, appointment slots, and job CRUD.
Step 6: Create the intake agent
Create src/agents/intake.ts. This module builds the agent registry and handles incoming request classification with a fallback on rate-limit exhaustion.
ts
import { classifierService, detectLanguage, isRateLimitError } from "@reaatech/agent-mesh-classifier";import { AgentConfigSchema, type AgentConfig, type ClassifierOutput } from "@reaatech/agent-mesh";export function buildAgentRegistry(): AgentConfig[] { const intake: AgentConfig = AgentConfigSchema.parse({ agent_id: "intake", display_name: "Intake Agent", description: "Classifies incoming service requests and triages dispatch needs", endpoint: "/api/webhook", confidence_threshold: 0.6, examples: ["I need a plumber", "My AC is broken"], clarification_required: true, }); const dispatch: AgentConfig = AgentConfigSchema.parse({ agent_id: "dispatch", display_name: "Dispatch Agent", description: "Schedules appointments and assigns technicians in ServiceTitan", endpoint: "/api/dispatch", confidence_threshold: 0.7, examples: ["Schedule a technician for Monday", "When can you come?"], clarification_required: false, }); return [intake, dispatch];}export async function processIncomingRequest(rawInput: string): Promise<ClassifierOutput> { const language = detectLanguage(rawInput); const registry = buildAgentRegistry(); try { const result = await classifierService.classify(rawInput, registry, language); return result; } catch (error) { if (isRateLimitError(error)) { console.warn("Classifier rate-limited, retrying once"); try { const retryResult = await classifierService.classify(rawInput, registry, language); return retryResult; } catch (retryError) { if (isRateLimitError(retryError)) { console.warn("Classifier rate-limited on retry, returning fallback classification"); const fallback: ClassifierOutput = { agent_id: "intake", confidence: 0.3, ambiguous: true, detected_language: language, intent_summary: "Fallback due to rate-limit exhaustion", entities: {}, }; return fallback; } throw retryError; } } throw error; }}
Expected output: A registration of two agents (intake and dispatch) and a processIncomingRequest function that retries once on rate-limit errors and falls back to a low-confidence default classification if the retry also hits the rate limit.
Step 7: Create the dispatch agent
Create src/agents/dispatch.ts. This module handles technician discovery, slot selection with constraint validation, and job scheduling.
ts
import { ServiceTitanClient } from "../integration/servicetitan";import type { Technician, AppointmentSlot, DispatchAction, DispatchResult } from "../types";export async function findAvailableTechnicians(date: string, client: ServiceTitanClient): Promise<Technician[]> { const all = await client.getTechnicians(); return all.filter((t) => t.availability !== "offline" && t.currentLoad < t.maxLoad);}export async function findBestSlot(technicians: Technician[], client: ServiceTitanClient, date: string): Promise<AppointmentSlot | null> { const slots: AppointmentSlot[] = []; for (const tech of technicians) { const techSlots = await client.getAppointmentSlots(tech.id, date); slots.push(...techSlots); } slots.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); return slots.find((s) => s.status === "open") ?? null;}export async function validateSchedulingConstraints(technicianId: string, slotTime: string, client: ServiceTitanClient): Promise<boolean> { try { const existing = await client.getAppointmentSlots(technicianId, slotTime.split("T")[0]); const conflict = existing.some( (s) => s.status === "booked" && new Date(s.startTime).getTime() <= new Date(slotTime).getTime() && new Date(s.endTime).getTime() > new Date(slotTime).getTime() ); return !conflict; } catch { return false; }}export async function executeDispatchAction(action: DispatchAction, client: ServiceTitanClient): Promise<DispatchResult> { if (action.action === "status") { const job = await client.getJobStatus(action.serviceRequestId); return { status: "scheduled", jobId: job.jobId, technicianId: job.technicianId, scheduledTime: undefined }; } try { const available = await findAvailableTechnicians(action.scheduledTime ?? new Date().toISOString(), client); if (available.length === 0) return { status: "failed", reason: "No available technicians" }; const slot = await findBestSlot(available, client, action.scheduledTime ?? new Date().toISOString()); if (!slot) return { status: "failed", reason: "No open appointment slots" }; const valid = await validateSchedulingConstraints(slot.technicianId, slot.startTime, client); if (!valid) return { status: "failed", reason: "Scheduling constraint conflict" }; const result = await client.scheduleJob(action.serviceRequestId, slot.technicianId, slot.startTime); return { status: "scheduled", jobId: result.jobId, technicianId: slot.technicianId, scheduledTime: slot.startTime }; } catch (error) { return { status: "failed", reason: error instanceof Error ? error.message : "Unknown error" }; }}
Expected output: Four dispatch functions — findAvailableTechnicians filters out offline/full techs, findBestSlot finds the earliest open slot across all available techs, validateSchedulingConstraints checks for time overlaps, and executeDispatchAction ties it all together with error-safe failure modes.
Step 8: Add Langfuse observability
Create src/observability.ts to wrap Langfuse tracing. You’ll use this in the webhook route to trace the full classify-route-dispatch pipeline.
Expected output: An observability module with a singleton Langfuse client and a wrapWithTrace helper that wraps any async function in a Langfuse trace + span, recording both successful outputs and errors.
Step 9: Wire the mesh orchestrator
Create src/mesh.ts — the central orchestrator that ties the classifier, confidence gate, handoff routing, and dispatch execution into a single process() method.
ts
import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import { createHandoffConfig, TypedEventEmitter, withRetry } from "@reaatech/agent-handoff";import { evaluateConfidenceGate, generateClarificationQuestion } from "@reaatech/agent-mesh-confidence";import { classifierService, detectLanguage } from "@reaatech/agent-mesh-classifier";import type { ClassifierOutput, ConfidenceDecision } from "@reaatech/agent-mesh";import type { DispatchResult } from "./types";import { ServiceTitanClient } from "./integration/servicetitan";import { executeDispatchAction } from "./agents/dispatch";import { buildAgentRegistry } from "./agents/intake";export class MeshOrchestrator {
Expected output: A MeshOrchestrator class that:
Uses classifierService.classify with withRetry (exponential backoff)
Passes the result through evaluateConfidenceGate for routing decisions
On route to the dispatch agent: calls executeDispatchAction with the classified entities
On clarify: calls generateClarificationQuestion for the matched agent config
Emits typed events at each stage via TypedEventEmitter
Step 10: Create API route handlers and the home page
Create the webhook route at app/api/webhook/route.ts. This is the main entry point — it accepts a JSON body, validates it with IncomingRequestSchema, runs the orchestrator, and returns the decision.
ts
import { type NextRequest, NextResponse } from "next/server";import { IncomingRequestSchema } from "@reaatech/agent-mesh";import { MeshOrchestrator } from "../../../src/mesh";import { wrapWithTrace } from "../../../src/observability";const orchestrator = new MeshOrchestrator();export async function POST(req: NextRequest): Promise<NextResponse> { try { const body: unknown = await req.json(); const parsed = IncomingRequestSchema.parse(body); const input = "input" in parsed ? parsed.input : ""; const { decision, result } = await wrapWithTrace("webhook.process", () => orchestrator.process(input) ); return NextResponse.json( { content: decision.reason, workflow_complete: decision.action === "route", result }, { status: 200 } ); } catch (error: unknown) { if (error instanceof SyntaxError) { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } if (error instanceof Error && "issues" in error) { return NextResponse.json( { error: "Invalid request body", details: (error as { issues: unknown }).issues }, { status: 400 } ); } return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}
Create the dispatch status route at app/api/dispatch/route.ts. This lets you check a job’s status by its ID.
ts
import { type NextRequest, NextResponse } from "next/server";import { ServiceTitanClient } from "../../../src/integration/servicetitan";const client = new ServiceTitanClient();export async function GET(req: NextRequest): Promise<NextResponse> { const jobId = req.nextUrl.searchParams.get("jobId"); if (!jobId) { return NextResponse.json({ error: "Missing jobId" }, { status: 400 }); } try { const job = await client.getJobStatus(jobId); return NextResponse.json(job, { status: 200 }); } catch (error: unknown) { if (error instanceof Error && error.message.includes("404")) { return NextResponse.json({ error: "Job not found" }, { status: 404 }); } return NextResponse.json({ error: "ServiceTitan API error" }, { status: 500 }); }}
Replace the default home page at app/page.tsx with a simple form that lets you submit a service request.
tsx
export default function Home() { return ( <main> <h1>ServiceTitan Dispatch Mesh</h1> <p>Enter a service request to dispatch a technician.</p> <form action="/api/webhook" method="POST"> <label htmlFor="raw_input">Describe your issue:</label> <textarea id="raw_input" name="raw_input" rows={3} cols={50} required /> <button type="submit">Submit</button> </form> </main> );}
Expected output: Two Next.js API route handlers (POST /api/webhook and GET /api/dispatch) plus a minimal home page with a textarea form.
Step 11: Wire the module entry point
Update src/index.ts to re-export everything consumers of the package will need.
ts
export * from "./types";export { MeshOrchestrator } from "./mesh";export { ServiceTitanClient } from "./integration/servicetitan";export { generateContent, generateContentStream, countTokens, getGenerativeModel } from "./lib/llm";export { recipeEnv } from "./lib/env";export { getLangfuse, wrapWithTrace } from "./observability";
Expected output: A barrel file that exports the orchestrator, ServiceTitan client, LLM adapter functions, env config, and Langfuse helpers.
Create the test setup file at tests/setup.ts with MSW HTTP handlers for ServiceTitan and vi.mock stubs for every external package (Vertex AI, Langfuse, agent-mesh, agent-mesh-classifier, agent-mesh-confidence). The mock config on globalThis.__mockConfig lets tests control mock behaviour (confidence gate action, classification success/failure, Vertex AI response shape, and more) at runtime.
ts
import { vi, beforeAll, afterEach, afterAll } from "vitest";import { setupServer } from "msw/node";import { http, HttpResponse } from "msw";declare global { var __mockConfig: { confidenceGateAction: "route" | "clarify" | "fallback"; classifyReject: boolean; isRateLimit: boolean; routeAgentId?: string; classifierEntities?: Record<string, unknown>; classifyRejectPayload?: unknown; vertexEmptyResponse?: boolean
Now create the test files. Here’s the env validation test at tests/env.test.ts:
Coverage should meet the 90% threshold on all metrics (lines, branches, functions, statements).
Step 13: Run type check and lint
Verify the project compiles cleanly and passes lint rules.
terminal
pnpm typecheckpnpm lint
Expected output:tsc --noEmit exits 0 with no errors. eslint . exits 0 with no warnings.
Next steps
Add more agent types — extend the registry with a billing agent, a parts-ordering agent, or a customer-feedback agent, each with its own routing rules and confidence thresholds
Integrate a webhook dispatcher — instead of polling, subscribe to ServiceTitan webhooks for real-time job status updates and automatically re-route when a technician’s availability changes
Add a dashboard — build a Next.js page that renders live dispatch status using Server-Sent Events or WebSocket push from the MeshOrchestrator’s TypedEventEmitter