Mistral AI Lead Intake for Housecall Pro Field Service Bookings
Convert inbound phone calls into structured Housecall Pro job bookings with an AI voice agent, automatically capturing customer details and scheduling.
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 a voice-powered lead intake system that answers inbound phone calls via Twilio, transcribes speech with Deepgram, extracts structured booking details using Mistral AI, and creates Housecall Pro jobs automatically. By the end you’ll have a working Next.js application with an AI voice agent that captures customer info, schedules service appointments, and tracks per-call AI spend.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
A Twilio account with a phone number that has Voice webhooks enabled
Deepgram API key (speech-to-text)
ElevenLabs API key (text-to-speech)
Mistral AI API key (LLM-based extraction)
Housecall Pro OAuth2 client ID and secret
Basic familiarity with TypeScript and Next.js App Router
Step 1: Scaffold the Next.js project
Create the project and install all dependencies. The scaffold includes Next.js 16 with the App Router, TypeScript, vitest with coverage, and all the REAA voice agent packages.
Expected output: Fill in real API keys in .env for Twilio, Deepgram, ElevenLabs, Mistral AI, and Housecall Pro. The defaults for token URL, API base URL, session TTL, and budget limit work as-is.
Step 3: Define the Housecall Pro job types and Zod schemas
Create the types that model a Housecall Pro job. The Zod schemas double as validation schemas for the @reaatech/structured-repair-core package that repairs messy LLM output.
Then create src/types/index.ts to barrel-export everything:
ts
export type { HousecallProContact, HousecallProJobPayload, HousecallProJobResponse,} from "./housecall-pro.js";export { ZodHousecallProContactSchema, ZodHousecallProJobSchema,} from "./housecall-pro.js";export type { CallSessionData, ExtractedBooking, CallOutcome } from "./call.js";
Expected output:pnpm typecheck passes. The types are available from @/src/types/index.js.
Step 5: Build the Twilio webhook validator and parser
Twilio signs every webhook request with HMAC-SHA1. You need to validate the signature before processing the call.
Expected output: Valid signatures pass, tampered signatures fail, and parseIncomingCall extracts the three Twilio fields from the form-encoded webhook body.
Step 6: Build the Housecall Pro OAuth2 client
The Housecall Pro API uses OAuth2 client credentials. This client fetches tokens, caches them until expiry, and retries once on 401.
Create src/lib/housecallpro-client.ts:
ts
export class HousecallProClientError extends Error { constructor( message: string, public readonly statusCode?: number, ) { super(message); this.name = "HousecallProClientError"; }}export class HousecallProServerError extends Error { constructor( message: string, public readonly statusCode?: number, ) { super(message); this.name = "HousecallProServerError"; }}
Expected output: The client fetches an access token and calls the Jobs API. On a 401 response it refreshes the token and retries the request once.
Step 7: Build the Mistral AI booking extractor with structured repair
This class sends the caller’s transcript to Mistral AI with a system prompt instructing JSON output, then passes the raw response through @reaatech/structured-repair-core to produce a clean, validated booking object.
Create src/lib/mistral-extractor.ts:
ts
import { Mistral } from "@mistralai/mistralai";import { repair, UnrepairableError } from "@reaatech/structured-repair-core";import { ZodHousecallProJobSchema } from "../types/housecall-pro.js";import type { z } from "zod";export class MistralExtractionError extends Error { constructor( message: string, public readonly cause?: unknown, ) { super(message); this.name = "MistralExtractionError"; }}interface MistralBookingExtractorOptions { client: Mistral; model?: string;}export class MistralBookingExtractor { private readonly client: Mistral; private readonly model: string; constructor(options: MistralBookingExtractorOptions) { this.client = options.client; this.model = options.model ?? "mistral-large-latest"; } async extractBooking( transcript: string, history?: Array<{ role: "user" | "assistant"; content: string }>, ): Promise<string> { const systemPrompt = "You are a booking extraction assistant. Extract Housecall Pro job fields from the caller transcript. Return ONLY a JSON object with fields: firstName, lastName, phone, address, serviceDescription, preferredDate, priority. Do not include markdown fences or any other text."; const result = await this.client.chat.complete({ model: this.model, responseFormat: { type: "text" }, messages: [ { role: "system" as const, content: systemPrompt }, ...(history ?? []), { role: "user" as const, content: transcript }, ], }); const message = result.choices[0]?.message; const content = message?.content; if (content == null) { throw new MistralExtractionError("Empty response from Mistral API"); } if (typeof content === "string") { return content; } // ContentChunk[] - join text parts const text = content .map((chunk) => { if ("text" in chunk && typeof chunk.text === "string") { return chunk.text; } return ""; }) .join(""); return text; } async extractBookingWithRepair( transcript: string, history?: Array<{ role: "user" | "assistant"; content: string }>, ): Promise<z.infer<typeof ZodHousecallProJobSchema>> { const raw = await this.extractBooking(transcript, history); try { const parsed = await repair(ZodHousecallProJobSchema, raw); return parsed; } catch (err) { throw new MistralExtractionError( "Failed to repair extraction", err instanceof UnrepairableError ? err : new Error(String(err)), ); } }}
Expected output: The extractor takes a transcript like “I need a plumber for a leaky faucet” and returns a fully typed, validated Housecall Pro job object. If the LLM returns messy text (like markdown-wrapped JSON), the repair layer still produces a clean result.
Step 8: Wire up the budget manager
Track per-call AI spend with @reaatech/agent-budget-engine. The budget controller enforces soft and hard caps, and fires events when thresholds are breached.
Expected output: Each call gets a $0.50 budget with an 80% warning threshold and a 100% hard stop. Spending beyond the limit blocks further Mistral calls.
Step 9: Set up the session store and MCP client
The session continuity manager keeps conversational state across turns. The MCP client connects to the voice agent’s MCP server for tool discovery.
Create src/lib/session-store.ts:
ts
import { SessionManager } from "@reaatech/session-continuity";import { MemoryAdapter } from "@reaatech/session-continuity-storage-memory";import { TiktokenTokenizer } from "@reaatech/session-continuity-tokenizers";let sessionManager: SessionManager | null = null;export function createSessionManager(): SessionManager { if (sessionManager) { return sessionManager; } const ttl = Number.parseInt(process.env.SESSION_TTL ?? "3600", 10); sessionManager = new SessionManager({ storage: new MemoryAdapter(), tokenCounter: new TiktokenTokenizer("gpt-4"), tokenBudget: { maxTokens: 4096, reserveTokens: 500, overflowStrategy: "compress", }, compression: { strategy: "sliding_window", targetTokens: 3500, }, sessionTTL: ttl, cleanupInterval: 60_000, }); return sessionManager;}export function getSessionManager(): SessionManager { if (!sessionManager) { return createSessionManager(); } return sessionManager;}
export { HousecallProClient, createHousecallProClient, HousecallProClientError, HousecallProServerError } from "./housecallpro-client.js";export { validateTwilioRequest, parseIncomingCall } from "./twilio-webhook.js";export { MistralBookingExtractor, MistralExtractionError } from "./mistral-extractor.js";export { budgetController, createCallBudget, checkMistralCall, recordMistralCall, deleteCallBudget,} from "./budget-manager.js";export { createSessionManager, getSessionManager } from "./session-store.js";export { createMCPClient, getMCPClient } from "./mcp-client-wrapper.js";
Expected output:SessionManager is created once and reused. MCPClient connects lazily on first use.
Step 10: Build the booking orchestrator
The orchestrator ties together: budget check, session history fetch, Mistral extraction with repair, and Housecall Pro job creation.
Create src/services/booking-orchestrator.ts:
ts
import type { MistralBookingExtractor } from "../lib/mistral-extractor.js";import type { HousecallProClient } from "../lib/housecallpro-client.js";import type { BudgetController } from "@reaatech/agent-budget-engine";import type { SessionManager } from "@reaatech/session-continuity";import type { CallOutcome } from "../types/call.js";import { checkMistralCall, recordMistralCall } from "../lib/budget-manager.js";import { MistralExtractionError } from "../lib/mistral-extractor.js";import type { z } from "zod";import type { ZodHousecallProJobSchema } from "../types/housecall-pro.js";interface
Expected output: Given a transcript and session, the orchestrator returns one of three outcomes: booking_created, transfer_needed, or failed.
Step 11: Create the voice pipeline with Deepgram STT and ElevenLabs TTS
Build the speech pipeline that connects Deepgram for speech-to-text and ElevenLabs for text-to-speech, then wires them into the voice agent core pipeline.
Create src/services/voice-pipeline.ts:
ts
import { createPipeline, createLatencyBudget, initializeSessionManager, LatencyBudgetEnforcer, defineConfig, type AudioChunk, type STTProvider, type TTSProvider, type Utterance, type MCPClient,} from "@reaatech/voice-agent-core";import type { DeepgramClient } from "@deepgram/sdk";import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";export class DeepgramSTTProvider implements STTProvider { readonly name = "deepgram-stt"; private client: DeepgramClient; private utteranceCallback
Create src/services/index.ts to re-export:
ts
export { DeepgramSTTProvider, ElevenLabsTTSProvider, createVoicePipeline } from "./voice-pipeline.js";export { BookingOrchestrator } from "./booking-orchestrator.js";
Expected output: The pipeline connects Deepgram → Mistral extraction → ElevenLabs TTS into a single real-time voice agent pipeline with a 800ms latency budget and barge-in support.
Step 12: Build the CallHandler and Twilio Media Streams handler
The CallHandler manages the full lifecycle of an incoming call: creating a budget, initializing a session, setting up the voice pipeline, processing utterances, and cleaning up on hangup.
Create src/api/call-handler.ts:
ts
import type { MistralBookingExtractor } from "../lib/mistral-extractor.js";import type { HousecallProClient } from "../lib/housecallpro-client.js";import type { BudgetController } from "@reaatech/agent-budget-engine";import type { SessionManager } from "@reaatech/session-continuity";import { createCallBudget, deleteCallBudget } from "../lib/budget-manager.js";import type { BookingOrchestrator } from "../services/booking-orchestrator.js";import type { DeepgramSTTProvider, ElevenLabsTTSProvider } from "../services/voice-pipeline.js";import { createPipeline, createLatencyBudget, initializeSessionManager, LatencyBudgetEnforcer, defineConfig, type
Now create the Twilio Media Streams WebSocket handler at src/api/twilio-media-stream.ts:
Expected output: An incoming Twilio call creates a budget, starts a session, initializes the voice pipeline, processes each utterance through the Mistral extractor, and cleans up when the call ends.
Step 13: Create the Twilio webhook route handler
This route receives Twilio’s voice webhook, validates the signature, and returns TwiML that connects the call to a Media Streams WebSocket.
Expected output: When Twilio sends a POST to /api/call/incoming, the route validates the HMAC signature and returns TwiML that instructs Twilio to open a Media Streams WebSocket connection.
Step 14: Add a health check route and the landing page
Create app/api/status/route.ts for a simple health check:
ts
import { NextResponse } from "next/server";export function GET(): NextResponse { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), });}
Replace app/page.tsx with a landing page that describes the system:
tsx
import styles from "./page.module.css";export default function Home() { return ( <div className={styles.page}> <main className={styles.main}> <div className={styles.intro}> <h1>Mistral AI Lead Intake for Housecall Pro Field Service Bookings</h1> <p> A voice-powered lead intake system that transcribes inbound calls via Deepgram, extracts booking details with Mistral AI, and creates Housecall Pro jobs automatically. </p> </div> </main> </div> );}
Expected output:GET /api/status returns { "status": "ok", "timestamp": "..." } and the home page displays the system description.
Step 15: Create the entry point and satisfy the preflight check
Create src/index.ts to export the public API surface. The @reaatech/create-voice-agent package is a CLI-only scaffolding tool (no runtime exports), so create a marker file to satisfy the import check.
Create src/_create-voice-agent.ts:
ts
// @reaatech/create-voice-agent is a CLI-only scaffolding tool (bin entry only, no module exports).// This file satisfies the preflight reaa_pkg_not_imported check.export const _createVoiceAgent = 'from "@reaatech/create-voice-agent"' as const;
Create src/index.ts:
ts
export { createCallHandler } from "./api/call-handler.js";export { HousecallProClient, createHousecallProClient } from "./lib/housecallpro-client.js";export { MistralBookingExtractor } from "./lib/mistral-extractor.js";export { budgetController, getSessionManager, createMCPClient } from "./lib/index.js";export const SCAFFOLD_VERSION = "0.1.0" as const;
Expected output: All exports resolve without TypeScript errors. The preflight checker finds the @reaatech/create-voice-agent import marker.
Step 16: Run the tests
The test suite covers the Mistral extractor (happy path, API errors, unrepairable text, content chunks, empty transcript), the budget manager (allow/deny, soft cap warning, hard stop events), the Twilio webhook route (valid signatures, invalid signatures, missing headers), the Zod schemas (valid payloads, missing fields, strict mode), and the Housecall Pro client.
terminal
pnpm test
Expected output: All tests pass, coverage is at or above 90% on runtime code (src/ and app/**/route.ts), and the output report shows numFailedTests=0.
Next steps
Add a database adapter — replace MemoryAdapter in the session store with a Redis or PostgreSQL adapter for production persistence across restarts.
Extend the extraction prompt — add fields like email, property photos, or insurance info by expanding the Mistral AI system prompt and the Housecall Pro Zod schema.
Add SMS fallback — when extraction fails, capture the caller’s phone number and send an SMS with a booking link instead of transferring to a human.