The admissions coordinator at a tutoring center receives dozens of family inquiries daily via web forms, phone, and email. Each inquiry requires manually assessing the student's needs, checking instructor availability, and finding the best fit. This process is slow, error-prone, and often leads to lost leads or mismatched pairings. The coordinator spends hours on triage instead of focusing on student success.
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 builds an automated family inquiry to instructor match agent for tutoring centers. When a parent submits an inquiry (student name, subject, grade level, language preference), the system classifies the request using an LLM-powered classifier, scores every available instructor against the inquiry, selects the best match, creates a persistent session, and dispatches the inquiry to the matched instructor over MCP. You’ll wire six REAA packages into a Next.js + Fastify dual-stack architecture with high test coverage.
Prerequisites
Node.js 22+ and pnpm 10 installed
A Google Cloud project with Firestore (the REAA packages use these for session storage — agent-mesh-classifier includes a mock fallback for local dev)
Familiarity with TypeScript, Next.js App Router, and Fastify route declarations
An .env file with these placeholders from .env.example:
Expected output: TypeScript compiles education-tutoring.ts without errors. The default("en") on languagePreference means empty input automatically falls back to English.
Now create a barrel export:
ts
// src/types/index.tsexport { InstructorProfileSchema, type InstructorProfile, FamilyInquirySchema, type FamilyInquiry, MatchResultSchema, type MatchResult,} from "./education-tutoring.js";export { type AgentConfig, type IncomingRequest, type AgentResponse, type ContextPacket, type ClassifierOutput, type SessionRecord, type TurnEntry, type ConfidenceDecision, type SessionStatus, SERVICE_NAME, CONFIDENCE, SESSION,} from "@reaatech/agent-mesh";
Step 3: Create the instructor registry
The registry defines five MCP-backed instructor agents. Each agent has a type: "mcp" endpoint, a description, example phrases for subject matching, and a confidence threshold.
ts
// src/services/registry.tsimport { type AgentConfig } from "@reaatech/agent-mesh";export const instructorRegistry: AgentConfig[] = [ { agent_id: "math-specialist", display_name: "Math Specialist", description: "Expert in algebra, geometry, calculus, trigonometry, and STEM subjects for grades 6-12", endpoint: "http://localhost:8081/mcp", type: "mcp" as const, is_default: false, clarification_required: false, confidence_threshold: 0.7, examples: [ "I need help with quadratic equations", "My daughter is struggling with calculus derivatives",
Expected output: The registry compiles. getInstructorBySubject("algebra") returns [math-specialist] because its description contains “algebra”.
Step 4: Build the matching service
The matching service scores instructors against an inquiry. It checks subject overlap, grade-level keywords, language preference, and classifier confidence.
ts
// src/services/matching-service.tsimport { type FamilyInquiry, type MatchResult } from "../types/index.js";import { type AgentConfig, type ClassifierOutput } from "../types/index.js";import { instructorRegistry, getInstructorBySubject } from "./registry.js";function calculateMatchScore(inquiry: FamilyInquiry, instructor: AgentConfig): number { const subject = inquiry.subject.toLowerCase(); const grade = inquiry.gradeLevel.toLowerCase(); const lang = inquiry.languagePreference.toLowerCase(); const desc = instructor.description.toLowerCase(); const allExamples = instructor.examples.join(" ").toLowerCase(); let score = 0; if (desc.includes(subject) || allExamples.includes(subject)) { score += 0.4; } const gradeKeywords = grade.match(/\d+/g); if (gradeKeywords && (desc.includes(`grade ${gradeKeywords[0]}`) || desc.includes(`k-${gradeKeywords[0]}`))) { score += 0.2; } if (instructor.agent_id === "language-specialist" && lang !== "en") { score += 0.2; } else if (lang === "en") { score += 0.1; } const specialtyOverlap = instructor.examples.length / 5; score += Math.min(specialtyOverlap, 0.2); return Math.max(0, Math.min(score, 1));}export function rankInstructors(inquiry: FamilyInquiry, classification?: ClassifierOutput): MatchResult[] { const candidates = getInstructorBySubject(inquiry.subject); const list = candidates.length > 0 ? candidates : instructorRegistry; const scored = list.map((instructor) => { let score = calculateMatchScore(inquiry, instructor); let confidence = score; if (classification) { if (instructor.agent_id === classification.agent_id) { score += 0.1; confidence = classification.confidence; } } return { instructorId: instructor.agent_id, instructorName: instructor.display_name, matchScore: Math.min(score, 1), rationale: `Match score ${(Math.min(score, 1) * 100).toFixed(0)}% based on subject alignment${classification && instructor.agent_id === classification.agent_id ? " and classifier confidence" : ""}`, confidence, }; }); scored.sort((a, b) => b.matchScore - a.matchScore); return scored.slice(0, 3);}
Expected output: A “Math Specialist” ranks highest for subject “algebra” with a match score of at least 0.4 (subject matching) plus possible grade and example overlap bonuses. When the classifier matches the instructor, the rationale string appends “and classifier confidence”.
Step 5: Build the classifier service
The classifier wraps @reaatech/agent-mesh-classifier. It calls the LLM-based classifierService.classify() and includes rate-limit recovery (the REAA classifier falls back to a deterministic mock when the upstream LLM is rate-limited).
ts
// src/services/classifier-service.tsimport { classifierService, detectLanguage, isRateLimitError, isValidLanguageCode, getClarificationQuestion } from "@reaatech/agent-mesh-classifier";export { getClarificationQuestion };import { type FamilyInquiry } from "../types/index.js";import { type ClassifierOutput } from "../types/index.js";import { instructorRegistry } from "./registry.js";import { logger } from "../lib/observability.js";export async function classifyInquiry(inquiry: FamilyInquiry, priorLanguage?: string): Promise<ClassifierOutput> { const inputText = `${inquiry.studentName} ${inquiry.gradeLevel} ${inquiry.subject} ${inquiry.learningGoals || ""}`.trim(); try { return await classifierService.classify(inputText, instructorRegistry, priorLanguage || inquiry.languagePreference); } catch (error) { if (isRateLimitError(error)) { logger.warn("Gemini rate-limited; classifierService will fall back to mock classifier"); return await classifierService.classify(inputText, instructorRegistry, priorLanguage || inquiry.languagePreference); } throw error; }}export function detectInquiryLanguage(inquiry: FamilyInquiry): string { const detected = detectLanguage(inquiry.studentName); if (isValidLanguageCode(detected)) { return detected; } return "en";}
Expected output:detectInquiryLanguage({ studentName: "María" }) returns "es" when the name is detected as Spanish. Unknown languages fall back to "en".
Step 6: Build the session service
The session service wraps @reaatech/agent-mesh-session. It reuses an existing active session when one exists, creates a new session otherwise, and manages turn history.
Expected output: Calling ensureSession("bob@example.com", "bob@example.com", "math-specialist") returns a SessionRecord with status "active" and an empty turn_history.
Step 7: Build the dispatch service
The dispatch service routes an inquiry to a matched instructor via @reaatech/agent-mesh-router’s dispatchToAgent() and handles the response by checking shouldCloseSession().
Expected output: When dispatchToAgent throws an error like "Circuit breaker OPEN", the error name is recorded via recordAgentDispatchError and the error is re-thrown.
Step 8: Build the observability wrapper
Create a thin wrapper over @reaatech/agent-mesh-observability that provides request-scoped loggers and dispatch metric helpers.
Expected output: The module exports SERVICE_NAME, initOtel, shutdownOtel, createRequestLogger, and all the metric recorders.
Step 9: Build the orchestrator
The orchestrator is the main pipeline. It validates the inquiry, detects language, classifies it with the LLM, ranks instructors, selects the best match (with a confidence >= 0.3 threshold and fallbacks), creates a session, dispatches the inquiry, and finalizes the session if the workflow is complete.
ts
// src/services/orchestrator.tsimport { AgentResponseSchema, type AgentResponse } from "@reaatech/agent-mesh";import { FamilyInquirySchema } from "../types/index.js";import { createRequestLogger, logAgentRouted } from "../lib/observability.js";import { ensureSession, addTurn, finalizeSession, getSession } from "./session-service.js";import { classifyInquiry, detectInquiryLanguage } from "./classifier-service.js";import { routeToInstructor, handleDispatchResponse } from "./dispatch-service.js";import { rankInstructors } from "./matching-service.js";import { instructorRegistry, getInstructorById } from "./registry.js";export async function processInquiry(raw: unknown)
Expected output:processInquiry parses the input through Zod, runs the full pipeline, and returns an AgentResponse with content from the matched instructor.
Step 10: Wire the Fastify server with MCP middleware
The Fastify server mounts MCP middleware from @reaatech/agent-mesh-mcp-server and session middleware from @reaatech/agent-mesh-session, then registers three API route groups.
ts
// src/server.tsimport { type Request, type Response } from "express";import Fastify, { type FastifyRequest, type FastifyReply } from "fastify";import fastifyExpress from "@fastify/express";import { mcpMiddleware, sseHandler, messageHandler } from "@reaatech/agent-mesh-mcp-server";import { mcpClientFactory } from "@reaatech/agent-mesh-router";import { sessionMiddleware } from "@reaatech/agent-mesh-session";import { SERVICE_NAME, shutdownOtel } from "./lib/observability.js";import inquiryRoutes from "./api/inquiry.js";import sessionRoutes from "./api/session.js";import instructorRoutes from "./api/instructors.js";const app = Fastify({ logger: true });async function start() { await app.register(fastifyExpress); app.use(mcpMiddleware); app.use(sessionMiddleware); app.get("/mcp/sse", (req: FastifyRequest, reply: FastifyReply) => sseHandler(req.raw as Request, reply.raw as Response)); app.post("/mcp/messages", (req: FastifyRequest, reply: FastifyReply) => messageHandler(req.raw as Request, reply.raw as Response)); app.get("/health", () => ({ status: "ok", service: SERVICE_NAME, uptime: process.uptime() })); await app.register(inquiryRoutes); await app.register(sessionRoutes); await app.register(instructorRoutes); const shutdown = async () => { await mcpClientFactory.closeAll(); await shutdownOtel(); await app.close(); }; process.on("SIGTERM", () => { void shutdown(); }); process.on("SIGINT", () => { void shutdown(); });}export default app;export { start };
Now create the three Fastify route plugins:
ts
// src/api/inquiry.tsimport { type FastifyInstance, type FastifyRequest, type FastifyReply } from "fastify";import { FamilyInquirySchema } from "../types/index.js";import { processInquiry } from "../services/orchestrator.js";export default function inquiryRoutes(app: FastifyInstance) { app.post("/api/inquiry", async (req: FastifyRequest, reply: FastifyReply) => { const parsed = FamilyInquirySchema.safeParse(req.body); if (!parsed.success) { return reply.status(400).send({ error: "Invalid inquiry", details: parsed.error.issues }); } try { const result = await processInquiry(req.body); return await reply.status(200).send(result); } catch (error) { req.log.error(error, "Inquiry processing failed"); return reply.status(500).send({ error: "Processing failed" }); } });}
ts
// src/api/session.tsimport { type FastifyInstance, type FastifyRequest, type FastifyReply } from "fastify";import { getSessionStatus } from "../services/orchestrator.js";export default function sessionRoutes(app: FastifyInstance) { app.get<{ Params: { sessionId: string } }>("/api/session/:sessionId", async (req: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => { const session = await getSessionStatus(req.params.sessionId); if (!session) { return reply.status(404).send({ error: "Session not found" }); } return reply.status(200).send(session); });}
ts
// src/api/instructors.tsimport { type FastifyInstance, type FastifyRequest, type FastifyReply } from "fastify";import { listAvailableInstructors } from "../services/orchestrator.js";export default function instructorRoutes(app: FastifyInstance) { app.get("/api/instructors", (_req: FastifyRequest, reply: FastifyReply) => { const instructors = listAvailableInstructors(); return reply.status(200).send(instructors); });}
Expected output:pnpm typecheck passes with no errors. The server exports a Fastify instance and a start() function.
Step 11: Create the Next.js route handlers and instrumentation
Add the Next.js API routes that mirror the Fastify endpoints. The Next.js routes import directly from the orchestrator so the web UI can submit inquiries.
First, create a small JSON parsing helper that the route handler will use:
ts
// app/_lib/parse-json.tsexport function parseJson(text: string): unknown { return JSON.parse(text);}
Enable the instrumentation hook in next.config.ts:
// app/api/inquiry/route.tsimport { NextRequest, NextResponse } from "next/server";import { FamilyInquirySchema } from "../../../src/types/index.js";import { processInquiry } from "../../../src/services/orchestrator.js";import { parseJson } from "../../_lib/parse-json.js";export async function POST(req: NextRequest) { try { const body = parseJson(await req.text()) as Record<string, unknown>; const parsed = FamilyInquirySchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: "Invalid inquiry", details: parsed.error.issues }, { status: 400 }); } const result = await processInquiry(body); return NextResponse.json(result); } catch (error) { console.error("Inquiry processing failed:", error); return NextResponse.json({ error: "Processing failed" }, { status: 500 }); }}
Create a health endpoint:
ts
// app/api/health/route.tsimport { NextResponse } from "next/server";import { SERVICE_NAME } from "../../../src/lib/observability.js";export function GET() { return NextResponse.json({ status: "ok", service: SERVICE_NAME });}
Add a barrel export for the public API:
ts
// src/index.tsexport { processInquiry, getSessionStatus, listAvailableInstructors } from "./services/orchestrator.js";
Expected output: A GET to /api/health returns {"status":"ok","service":"agnostic-family-inquiry-to-match-agent-2"}.
Step 12: Build the UI page
Replace the default Next.js home page with a family inquiry form. The form collects student name, parent name, contact email, subject, grade level, optional schedule preference, and optional learning goals, then POSTs to /api/inquiry.
Expected output: Opening http://localhost:3000 shows a form with inputs for student name, parent name, email, subject dropdown, grade dropdown, optional schedule preference, optional learning goals, and a “Submit Inquiry” button.
Step 13: Run the tests
Write tests for the orchestrator pipeline, the type schemas, each service in isolation, the Next.js route handlers, and the Fastify API routes. Every test mocks external dependencies via vi.mock so no live HTTP calls are made.
Run the full test suite with coverage:
terminal
pnpm test
Expected output: All tests pass (the recipe ships with 74 passing tests across 47 suites), and coverage thresholds of 90%+ on lines, branches, functions, and statements are met for all runtime code under src/ and app/**/route.ts.
Here’s an example test for the inquiry API route:
ts
// tests/app/api/inquiry.test.tsimport { describe, it, expect, vi } from "vitest";import { NextRequest, NextResponse } from "next/server";vi.mock("../../../src/services/orchestrator.js", () => ({ processInquiry: vi.fn(),}));vi.mock("@reaatech/agent-mesh", () => ({ SERVICE_NAME: "test-service", CONFIDENCE: {}, SESSION: {},}));import { POST } from "../../../app/api/inquiry/route.js";import { processInquiry } from "../../../src/services/orchestrator.js";const validInquiry = { studentName: "Alice", parentName: "Bob", contactEmail: "bob@example.com", subject: "math", gradeLevel: "5", languagePreference: "en",};describe("Next.js POST /api/inquiry", () => { it("returns 200 for valid inquiry", async () => { vi.mocked(processInquiry).mockResolvedValue({ content: "I can help with math!", workflow_complete: false, }); const req = new NextRequest("http://localhost/api/inquiry", { method: "POST", body: JSON.stringify(validInquiry), }); const res: NextResponse = await POST(req); expect(res.status).toBe(200); const body = await res.json() as Record<string, unknown>; expect(body.content).toBe("I can help with math!"); }); it("returns 400 for empty body", async () => { const req = new NextRequest("http://localhost/api/inquiry", { method: "POST", body: "{}", }); const res: NextResponse = await POST(req); expect(res.status).toBe(400); const body = await res.json() as Record<string, unknown>; expect(body.error).toBe("Invalid inquiry"); }); it("returns 500 when processInquiry rejects", async () => { vi.mocked(processInquiry).mockRejectedValue(new Error("Crash")); const req = new NextRequest("http://localhost/api/inquiry", { method: "POST", body: JSON.stringify(validInquiry), }); const res: NextResponse = await POST(req); expect(res.status).toBe(500); const body = await res.json() as Record<string, unknown>; expect(body.error).toBe("Processing failed"); });});
Next steps
Add clarification workflows: When classifier confidence is low or the inquiry is ambiguous, use getClarificationQuestion from the classifier service to ask the parent follow-up questions before routing.
Integrate with real MCP agent endpoints: Replace the registry’s http://localhost:8081/mcp URLs with production MCP servers backed by actual tutoring instructors.
Add a dashboard view: Build a Next.js page at /admin/sessions that lists active sessions, their matched instructors, and allows manual re-routing.
"Looking for geometry tutoring for 10th grade"
,
],
},
{
agent_id: "language-specialist",
display_name: "Language Specialist",
description: "Expert in ESL, Spanish, French, Mandarin, and foreign language tutoring for all grades",
endpoint: "http://localhost:8082/mcp",
type: "mcp" as const,
is_default: false,
clarification_required: false,
confidence_threshold: 0.7,
examples: [
"Need Spanish tutoring for beginner",
"Looking for ESL help for my son who just moved here",
"French conversation practice for high school student",
],
},
{
agent_id: "test-prep-coach",
display_name: "Test Prep Coach",
description: "SAT, ACT, SSAT, ISEE, AP exam preparation and test-taking strategies",
endpoint: "http://localhost:8083/mcp",
type: "mcp" as const,
is_default: false,
clarification_required: false,
confidence_threshold: 0.7,
examples: [
"SAT math prep needed urgently",
"Looking for ACT tutoring for junior year",
"AP Biology exam preparation help",
],
},
{
agent_id: "writing-tutor",
display_name: "Writing Tutor",
description: "Expert in essays, composition, literature analysis, creative writing, and college application essays",
endpoint: "http://localhost:8084/mcp",
type: "mcp" as const,
is_default: false,
clarification_required: false,
confidence_threshold: 0.7,
examples: [
"Help with college application essay",
"My child needs help with literary analysis essays",
"Creative writing workshop for middle schooler",
],
},
{
agent_id: "elementary-generalist",
display_name: "Elementary Generalist",
description: "K-8 all-subjects tutoring including reading, writing, math, science, and social studies",
endpoint: "http://localhost:8085/mcp",
type: "mcp" as const,
is_default: false,
clarification_required: false,
confidence_threshold: 0.7,
examples: [
"Reading help for 2nd grader",
"Elementary math homework support",
"Science project guidance for 5th grade",
],
},
];
export function getInstructorById(id: string): AgentConfig | undefined {