SMBs rely on generic contact forms that dump leads into a single email inbox. There is no real‑time qualification, no tagging, and no immediate segmentation for email nurturing, causing lost opportunities.
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.
Small-business marketing teams collect leads through website contact forms but have no automated way to sort them by urgency. Hot leads with purchase intent sit in the same inbox as cold general inquiries. In this tutorial you’ll build an AI-powered lead intake system that uses xAI Grok to classify website form submissions as hot, warm, or cold and automatically sync qualified leads into Mailchimp audience lists with the right tags. The pipeline uses the @reaatech/confidence-router to decide whether to route a lead, ask for clarification, or fall back based on confidence thresholds, and every classification emits a cost-telemetry span to Langfuse for observability.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
An xAI API key with access to Grok — set it as XAI_API_KEY in your environment
A Mailchimp account with a configured audience list — you’ll need the API key, server prefix, and list ID
A Langfuse account (free tier works) for cost telemetry — note your public and secret keys
Basic familiarity with TypeScript, Zod schemas, and Next.js App Router
Step 1: Scaffold the project
Create a new Next.js project and install the exact dependency versions this recipe uses. All packages are pinned to specific versions — no ^ or ~ ranges.
Now create the source directory where you’ll put the recipe’s modules, then add the dependencies. The @reaatech/* packages provide the confidence-router, LLM classifiers, structured output repair, and cost telemetry. The @ai-sdk/xai package connects to xAI Grok through the Vercel AI SDK, and @mailchimp/mailchimp_marketing handles Mailchimp API calls.
Expected output: Your package.json should show all recipe dependencies with exact versions (the scaffolding provides next, react, and their types; the pnpm add commands above pin everything else). The pnpm-lock.yaml file is regenerated.
Step 2: Define types and Zod schemas
Every module in this system shares a common set of TypeScript types and Zod validation schemas. You’ll define the lead form shape, the quality classification, the enriched lead that gets sent to Mailchimp, and the response types the API returns.
Create src/lib/types.ts:
ts
import { type CostSpan, type TelemetryContext } from "@reaatech/llm-cost-telemetry";import { z } from "zod";export type LeadQuality = "hot" | "warm" | "cold";
Expected output:pnpm typecheck exits 0. The LeadFormDataSchema validates a form with name, email, and message as required fields, and the LeadClassificationSchema constrains quality to exactly "hot", "warm", or "cold".
Step 3: Create the Grok classifier with confidence-router
The classifier module does the heavy lifting. It wires an LLMClassifier backed by xAI Grok into a ConfidenceRouter, then exposes classifyLead() and decideOnLead() functions. If the confidence-router’s primary path fails, a fallback path calls generateText from the Vercel AI SDK directly.
Create src/lib/classifier.ts:
Expected output:pnpm typecheck passes. The LLMClassifier points at https://api.x.ai/v1 with model "grok-3". The ConfidenceRouter uses routeThreshold (0.8) and fallbackThreshold (0.3) from environment variables.
The LLMClassifier constructor uses provider: "openai" — this tells the classifier to use OpenAI-compatible chat completions format, which xAI’s API speaks natively. The baseUrl is set to https://api.x.ai/v1 to point at xAI’s endpoint instead of OpenAI’s.
Step 4: Add structured output repair for LLM JSON
LLMs sometimes return JSON wrapped in markdown fences, with trailing commas, or with extra hallucinated fields. The @reaatech/structured-repair-core package cleans this up automatically. Create a thin wrapper that exposes repairJson() (throws on failure) and safeRepairJson() (returns a discriminated result).
Create src/lib/repair.ts:
ts
import { repair, repairOutput } from "@reaatech/structured-repair-core";import { type z } from "zod";export async function repairJson<T extends z.ZodType>( schema: T, input: string,): Promise<z.infer<T>> { return repair
Expected output:pnpm typecheck exits clean. The repair function is used in the classifier’s fallback path to clean up Grok’s raw JSON output before parsing it through the LeadClassificationSchema.
Step 5: Wire up cost telemetry with Langfuse
Every LLM call costs money. The @reaatech/llm-cost-telemetry package provides helpers for generating span IDs, timestamps, and calculating cost from token counts. You’ll build a small module that creates cost spans and records them to Langfuse for observability.
Create src/lib/telemetry.ts:
ts
import { generateId, now, calculateCostFromTokens, type CostSpan } from "@reaatech/llm-cost-telemetry";import { Langfuse } from "langfuse";import { type LeadQuality } from "./types";interface CreateCostSpanParams
Expected output:pnpm typecheck passes. The pricing constants match xAI Grok’s published rates ($0.50 per 1M input tokens, $1.50 per 1M output tokens). The initLangfuse function returns a singleton — calling it multiple times reuses the same client instance.
Step 6: Build the Mailchimp adapter
The Mailchimp adapter handles contact upsert and tagging. It computes the MD5 subscriber hash from the lead’s email (the standard Mailchimp identifier) and maps merge fields for name, phone, company, and source.
Create src/lib/mailchimp.ts:
ts
import mailchimp from "@mailchimp/mailchimp_marketing";import { createHash } from "node:crypto";import { type EnrichedLead } from "./types"
Expected output:pnpm typecheck passes. The node:crypto import provides the MD5 hashing used to compute the Mailchimp subscriber hash.
Step 7: Create the lead processor
The lead processor is the orchestration layer. It calls createRouter() to get a configured confidence-router, runs classifyLead() to get a classification from Grok, calls decideOnLead() to get the router’s decision, then either syncs to Mailchimp, returns a clarify prompt, or rejects the lead. It always records a cost-telemetry span regardless of outcome.
Create src/lib/lead-processor.ts:
ts
import { type LeadFormData, type LeadResponse, type EnrichedLead,} from "./types";import { createRouter, classifyLead, decideOnLead }
Expected output:pnpm typecheck passes. The function is the default export, making it easy to import as import processLead from "./lead-processor.js" in the route handler. Every path — ROUTE, CLARIFY, FALLBACK, and error — returns a LeadResponse discriminated union.
Step 8: Build the API route handler
The route handler lives at app/api/lead/route.ts and exposes a POST handler (with an OPTIONS handler for CORS preflight). It validates the incoming JSON body against the Zod schema, passes valid data through the lead processor, and maps the response to HTTP status codes.
Create app/api/lead/route.ts:
ts
import { type NextRequest, NextResponse } from "next/server";import { LeadFormDataSchema } from "@/src/lib/types";import processLead from "@/src/lib/lead-processor";const CORS_HEADERS: Record<string
Expected output:pnpm typecheck passes. The handler uses NextRequest and NextResponse (not bare Request/Response) so that the Content-Type: application/json header is set automatically via NextResponse.json(). The CORS_HEADERS object is attached to every response so browser-based form submissions work cross-origin.
Step 9: Configure environment and test
Set up your .env file with real API keys by copying the template:
terminal
cp .env.example .env
Open .env and fill in your xAI API key, Mailchimp credentials, and Langfuse keys. The file should look like this:
Create the server wrapper at tests/mocks/server.ts:
ts
import { setupServer } from "msw/node";import { handlers } from "./handlers";export const server = setupServer(...handlers);
Now run the test suite:
terminal
pnpm test
Expected output: 79 tests pass across 9 test files (exports, types, classifier, repair, telemetry, mailchimp, lead-processor, route handler, and an integration flow). Coverage across src/ and app/api/ meets the project’s 90% threshold on all categories.
Next steps
Add an email confirmation step — After a hot lead is synced to Mailchimp, send an automated reply via Mailchimp’s automation or a third-party email service like SendGrid.
Extend the classifier labels — Add "hot-sales" and "hot-support" subcategories to route leads to different Mailchimp lists or sales reps.
Add a dashboard — Build a simple admin page that shows recent classifications, cost-per-classification, and Mailchimp sync status using Langfuse’s trace data.
Deploy to production — Dockerize the app and deploy behind a reverse proxy, enforcing rate limits on the /api/lead endpoint to prevent abuse.
Add webhook signatures — Validate incoming form submissions with a shared secret to ensure they come from your trusted form provider.
export
interface
LeadFormData
{
name: string;
email: string;
phone?: string;
company?: string;
message: string;
source?: string;
}
export interface LeadClassification {
quality: LeadQuality;
confidence: number;
explanation: string;
}
export interface LeadDecision {
outcome: "ROUTE" | "CLARIFY" | "FALLBACK";
target?: string;
confidence: number;
prompt?: string;
}
export interface EnrichedLead {
id: string;
name: string;
email: string;
phone: string;
company: string;
quality: LeadQuality;
confidence: number;
source: string;
tags: string[];
scoredAt: string;
}
export type LeadResponse =
| { status: "accepted"; contactId: string }
| { status: "rejected"; reason: string }
| { status: "clarify"; prompt: string };
export const LeadFormDataSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.email("Invalid email format"),
phone: z.string().optional(),
company: z.string().optional(),
message: z.string().min(1, "Message is required"),