Anthropic Lead Intake for SMB Home Services Bookings
AI-powered lead capture and qualification for plumbers, electricians, and HVAC businesses, extracting service details from web forms and scheduling callbacks.
Home service businesses lose leads when they can't respond quickly to website inquiries, and manual form reading leads to missed appointments and data entry errors.
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 an AI-powered lead intake system for home service businesses — plumbers, electricians, and HVAC contractors. You’ll wire up Claude (via the Anthropic SDK) to extract structured service requests from web form submissions, qualify leads by confidence thresholds, route high-confidence leads to a booking webhook, and maintain session context for follow-up questions. By the end you’ll have a working Next.js App Router API endpoint at POST /api/lead that accepts form data (including photo and PDF attachments), returns extracted lead data, and optionally schedules a callback.
Prerequisites
Node.js >= 22 and pnpm >= 10 installed on your machine
An Anthropic API key with access to claude-sonnet-4-6
A Langfuse account (or any OpenTelemetry-compatible endpoint) for cost telemetry — or just set placeholder values and skip the dashboards
Basic familiarity with Next.js App Router route handlers and TypeScript
Step 1: Scaffold and install dependencies
Start from the project root. The scaffold includes a Next.js 16 App Router shell with all dependencies already pinned in package.json. Install everything and verify the scaffold compiles.
terminal
pnpm installpnpm typecheckpnpm lint
Expected output:pnpm typecheck and pnpm lint both exit 0 with zero errors. The next.config.ts at the root is minimal — just an empty NextConfig object. Do not modify it; the instrumentation setup in Step 7 will note the config gap.
Now create your .env.local file from the template:
terminal
cp .env.example .env.local
Open .env.local and replace the placeholders with real values. The key one is ANTHROPIC_API_KEY — without it the Anthropic service will fail at runtime. Your .env.local should look something like this after editing:
Expected output: You now have a .env.local file with all the env vars the application reads. The BOOKING_WEBHOOK_URL can point to a local mock server during development — you’ll see how the test suite handles this in Step 8.
Step 2: Define domain types and validation schemas
The entire pipeline revolves around a handful of types and Zod schemas. Start with src/types/lead.ts — these are the plain TypeScript types you’ll use across every module.
Next, the validation schemas in src/lib/schemas.ts. You’ll use the extracted lead schema with .strict() — this pairs with @reaatech/structured-repair-core’s remove-extra-fields strategy, so any hallucinated keys Claude produces are automatically stripped.
typescript
import { z } from "zod";export const LeadFormSchema = z.object({ serviceType: z.enum(["plumbing", "electrical", "hvac", "other"]), urgency: z.enum(["emergency", "urgent", "standard", "flexible"]),
Expected output: You now have typed domain model files that define every shape flowing through the pipeline. Note the .email() method — this is Zod 4.4.3’s native email validator (not a .regex() or .email() from Zod 3). The ExtractedLeadSchema includes .strict(), which means any extra field Claude adds will cause a parse failure — the repair engine handles stripping those.
Step 3: Write prompt templates and the Anthropic service
The system prompt tells Claude what format to return. Create src/services/prompt-templates.ts with two system prompts (extraction and classification) and two builder functions that wrap form data into the message arrays Claude expects.
typescript
import type
Now wire those into the Anthropic service at src/services/anthropic-service.ts. This class wraps client.messages.create() and pipes every response through repair() from @reaatech/structured-repair-core, so malformed JSON is fixed automatically.
typescript
import Anthropic from "@anthropic-ai/sdk";
Note the imports: Anthropic comes in as a default import (not a named import { Anthropic }), matching the SDK’s actual export at @anthropic-ai/sdk@0.98.0.
Expected output: Two modules — prompt-templates.ts with the system prompts and message builders, and anthropic-service.ts with a class that calls Claude, validates via repair(), generates clarification prompts for ambiguous leads, and throws typed errors on non-text responses or irreparable output.
Step 4: Build the attachment extractor
Home service requests often include photos of damaged pipes or PDF estimates. The attachment extractor in src/services/attachment-extractor.ts handles three cases: PDF text extraction via unpdf, image resizing via sharp, and unsupported formats.
typescript
import { extractText, getDocumentProxy } from "unpdf";import sharp from "sharp";export type AttachmentResult = { filename: string; text?: string; base64Image?:
Key design decision: per-file try/catch — a broken PDF won’t crash the whole request. The caller (the API route) decides how to surface per-file errors.
Expected output:attachment-extractor.ts is ready to handle application/pdf, image/jpeg, image/png, and image/webp files. Everything else gets error: "unsupported_format". The hasAttachments() guard lets you skip Claude calls when there are no files.
Step 5: Configure confidence routing, session continuity, and cost tracking
Three lib modules handle the business logic between Claude’s output and the final response.
Confidence router (src/lib/classifier.ts): uses @reaatech/confidence-router to decide what to do with each lead.
typescript
import { ConfidenceRouter } from "@reaatech/confidence-router";export function createLeadRouter() { const routeThreshold = parseFloat(process.env.LEAD_ROUTE_THRESHOLD ?? "0.8"); const fallbackThreshold = parseFloat(process.env.LEAD_FALLBACK_THRESHOLD ?? "0.3"); return new ConfidenceRouter({ routeThreshold, fallbackThreshold, clarificationEnabled: true, });
Three decision types: ROUTE (confidence >= threshold), CLARIFY (between thresholds), and FALLBACK (below lower threshold).
Session continuity (src/lib/session.ts): implements an in-memory IStorageAdapter and wraps SessionManager from @reaatech/session-continuity.
Cost tracking (src/lib/cost-tracker.ts): uses @reaatech/llm-cost-telemetry to calculate spend from token counts.
typescript
import { generateId, now, calculateCostFromTokens, CostSpanSchema,} from "@reaatech/llm-cost-telemetry";import type { CostSpan } from "@reaatech/llm-cost-telemetry"
Expected output: Three lib modules handle routing decisions, conversation session state, and per-lead cost calculation. The wrapWithCostTracking helper records a cost span even when the wrapped function throws — no silent spend gaps.
Step 6: Wire up Langfuse telemetry and booking handoff
Telemetry (src/services/telemetry.ts) creates a lazy-initialized Langfuse client and exposes helpers for traces and generations.
typescript
import Langfuse from "langfuse";import type { LangfuseTraceClient, LangfuseGenerationClient } from "langfuse";let client: Langfuse | null = null;export function createLangfuseClient(): Langfuse { if (client) return client; try {
The first call to createLangfuseClient() that fails falls back to a no-op instance — your development environment doesn’t need a running Langfuse instance.
Booking handoff (src/services/handoff.ts) POSTs qualified leads to a configurable webhook URL, using withRetry from @reaatech/agent-handoff for resilience.
typescript
import { withRetry } from "@reaatech/agent-handoff";export class BookingHandoffService { private webhookUrl: string | undefined; constructor() { this.webhookUrl = process.env.BOOKING_WEBHOOK_URL;
Expected output: Telemetry and handoff modules are ready. Langfuse traces track the full lifecycle of each lead; the booking handoff retries up to 3 times on network errors.
Step 7: Create the lead intake API route, health check, and instrumentation
This is where everything comes together. The main route at app/api/lead/route.ts accepts multipart/form-data, validates it, extracts attachments, calls Claude twice (extraction then classification), routes the result, and returns a structured response.
The health check at app/api/health/route.ts is a one-liner:
typescript
import { NextResponse } from "next/server";export function GET(): NextResponse { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });}
The instrumentation module at src/instrumentation.ts initializes the Langfuse client when Next.js starts in Node runtime:
typescript
export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME !== "nodejs") return; const { createLangfuseClient } = await import("./services/telemetry.js"); createLangfuseClient();}
Note: the instrumentation.ts file requires experimental.instrumentationHook: true in next.config.ts to fire. The scaffold’s next.config.ts does not set this flag — the instrumentation tests verify the register() function directly rather than relying on the Next.js lifecycle, so tests pass regardless. If you want the hook to fire in production, add the config key yourself.
Expected output: A complete API endpoint at /api/lead that accepts form submissions, extracts attachments, classifies via Claude, routes decisions, and logs telemetry. A health endpoint at /api/health. Instrumentation that seeds Langfuse on startup.
Step 8: Run the test suite
The test suite covers every module with 50+ tests using MSW to mock all external HTTP calls. The setup file at tests/setup.ts configures MSW handlers for both the Anthropic API and the booking webhook.
typescript
import { http, HttpResponse } from "msw";import { setupServer } from "msw/node";function makeExtractionResponse
Run the full suite:
terminal
pnpm test
Expected output: All tests pass. The test suite validates the full pipeline end-to-end: a valid form submission triggers Claude extraction (MSW-mocked), classification, routing, and a webhook POST. It also covers error paths — invalid enums return 400, Anthropic 500s return 500 with a leadId, and broken PDFs produce per-file errors without crashing the handler.
Next steps
Add a real database adapter — replace the in-memory InMemoryAdapter in session.ts with the Postgres or Redis adapter from @reaatech/session-continuity for persistence across restarts
Deploy the webhook — wire BOOKING_WEBHOOK_URL to a real calendar API (Calendly, Cal.com) so qualified leads auto-schedule appointments
Extend the dashboard — build a Next.js page at /dashboard that queries the Langfuse API to show per-lead cost, route decisions, and session history in real time
export type LeadClassificationData = z.infer<typeof LeadClassificationSchema>;
{ LeadFormData, ExtractedLeadData }
from
"../lib/schemas.js"
;
export const LEAD_EXTRACTION_SYSTEM = `You are a lead extraction assistant. Extract structured lead data from the provided form submission. Return a JSON object matching the ExtractedLeadSchema with the following fields:
- serviceType: one of "plumbing", "electrical", "hvac", or "other"
- description: the full description text
- urgency: one of "emergency", "urgent", "standard", or "flexible"
- address: the service address
- contactName: the contact's name
- phone: the contact's phone number
- email: optional email address
- preferredDate: optional preferred service date
Output raw JSON with no markdown fences or additional text.`;
export const LEAD_CLASSIFICATION_SYSTEM = `You are a lead classification assistant. Classify the extracted lead into a service category with a confidence score between 0 and 1 and an urgency assessment. Return a JSON object matching the LeadClassificationSchema with the following fields:
- category: one of "plumbing", "electrical", "hvac", or "other"
- confidence: a number between 0 and 1 indicating confidence in the classification
- urgency: one of "emergency", "urgent", "standard", or "flexible"
Output raw JSON with no markdown fences or additional text.`;
const result = await this.client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 512,
system: "You are a clarification assistant. Given an extracted lead with potentially missing or ambiguous fields, generate a prompt asking the user for clarification.",
messages: [
{
role: "user",
content: `Extracted lead data:\n${JSON.stringify(lead, null, 2)}\n\nIdentify any missing or ambiguous fields and ask for clarification.`,