Small businesses lose leads because visitors abandon long contact forms or get no immediate response. Salespeople waste time on unqualified meetings while hot leads sit idle.
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 qualification chatbot that conducts natural conversations with website visitors, scores their sales readiness, and books Calendly meetings for hot leads. You’ll use Azure OpenAI for natural language understanding, five REAA packages for conversation management and lead routing, and Langfuse for observability. By the end you’ll have a fully functional Next.js API with a multi-turn questionnaire, keyword-based intent classification, Calendly integration, webhook event processing, and agent handoff escalation.
Prerequisites
Node.js >= 22 and pnpm 10 installed on your machine
An Azure OpenAI resource with a deployed model (e.g., gpt-4o) — you’ll need the endpoint, API key, and deployment name
A Langfuse account (free tier works) for observability — get public and secret keys from your project settings
Familiarity with TypeScript, Next.js App Router, and basic REST API concepts
Step 1: Scaffold the project and install dependencies
Create a new Next.js project with TypeScript and install all dependencies. The package manager is pnpm, and every dependency must be pinned to an exact version.
Expected output: The dependencies block in package.json now contains all 10 packages (Next.js 16, React 19, and the 8 you just added) with exact semver — no ^ or ~ prefixes. msw 2.14.6 appears under devDependencies. Run pnpm install to confirm the lockfile is healthy.
Step 2: Configure environment variables
Open .env.example and paste the complete list of environment variables. Every value is a placeholder — never commit real keys.
env
# Env vars used by azure-ai-lead-intake-for-calendly-smb-lead-qualification.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Azure OpenAIAZURE_OPENAI_ENDPOINT=<your-azure-openai-endpoint>AZURE_OPENAI_API_KEY=<your-azure-openai-api-key>AZURE_OPENAI_DEPLOYMENT_NAME=<your-deployment-name># Calendly OAuth + APICALENDLY_CLIENT_ID=<your-calendly-client-id>CALENDLY_CLIENT_SECRET=<your-calendly-client-secret>CALENDLY_REDIRECT_URI=<your-redirect-uri>CALENDLY_API_KEY=<your-calendly-personal-access-token># Langfuse observabilityLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=<your-langfuse-host># Confidence router thresholdsCONFIDENCE_ROUTE_THRESHOLD=0.8 # minimum confidence to route the lead as sales‑readyCONFIDENCE_FALLBACK_THRESHOLD=0.3 # confidence below this triggers polite decline# Session token budgetSESSION_MAX_TOKENS=4096 # token budget per conversation session
Expected output: Run cat .env.example — you should see all 13 variables with <your-...> placeholders. Note that CALENDLY_WEBHOOK_SECRET is an optional env var used by the webhook route (the code checks for it before verifying signatures); you can add it later when you configure Calendly webhooks in production.
Step 3: Define shared types
Create src/lib/types.ts with all the TypeScript types used across the project:
Expected output: Run npx tsc --noEmit --pretty — no errors. The LeadScore union type restricts values to exactly four strings, and every interface has the fields your routes and services will use.
Step 4: Create the Zod-validated environment config
Create src/lib/config.ts to validate all environment variables at module load with Zod:
Expected output: The schema calls .coerce.number() on CONFIDENCE_ROUTE_THRESHOLD, CONFIDENCE_FALLBACK_THRESHOLD, and SESSION_MAX_TOKENS so they arrive as numbers even though process.env gives you strings. CALENDLY_WEBHOOK_SECRET is optional — no ZodError if it’s missing. If any required variable is absent at import time, envSchema.parse(process.env) throws a ZodError immediately — fail fast.
Step 5: Build the token counter
Create src/services/token-counter.ts — a simple character-count tokenizer that implements the TokenCounter interface from @reaatech/session-continuity:
ts
import type { TokenCounter, Message } from "@reaatech/session-continuity";export class SimpleTokenCounter implements TokenCounter { readonly model = "azure-gpt-4o"; readonly tokenizer = "simple-char-count"; count(text: string): number { return Math.max(1, Math.ceil(text.length / 4)); } countMessages(messages: Message[]): number { let total = 0; for (const msg of messages) { const content = msg.content; if (typeof content === "string") { total += this.count(content); } else { for (const block of content) { if (block.type === "text") { total += this.count(block.text); } else { total += 85; } } } total += 3; } return total; }}export const tokenCounter = new SimpleTokenCounter();
Expected output: The count() method estimates ~4 characters per token. count("") returns 1 (the Math.max(1, ...) guard prevents zero). Image blocks get a fixed cost of 85 tokens. The module exports a singleton instance for reuse.
Step 6: Build the in-memory session store
Create src/services/session-store.ts implementing IStorageAdapter from @reaatech/session-continuity. You can paste the entire file using a terminal heredoc:
terminal
cat > src/services/session-store.ts << 'TYPESCRIPT_EOF'import { type IStorageAdapter, type Session, type Message, type SessionId, type MessageId, type HealthStatus, type SessionFilters, type UpdateSessionOptions, type MessageQueryOptions, ConcurrencyError,} from "@reaatech/session-continuity";export class InMemorySessionStore implements IStorageAdapter { private sessions = new Map<SessionId, Session>(); private messages = new Map<SessionId, Message[]>(); createSession( session: Omit<Session, "id" | "createdAt" | "lastActivityAt">, ): Promise<Session> { const id: SessionId = crypto.randomUUID(); const now = new Date(); const full: Session = { ...session, id, createdAt: now, lastActivityAt: now, }; this.sessions.set(id, full);
Expected output: The store implements all 13 methods of IStorageAdapter. Notable: updateSession checks expectedVersion for optimistic concurrency and throws ConcurrencyError on mismatch. Messages are deterministically ordered by (createdAt, sequence, id).
Step 7: Create the Azure OpenAI LLM client
Create src/services/llm-client.ts. This recipe uses direct fetch calls to the Azure OpenAI REST API (the @azure/openai import is a side-effect import for SDK registration; all actual API calls go through fetch):
Expected output: Two methods — sendMessage returns plain text, sendMessageWithJson uses response_format: { type: "json_object" } and parses the result. Both throw LlmClientError on HTTP errors or content-filtered responses.
Step 8: Build the lead scorer with confidence routing
Create src/services/lead-scorer.ts using @reaatech/confidence-router. This is the core lead qualification engine:
Expected output: The ConfidenceRouter is configured from confidenceConfig (route threshold 0.8, fallback threshold 0.3). The KeywordClassifier maps user messages to one of four intent labels. router.process(userMessage) runs classify-then-decide end-to-end, returning a RoutingDecision with { type, target }.
Test this by calling scoreLeadIntent("I want to book a demo") — it should return a decision with type: "ROUTE" and target: "high_intent".
Step 9: Create the questionnaire engine
Create src/services/questionnaire.ts with a 6-step lead qualification form:
ts
import type { QuestionnaireStep, LeadProfile, ConversationTurn } from "../lib/types.js";import { repair } from "@reaatech/structured-repair-core";import { z } from "zod";export const DEFAULT_QUESTIONNAIRE: QuestionnaireStep[] = [ { id: "name", question: "What's your name?", field: "name", type: "free_text", }, { id: "email", question: "What's your email address?", field: "email", type: "free_text", }, { id: "company", question: "What company are you with?", field: "company", type: "free_text", }, { id: "interest", question: "What product or service are you interested in?", field: "interest", type: "free_text", }, { id: "budget", question: "What's your approximate budget range?", field: "budget", type: "choice", options: ["<5k", "5k–20k", "20k–100k", "100k+"], }, { id: "timeline", question: "When are you looking to make a decision?", field: "timeline", type: "choice", options: [ "Immediately", "1–3 months", "3–6 months", "6+ months", "Just exploring", ], },];export function getNextQuestion( profile: Partial<LeadProfile>,): QuestionnaireStep | null { for (const step of DEFAULT_QUESTIONNAIRE) { const value = profile[step.field as keyof typeof profile]; if (value === undefined || value === "") { return step; } } return null;}export function isQuestionnaireComplete( profile: Partial<LeadProfile>,): boolean { for (const step of DEFAULT_QUESTIONNAIRE) { const value = profile[step.field as keyof typeof profile]; if (!value || value === "") { return false; } } return true;}export async function extractAnswer( llmReply: string, step: QuestionnaireStep, schema: z.ZodType,): Promise<{ field: string; value: string }> { const result = await repair(schema, llmReply); const parsed = result as Record<string, unknown>; const rawValue = typeof parsed.value === "string" ? parsed.value : String(result); return { field: step.field, value: rawValue };}export function buildQuestionnairePrompt( step: QuestionnaireStep, history: ConversationTurn[],): Array<{ role: string; content: string }> { const messages: Array<{ role: string; content: string }> = [ { role: "system", content: "You are a friendly lead-qualification chatbot. Ask one question at a time and keep responses concise.", }, ]; for (const turn of history) { messages.push({ role: turn.role, content: turn.content }); } messages.push({ role: "assistant", content: step.question, }); return messages;}
Expected output:getNextQuestion({}) returns the first step (name). After all 6 fields are populated, isQuestionnaireComplete returns true. The extractAnswer function uses @reaatech/structured-repair-core’s repair() to fix malformed LLM JSON responses before extracting the answer.
Step 10: Build the Calendly client
Create src/services/calendly-client.ts to generate single-use scheduling links:
ts
import { calendlyConfig } from "../lib/config.js";import type { LeadProfile } from "../lib/types.js";export class CalendlyError extends Error { statusCode: number; body?: unknown; constructor(message: string, statusCode: number, body?: unknown) { super(message); this.name = "CalendlyError"; this.statusCode = statusCode; this.body = body; }}export class CalendlyClient { private
Expected output: Three API calls in sequence: GET /users/me → GET /event_types → POST /scheduling_links. The retryAfter429 method reads the Retry-After header, waits, and retries with the same POST body. All errors throw CalendlyError with statusCode and body.
Step 11: Create the webhook handler
Create src/services/webhook-handler.ts to process Calendly invitee.created events:
ts
import type { CalendlyEventPayload, LeadRecord } from "../lib/types.js";import { repair } from "@reaatech/structured-repair-core";import { z } from "zod";import { matchEventType } from "@reaatech/webhook-relay-core";const calendlyInviteeCreatedSchema = z.object({ event: z.string(), event_type: z.object({ uri: z.string(), }), invitee: z.object({ uri: z.string(), email: z.string(), }), scheduled_event: z.object({ uri: z.string
Expected output:processInviteeCreated first tries safeParse on the raw payload. If that fails, it falls back to @reaatech/structured-repair-core’s repair() to fix malformed JSON. verifyCalendlySignature computes HMAC-SHA-256 and compares in constant time. filterCalendlyEvent delegates to matchEventType from @reaatech/webhook-relay-core.
Step 12: Create the agent handoff service
Create src/services/handoff-service.ts to escalate leads to human agents:
Expected output:requestHandoff builds a complete HandoffPayload with compressed context and session history, emits a handoff:requested event, and sends the payload with withRetry (exponential backoff, max 3 retries). shouldTriggerHandoff checks for handoff keywords in messages classified as "support" or "FALLBACK".
Step 13: Wire up observability with Langfuse
Create src/services/observability.ts for tracing and instrumentation:
Expected output: The Langfuse client is initialized inside a try/catch so missing or invalid keys don’t crash the module. All trace/span operations are similarly guarded — observability failures are always silent.
Step 14: Build the conversation orchestrator
Create src/services/conversation-service.ts — the central coordinator that ties every service together:
ts
import { SessionManager } from "@reaatech/session-continuity";import { InMemorySessionStore } from "./session-store.js";import { tokenCounter } from "./token-counter.js";import { LlmClient } from "./llm-client.js";import { scoreLeadIntent, getLeadScoreLabel } from "./lead-scorer.js";import { getNextQuestion, isQuestionnaireComplete, extractAnswer, buildQuestionnairePrompt,} from "./questionnaire.js";import { CalendlyClient } from "./calendly-client.js";import { HandoffService, shouldTriggerHandoff } from "./handoff-service.js";import { createTrace, createSpan, endSpan, endTrace } from "./observability.js";
Expected output: This is the brain of the application. The processMessage method:
Creates or retrieves a session via SessionManager
Scores the user’s intent with ConfidenceRouter
Checks for handoff requests
Extracts answer from the LLM for the current questionnaire step
Asks the next question, or completes the questionnaire
For hot leads: generates a Calendly scheduling link
For warm/cold leads: returns an appropriate message
Wraps everything in Langfuse traces and spans
Step 15: Create the API routes
Chat endpoint at app/api/chat/route.ts:
ts
import { type NextRequest, NextResponse } from "next/server";import { orchestrator } from "../../../src/services/conversation-service.js";export async function POST(req: NextRequest) { const body = (await req.json()) as { message?: string; sessionId?: string }; if (!body.message) { return NextResponse.json({ error: "message is required" }, { status: 400 }); } try { const response = await orchestrator.processMessage(body.sessionId, body.message); return NextResponse.json(response); } catch { return NextResponse.json({ error: "internal server error" }, { status: 500 }); }}
Calendly webhook endpoint at app/api/webhook/calendly/route.ts:
Expected output: The chat route accepts POST /api/chat with { message: string, sessionId?: string } and returns a ChatResponse. The webhook route accepts POST /api/webhook/calendly with Calendly’s invitee.created payload structure, validates the HMAC signature (when CALENDLY_WEBHOOK_SECRET is configured), filters for invitee.created events only, and updates the lead record.
Step 16: Run the tests
The project includes a comprehensive test suite. Run it to verify everything works:
terminal
pnpm test
Expected output:
numFailedTests: 0 (all 161 tests passing across 47 test suites)
numPassedTests: 161
Coverage: lines, branches, functions, and statements all at 90% or above on src/**/*.ts and app/**/route.ts
Run type checking and linting too:
terminal
pnpm typecheckpnpm lint
Both should exit 0 with no errors.
Next steps
Add a persistent database: Replace InMemorySessionStore with a PostgreSQL or Redis adapter that implements the same IStorageAdapter interface — SessionManager works with any backend
Improve lead scoring accuracy: Add an embedding-based classifier alongside the keyword classifier for semantic intent matching beyond exact keyword hits
Build a chat UI: Create a simple React client component that calls POST /api/chat with the user message and sessionId, rendering a conversational interface
Deploy to production: Use Azure App Service or a Docker container, set all environment variables, and configure Calendly webhook subscriptions pointing to your deployed endpoint
Add analytics: Feed Langfuse trace data into dashboards to track conversion rates per intent label, questionnaire drop-off points, and average time-to-booking