A studio owner or front-desk manager loses potential members because their manual follow-up to website lead forms takes hours or even days. Chain studios auto-reply instantly, so prospects sign up elsewhere. The owner needs a zero-touch system that sends a personalized welcome and class schedule immediately, without requiring constant attention.
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.
You’ll build an instant lead response agent for fitness studios — an API endpoint that takes a web form submission, classifies the prospect’s intent, routes the request through an agent mesh, generates a personalized LLM welcome message, and stores the interaction for follow-up. The stack is Next.js 16 (App Router) with Hono for the HTTP layer, the Vercel AI SDK for LLM calls, the @reaatech/* agent packages for classification and routing, Langfuse for observability, and Zod for validation. By the end you’ll have a working POST /api/leads endpoint and a test suite with 90%+ coverage.
Expected output:pnpm-lock.yaml is created and all packages are resolved. Your package.json now has every dependency pinned to an exact semver (no ^ or ~ prefixes).
Step 2: Configure Next.js for instrumentation
Next.js 16 requires experimental.instrumentationHook: true in the config so the src/instrumentation.tsregister() function fires at server startup.
Copy the environment template and fill in your keys. The app reads these variables at startup — the config loader will throw if OPENAI_API_KEY or LEAD_RESPONSE_MODEL are missing.
Copy .env.example to .env.local:
env
# Env vars used by agnostic-lead-response-agent.# Keep placeholders only — never commit real values.OPENAI_API_KEY=<your-openai-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=https://cloud.langfuse.comLEAD_RESPONSE_MODEL=gpt-5.2-miniLEAD_AGENT_ENDPOINT=http://localhost:3100NEXT_PUBLIC_APP_NAME="Instant Lead Response"NODE_ENV=development
Expected output: The app now has OPENAI_API_KEY, LEAD_RESPONSE_MODEL, and optional LANGFUSE_* vars available via process.env.
Step 4: Create the Zod schemas
The schemas module defines three Zod schemas for request validation, response shape, and studio profiles. These are used by the Hono route handler and the test suite.
Expected output:LeadIntakeSchema validates inbound lead form data (name, email, message, studioId required; phone and preferredClassType optional). LeadResponseSchema and StudioProfileSchema define the API output shapes.
Step 5: Create the types and re-export layer
This module re-exports the core types from @reaatech/agent-mesh (schemas, enums, configuration types) and defines the domain-specific interfaces the recipe uses.
Expected output: Your project now has all the reusable types needed by the services and route handler. Note the SERVICE_VERSION constant — exported as "1.0.0" — which the health endpoint uses.
Step 6: Create the config loader
The config module validates that required environment variables are present at module load time using Zod. If LEAD_RESPONSE_MODEL or OPENAI_API_KEY is missing, startup fails immediately with a clear error.
Expected output: When the module is imported with OPENAI_API_KEY and LEAD_RESPONSE_MODEL set, leadResponseConfig is a validated typed object. When either is missing, it throws a descriptive error.
Step 7: Create the observability service
The observability module wraps Langfuse tracing. It lazily creates a singleton client and provides traceLeadProcessing — a higher-order function that wraps any async operation with Langfuse trace/span instrumentation. When Langfuse keys aren’t set, it falls through gracefully.
The classifier wraps @reaatech/agent-mesh-classifier’s classifierService.classify() call, which uses an underlying intent classifier (Gemini Flash when available, keyword-based mock otherwise) to map a prospect’s message to an agent profile and extract intent details. If the classification call fails, it returns a safe fallback.
Create src/services/lead-classifier.ts:
ts
import { classifierService, detectLanguage, isRateLimitError, buildClassifierPrompt, parseClassifierOutput, isValidLanguageCode, getClarificationQuestion, FALLBACK_QUESTIONS,} from "@reaatech/agent-mesh-classifier";import { type ClassifierOutput, type AgentConfig } from "@reaatech/agent-mesh";const FALLBACK_CLASSIFICATION: ClassifierOutput = { agent_id: "lead-agent", confidence: 0.5, ambiguous: false, detected_language: "en", intent_summary: "lead inquiry", entities: {},};export async function classifyIntent( userInput: string, agentRegistry: unknown = [], priorLanguage?: string,): Promise<ClassifierOutput> { try { if (classifierService.isMock()) { console.log("Running in mock classifier mode"); } return await classifierService.classify( userInput, agentRegistry as AgentConfig[], priorLanguage, ); } catch (error) { console.warn("classifyIntent failed, returning fallback:", error); return FALLBACK_CLASSIFICATION; }}export function detectLeadLanguage(text: string): string { return detectLanguage(text);}export function isRateLimited(error: unknown): boolean { return isRateLimitError(error);}export function buildLeadClassifierPrompt( registry: AgentConfig[], userInput: string, detectedLanguage?: string,): string { return buildClassifierPrompt(registry, userInput, detectedLanguage);}export function parseClassifierResult(jsonStr: string): ClassifierOutput { return parseClassifierOutput(jsonStr);}export function validateLanguageCode(code: string): boolean { return isValidLanguageCode(code);}export function getLocalizedClarification(language: string): string { return getClarificationQuestion(language);}export { FALLBACK_QUESTIONS };
Step 9: Create the lead routing service
The routing service dispatches classified leads to the correct agent via @reaatech/agent-mesh-router’s dispatchToAgent over StreamableHTTP. It also provides helpers for building turn entries, extracting response content, checking workflow completion, and managing MCP connections.
Create src/services/lead-routing.ts:
ts
import { dispatchToAgent, buildTurnEntry, formatAgentResponse, shouldCloseSession, getUpdatedWorkflowState, mcpClientFactory,} from "@reaatech/agent-mesh-router";import { type AgentConfig, type AgentResponse, type TurnEntry } from "@reaatech/agent-mesh";export function createLeadAgentConfig(): AgentConfig { return { agent_id: "lead-agent", display_name: "Lead Responder", description: "Handles inbound fitness studio leads", endpoint: process.env.LEAD_AGENT_ENDPOINT ?? "http://localhost:3100", type: "mcp" as const, is_default: false, confidence_threshold: 0.5, clarification_required: false, examples: [], };}export async function routeLead( agent: AgentConfig, input: { sessionId: string; leadName: string; leadEmail: string; rawInput: string; intentSummary: string; entities: Record<string, unknown>; detectedLanguage: string; turnHistory: TurnEntry[]; workflowState: Record<string, unknown>; },): Promise<AgentResponse> { try { return await dispatchToAgent(agent, { sessionId: input.sessionId, employeeId: input.leadEmail, displayName: input.leadName, rawInput: input.rawInput, intentSummary: input.intentSummary, entities: input.entities, detectedLanguage: input.detectedLanguage, turnHistory: input.turnHistory, workflowState: input.workflowState, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes("Circuit breaker OPEN")) { throw new Error(`Circuit breaker open for agent ${agent.agent_id}: ${message}`); } if (message.includes("MCP request timeout")) { throw new Error(`MCP timeout dispatching to ${agent.agent_id}: ${message}`); } if (message.includes("did not match AgentResponseSchema")) { throw new Error(`Schema validation error for ${agent.agent_id}: ${message}`); } throw new Error(`Failed to dispatch request to agent ${agent.agent_id}: ${message}`); }}export function buildLeadTurnEntry( role: "user" | "assistant", content: string, intentSummary?: string,): TurnEntry { return buildTurnEntry(role === "assistant" ? "agent" : role, content, intentSummary);}export function extractResponseContent(response: AgentResponse): string { return formatAgentResponse(response);}export function isLeadWorkflowComplete(response: AgentResponse): boolean { return shouldCloseSession(response);}export function mergeLeadWorkflowState( current: Record<string, unknown>, response: AgentResponse,): Record<string, unknown> { return getUpdatedWorkflowState(current, response);}export async function closeAllAgentConnections(): Promise<void> { await mcpClientFactory.closeAll();}
Step 10: Create the response generator
The response generator uses the Vercel AI SDK (generateText) with the OpenAI provider to produce a personalized welcome message. It builds a system prompt that includes the studio name and class types, then estimates cost from token usage.
Create src/services/response-generator.ts:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import type { LeadContext } from "../lib/types.js";export async function generateWelcomeResponse( ctx: LeadContext,): Promise<{ text: string; usage: { inputTokens: number; outputTokens: number } }> { const systemPrompt = `You are a friendly fitness studio assistant for ${ctx.studioName}. Respond in under 2 seconds with a warm, personalized welcome. Include: greeting by name, available class types (${ctx.classTypes.join(", ")}), next steps to get started. Keep under 150 words.`; const userPrompt = `New lead: ${ctx.leadName}.${ctx.preferredClassType ? ` Interested in ${ctx.preferredClassType}.` : ""}${ctx.priorHistory ?? ""}`; const result = await generateText({ model: openai(process.env.LEAD_RESPONSE_MODEL ?? "gpt-5.2-mini"), system: systemPrompt, prompt: userPrompt, }); return { text: result.text, usage: { inputTokens: result.usage.inputTokens ?? 0, outputTokens: result.usage.outputTokens ?? 0, }, };}export function estimateResponseCost(usage: { inputTokens: number; outputTokens: number;}): number { return ((usage.inputTokens + usage.outputTokens) * 0.002) / 1000;}
Step 11: Create the lead memory service
The memory service wraps @reaatech/agent-memory’s AgentMemory class configured with in-memory storage and OpenAI embeddings. It stores conversations (user + agent turns) via extractAndStore, which automatically extracts facts and preferences for long-term retention.
This is the central orchestrator. The Hono app defines three routes — POST /leads (the main lead intake endpoint), GET /health (a health check), and GET /leads/:id (a placeholder). The POST /leads handler chains the full pipeline: validate the request body with Zod, classify intent, route through the agent mesh, generate an LLM welcome message, store the interaction in memory, and return the response with timing metrics.
Create src/api/hono-app.ts:
ts
import { Hono } from "hono";import { LeadIntakeSchema } from "../lib/schemas.js";import { classifyIntent } from "../services/lead-classifier.js";import { routeLead, createLeadAgentConfig } from "../services/lead-routing.js";import { createLeadMemory, storeLeadInteraction } from "../services/lead-memory.js";import { generateWelcomeResponse, estimateResponseCost } from "../services/response-generator.js";import { traceLeadProcessing, createLeadSpan } from "../services/observability.js";import type { LeadContext } from "../lib/types.js";import { SERVICE_VERSION } from "../lib/types.js";const app = new Hono();
Expected output: The Hono app is ready with three routes. POST /leads is the main pipeline; GET /health returns {"status":"ok","version":"1.0.0","uptimeMs":...}.
Step 13: Create the Next.js route handler and instrumentation
Next.js exposes the Hono app via hono/vercel’s handle() adapter. The route handler file is minimal — it imports the Hono app and exports GET and POST handlers that Next.js discovers automatically.
Create app/api/leads/route.ts:
ts
import { handle } from "hono/vercel";import app from "../../../src/api/hono-app.js";export const GET = handle(app);export const POST = handle(app);
Next, create the instrumentation hook. This runs once during server startup and initializes the Langfuse observability client (only in the Node.js runtime, not Edge).
Create src/instrumentation.ts:
ts
export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { createObservabilityClient } = await import("./services/observability.js"); createObservabilityClient(); }}
Expected output: The instrumentationHook: true flag in next.config.ts ensures register() runs at startup, initializing the Langfuse client before any requests arrive.
Step 14: Create the barrel exports and replace the home page
The barrel file re-exports everything from a single entry point. This is useful for external consumers of the recipe package.
Create src/index.ts:
ts
export type { LeadIntake, LeadResponse, StudioProfile, ClassSchedule, LeadContext, IncomingRequest, AgentResponse, ClassifierOutput, AgentConfig, ContextPacket, TurnEntry,} from "./lib/types.js";export { LeadIntakeSchema, LeadResponseSchema, StudioProfileSchema,} from "./lib/schemas.js";export { IncomingRequestSchema, AgentResponseSchema, ClassifierOutputSchema, AgentConfigSchema, ContextPacketSchema, TurnEntrySchema, env, SERVICE_NAME, SERVICE_VERSION,} from "./lib/types.js";export { classifyIntent, detectLeadLanguage, isRateLimited, buildLeadClassifierPrompt, parseClassifierResult, validateLanguageCode, getLocalizedClarification,} from "./services/lead-classifier.js";export { routeLead, createLeadAgentConfig } from "./services/lead-routing.js";export { createLeadMemory, storeLeadInteraction } from "./services/lead-memory.js";export { generateWelcomeResponse, estimateResponseCost } from "./services/response-generator.js";export { createObservabilityClient, traceLeadProcessing } from "./services/observability.js";
Now replace the scaffolded app/page.tsx with the lead capture form. This client component renders a form with name, email, phone, class type selector, and message fields, then POSTs to /api/leads and displays the AI-generated welcome response.
Hono API integration test — tests the full pipeline end-to-end with all services mocked. Below is an excerpt showing the structure; the remaining tests cover validation failures, service errors (500), the health endpoint, and the placeholder route.
Coverage thresholds for lines, branches, functions, and statements all meet 90% or higher on runtime code (services under src/ and route handlers under app/)
You can also start the dev server to test the API live:
terminal
pnpm dev
Then send a lead form submission:
terminal
curl -X POST http://localhost:3000/api/leads \ -H "Content-Type: application/json" \ -d '{ "name": "Sarah", "email": "sarah@example.com", "phone": "555-0123", "preferredClassType": "Yoga", "message": "I'\''d like to try a beginner yoga class this week", "studioId": "fitstudio-downtown" }'
Expected output: A 201 response with leadId, welcomeMessage (an LLM-generated personalized greeting), scheduleSummary, nextSteps, agentId, and responseTimeMs.
Next steps
Add a persistent storage layer — replace the in-memory memory adapter with PostgreSQL pgvector via @reaatech/agent-memory-storage so leads survive server restarts
Wire a real MCP agent backend — the routeLead call dispatches to an MCP endpoint at LEAD_AGENT_ENDPOINT; point it at a real agent server for deeper conversation workflows
Add rate limiting and spam detection — integrate middleware that caps submissions per email or IP and filters obvious spam before classification