Small law firms using Clio still manually copy lead info from web forms and emails, often duplicating contacts and losing track of follow‑ups, which slows client onboarding.
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 legal lead intake system powered by Mistral AI that integrates with Clio’s REST API. Your app will run a conversational chat session that asks intake questions, extracts structured lead fields from the conversation, detects duplicate leads using hybrid vector + BM25 retrieval, handles document uploads (PDFs and images), and pushes qualified leads to Clio as contacts and matters. By the end, you’ll have a working Next.js app with a chat API, document upload endpoint, duplicate warnings, qualification scoring, and Clio OAuth2 sync — all instrumented with Langfuse observability.
Prerequisites
Node.js >= 22 and pnpm 10.x (the project uses pnpm@10.0.0)
A Mistral AI API key for the conversational model
An OpenAI API key for text embeddings (used by the REAA hybrid-rag stack)
A Langfuse account for observability (public + secret keys, host URL)
A Qdrant instance URL for vector storage (used by duplicate detection)
Familiarity with TypeScript and Next.js App Router conventions
Step 1: Scaffold the project and install dependencies
Create the Next.js project and install all the dependencies you’ll need. The project uses the App Router (app/ directory) and pnpm as its package manager.
Expected output: pnpm resolves the lockfile and prints a success summary with all packages installed.
Step 2: Configure environment variables
Create .env.local with the credentials each integration needs. The app reads these at runtime from process.env.
env
# Env vars used by mistral-ai-lead-intake-for-clio-legal-client-onboarding.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentMISTRAL_API_KEY=<your-mistral-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=https://cloud.langfuse.comOPENAI_API_KEY=<your-openai-key>QDRANT_URL=<your-qdrant-url>QDRANT_COLLECTION_NAME=leadsCLIO_CLIENT_ID=<your-clio-client-id>CLIO_CLIENT_SECRET=<your-clio-client-secret>CLIO_REDIRECT_URI=http://localhost:3000/api/clio/callback
Also create a .env.example with the same placeholders so other developers know what to configure.
Step 3: Define the domain types and Zod schemas
Create src/types.ts with the core interfaces your services will use. This file defines the shape of every lead, chat message, document result, and API response in the system.
Expected output: TypeScript compiles these without errors. The Zod schemas use .strict() so extra fields are rejected at validation time.
Step 4: Create the AGENTS.md configuration loader
The app reads its agent persona from an AGENTS.md file at the project root. Create src/lib/agents-md.ts to parse the YAML frontmatter and extract the system prompt body using the @reaatech/agents-markdown package.
ts
import { type AgentsMdFrontmatter, AgentsMdFrontmatterSchema } from "@reaatech/agents-markdown";import { readFileSync } from "node:fs";export function loadAgentConfig(): AgentsMdFrontmatter { const raw = readFileSync("./AGENTS.md", "utf-8"); const lines = raw.split("\n"); const firstFence = lines.findIndex((l) => l.trim() === "---"); if (firstFence === -1) { throw new Error("No frontmatter delimiter found in AGENTS.md"); } const secondFence = lines.findIndex((l, i) => i > firstFence && l.trim() === "---"); if (secondFence === -1) { throw new Error("Unclosed frontmatter delimiter in AGENTS.md"); } const yamlLines = lines.slice(firstFence + 1, secondFence); const yamlText = yamlLines.join("\n"); const parsed = AgentsMdFrontmatterSchema.safeParse({ agent_id: extractYamlValue(yamlText, "agent_id"), display_name: extractYamlValue(yamlText, "display_name"), version: extractYamlValue(yamlText, "version"), description: extractYamlValue(yamlText, "description"), }); if (!parsed.success) { throw new Error( `Invalid AGENTS.md frontmatter: ${parsed.error.issues.map((i) => i.message).join(", ")}`, ); } return parsed.data;}export function getSystemPrompt(): string { const raw = readFileSync("./AGENTS.md", "utf-8"); const parts = raw.split("---"); if (parts.length < 3) { throw new Error("AGENTS.md must have frontmatter delimited by ---"); } return parts.slice(2).join("---").trim();}function extractYamlValue(yaml: string, key: string): string | undefined { const regex = new RegExp(`^${key}:\\s*(.+)$`, "m"); const match = yaml.match(regex); return match ? match[1].trim() : undefined;}
Create AGENTS.md at the project root:
md
---agent_id: legal-intake-agentdisplay_name: Legal Intake Assistantdescription: Conversational intake qualifier for legal client onboardingversion: 1.0.0---You are a legal intake assistant for a law firm. Your goal is to gather information about a potential client's legal issue. Ask structured questions to collect:1. The client's full name2. Their email address3. Their phone number (optional)4. The type of legal case they need help with5. A description of their legal issueBe professional and empathetic. Do not provide legal advice.
Step 5: Build the Mistral AI chat service
Create src/lib/mistral.ts — the core service that wraps the @mistralai/mistralai SDK. It supports chat completion, streaming, lead-field extraction, and lead qualification scoring.
ts
import { Mistral } from "@mistralai/mistralai";import { MistralError } from "@mistralai/mistralai/models/errors";import type { ChatMessage } from "../types.js";import { LeadIntakeSchema } from "../types.js";export type MistralTool = { type: "function"; function: { name: string; description: string; parameters: Record<string, unknown>; };};export class MistralChatService {
Key points:
The constructor picks up MISTRAL_API_KEY from the environment by default.
chat() accepts an optional tools array — you’ll use this to pass intake qualification tool definitions to the model.
How it works:HybridRetriever combines Qdrant vector search (with OpenAI embeddings) and an in-process BM25 keyword index, fusing results with Reciprocal Rank Fusion (RRF). If Qdrant is unreachable, initialize() sets ready = false and checkDuplicate() returns a safe fallback instead of crashing.
Step 7: Build the document ingestion service
Create src/lib/ingest.ts to handle PDF and image uploads. It uses unpdf for PDF text extraction and tesseract.js for OCR on images, then chunks the content with the REAA ingestion library.
Create src/lib/clio.ts — a full OAuth2 client for the Clio REST API. It handles authorization URL generation, token exchange, contact creation, matter creation, and a pushLead() method that deduplicates by email before creating.
ts
import { type LeadIntake, ClioApiError, type ClioContact, type ClioMatter, type ClioToken } from "../types.js";export { ClioApiError };export class ClioService { private baseUrl = "https://app.clio.com"; getAuthUrl(state: string): string { const clientId = process.env.CLIO_CLIENT_ID ?? ""; const redirectUri = process.env.CLIO_REDIRECT_URI ?? ""; return `${this.baseUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&state=${
The service includes OAuth2 methods, searchContactByEmail() for deduplication, createContact() (splits name into first/last), createMatter(), refreshToken(), and proxyRequest() — all with proper error handling via ClioApiError.
Step 9: Wire up Langfuse observability
Create src/lib/observability.ts to trace every chat session through Langfuse:
If Langfuse keys aren’t configured, the module returns null from every function — the app keeps working without crashing.
Step 10: Create the API route handlers
The app has six API routes under app/api/. Here are the four most important ones.
app/api/chat/route.ts — the conversational intake endpoint:
ts
import { type NextRequest, NextResponse } from "next/server";import { randomUUID } from "node:crypto";import { z } from "zod";import { MistralError } from "@mistralai/mistralai/models/errors";import { ChatMessageSchema } from "../../../src/types.js";import { getSystemPrompt } from "../../../src/lib/agents-md.js";import { MistralChatService } from "../../../src/lib/mistral.js";import type { MistralTool } from "../../../src/lib/mistral.js";import { DedupService } from "../../../src/lib/dedup.js";import { startTrace, endTrace } from "../../../src/lib/observability.js";
app/api/upload/route.ts — handles document uploads (PDF/image) and returns extracted text, chunks, lead fields, and dedup results:
import { type NextRequest, NextResponse } from "next/server";import { DedupRequestSchema } from "../../../src/types.js";import { DedupService } from "../../../src/lib/dedup.js";export async function POST(req: NextRequest) { try { const parsed = DedupRequestSchema.safeParse(await req.json()); if (!parsed.success) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } const { text, threshold } = parsed.data; const dedupService = new DedupService(); await dedupService.initialize(); const result = await dedupService.checkDuplicate(text, threshold ?? 0.85); return NextResponse.json(result); } catch { return NextResponse.json({ error: "Internal server error" }, { status: 500 }); }}
The Clio routes live under app/api/clio/auth/route.ts, app/api/clio/callback/route.ts, and app/api/clio/push-lead/route.ts. They handle OAuth2 URL generation, token exchange, and push-lead-to-Clio respectively. Each route validates inputs with Zod, instantiates ClioService, and returns NextResponse.json() with appropriate error handling for ClioApiError and 401 token expiry.
Step 11: Create the barrel exports
Create src/index.ts to re-export every public API from a single entry point:
ts
export { MistralChatService } from "./lib/mistral.js";export type { MistralTool } from "./lib/mistral.js";export { IngestionService } from "./lib/ingest.js";export { DedupService } from "./lib/dedup.js";export { ClioService } from "./lib/clio.js";export { ClioApiError } from "./types.js";export { initObservability, startTrace, endTrace } from "./lib/observability.js";export { loadAgentConfig, getSystemPrompt } from "./lib/agents-md.js";export { getEmbedder, embedText, embedBatch } from "./lib/embedding.js";export type { LeadIntake, LeadIntakeResult, DedupResult, ChatMessage, ChatSession, DocumentUploadResult, ClioContact, ClioMatter, ClioToken,} from "./types.js";export { LeadIntakeSchema, ChatMessageSchema, DedupRequestSchema,} from "./types.js";
Step 12: Run the tests
The project includes 115 tests across 37 test suites covering every service, route handler, and integration path. Run them with:
terminal
pnpm test
Expected output: All 115 tests pass (0 failed). The coverage report shows 100% on lines, statements, and functions, with 95.1% branch coverage for runtime code (src/**/*.ts and app/**/route.ts). Example test output:
Each service test mocks external dependencies — Mistral HTTP calls via vi.mock, Clio HTTP calls via MSW, and the REAA packages via vi.mock — so tests are fast and deterministic.
Next steps
Add streaming to the chat API — MistralChatService already has a chatStream() method. Wire it to ReadableStream or Server-Sent Events for real-time message generation.
Deploy the Qdrant index — the DedupService defaults to graceful fallback when Qdrant is unreachable. Deploy a Qdrant instance (or use Qdrant Cloud) and set QDRANT_URL to enable live duplicate detection.
Add automatic Clio token refresh — the ClioService.refreshToken() method exists but isn’t called automatically in the push-lead route. Add automatic token refresh on 401 responses from the Clio API.
"Extract lead fields from the following text. Return ONLY valid JSON with keys: name, email, phone, caseType, description. Do not include any other text.",
},
{ role: "user", content: text },
],
responseFormat: { type: "json_object" },
});
const msg = result.choices[0]?.message;
if (typeof msg?.content !== "string") {
return {};
}
const parsed = JSON.parse(msg.content) as Record<string, unknown>;