A recruiting coordinator at a 10-person firm juggles 30+ interviews per week, each requiring alignment between a candidate and 3-5 panelists. The constant email ping-pong to find mutual availability consumes 15+ hours weekly. The coordinator needs an agent that can negotiate calendars, send invites, and reschedule conflicts autonomously.
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 through the AI Interview Coordinator for Boutique Recruiting Firms, a voice-first agent that schedules interviews across candidates and panelists. You’ll build a Next.js 16 application using 6 REAA packages (agent-mesh, agent-mesh-router, agent-handoff, agent-handoff-routing, agent-memory, agent-memory-retrieval) plus Google Calendar, LiveKit for voice, OpenAI for LLM reasoning, and Langfuse for observability. By the end you’ll have a working agent that accepts scheduling requests, routes them to the right sub-agent, checks calendar availability, creates Google Calendar events, and serves everything through REST and MCP endpoints.
Prerequisites
Node.js >= 22 and pnpm >= 10 installed
A Google Cloud service account with Calendar API enabled (for calendar operations)
A LiveKit server or cloud account (for voice agent rooms)
An OpenAI API key with access to gpt-4o, text-embedding-3-small, and whisper-1/tts-1
(Optional) A Langfuse account for LLM observability
Familiarity with TypeScript, Next.js App Router, and basic Zod schemas
Step 1: Scaffold the project
Create a Next.js 16 project with the App Router, all dependencies exact-pinned in package.json, a tsconfig.json with strict: true and NodeNext module resolution, vitest.config.ts with 90% coverage thresholds, and eslint.config.mjs with flat config.
Expected output:src/lib/config.ts is created. When you run the app later, missing required env vars will throw a ZodError immediately — fail-fast at startup.
Step 3: Define domain types with Zod
Create src/lib/interview-types.ts. This file re-exports shared types from the REAA agent-mesh package and defines Zod schemas for the interview domain:
ts
import { IncomingRequestSchema, type IncomingRequest, AgentConfigSchema, type AgentConfig, AgentResponseSchema, type AgentResponse, ContextPacketSchema, type ContextPacket, TurnEntrySchema, type TurnEntry, SERVICE_NAME,} from "@reaatech/agent-mesh";import type { HandoffPayload, AgentCapabilities, RoutingDecision, HandoffConfig, HandoffRouter, Message } from "@reaatech/agent-handoff";export { IncomingRequestSchema, type IncomingRequest, AgentConfigSchema, type AgentConfig, AgentResponseSchema, type AgentResponse, ContextPacketSchema, type ContextPacket,
Expected output: Every domain entity — candidates, panelists, time slots, interviews, requests — is now backed by a Zod schema. The ScheduleRequestSchema enforces panelistIds to have at least one entry via .min(1).
Step 4: Create typed error classes
Error classes extend HandoffError from @reaatech/agent-handoff. Each carries a unique .code property so callers can discriminate errors without parsing message strings:
Expected output:src/lib/errors.ts defines 5 error classes. Each has a unique .code property (e.g. "calendar_auth_error", "interview_not_found") — the custom .code property is the discriminator used at catch sites.
Step 5: Build the Calendar adapter
The CalendarAdapter wraps Google Calendar API methods — freebusy.query, events.insert, events.patch, events.delete, and events.get. Every call is wrapped in withRetry from @reaatech/agent-handoff for resilience against transient failures:
ts
import { google } from "googleapis";import { config } from "../lib/config.js";import { CalendarAuthError, InterviewNotFoundError } from "../lib/errors.js";import { withRetry } from "@reaatech/agent-handoff";import type { TimeSlot, CandidateProfile, Panelist, CalendarEvent } from "../lib/interview-types.js";const RETRY_OPTIONS = { maxRetries: 3, backoff: "exponential" as const, baseDelayMs: 200, maxDelayMs: 5000, shouldRetry: (error: unknown) => error instanceof Error,
Expected output:src/services/calendar-adapter.ts is created with a fully working Google Calendar integration. The listAvailability method inverts busy periods into free slots and sorts them by start time.
Step 6: Set up the Agent Router with capability-based routing
The InterviewAgentRouter registers three sub-agents — scheduler, notifier, and followup — each with specific skills and domains. Incoming handoff payloads are routed to the best-matching agent using the CapabilityBasedRouter:
Expected output:src/services/agent-router.ts registers three sub-agents and provides a routeToAgent method that returns a RoutingDecision with type "primary", "clarification", or "fallback".
Step 7: Build the Memory Service for persistent context
The InterviewMemoryService wraps AgentMemory, MemoryRetriever, and ContextInjector into a domain-specific facade. It stores candidate preferences, retrieves relevant context before scheduling decisions, and runs periodic maintenance to decay stale memories:
Expected output:src/services/memory-service.ts creates an in-memory memory store (swap to postgres in production) with OpenAI embeddings and GPT-4o-mini extraction. The runMaintenance() method is called periodically from the instrumentation hook to decay stale preferences.
Step 8: Build the Interview Coordinator — the orchestration core
The InterviewCoordinator is the central orchestrator. It wires the calendar adapter, memory service, and agent router together. When a scheduleInterview request arrives, it:
Retrieves candidate context from memory
Builds a HandoffPayload and routes it through the agent router
On a "primary" decision, dispatches to the matched agent via dispatchToAgent
Validates the agent response with AgentResponseSchema
Creates a Google Calendar event via the adapter
Emits a typed event so external listeners can react
ts
import { dispatchToAgent, shouldCloseSession, getUpdatedWorkflowState, buildTurnEntry, formatAgentResponse,} from "@reaatech/agent-mesh-router";import { TypedEventEmitter } from "@reaatech/agent-handoff";import type { HandoffPayload, HandoffError,} from "@reaatech/agent-handoff";import { AgentResponseSchema, type AgentResponse, type AgentConfig,} from "@reaatech/agent-mesh";import { calendarAdapter as calAdapter } from "./calendar-adapter.js";import type { CalendarAdapter } from "./calendar-adapter.js"
Expected output:src/services/interview-coordinator.ts wires the entire orchestration. The exported singleton coordinator is ready to be consumed by API routes and the LiveKit voice agent.
Step 9: Create the LiveKit voice agent
The InterviewVoiceAgent connects to a LiveKit room, configures OpenAI for STT (whisper-1), LLM (gpt-4o), and TTS (tts-1 voice alloy), and exposes four LLM-callable tools — schedule_interview, reschedule_interview, cancel_interview, and check_availability:
ts
import { voice, llm } from "@livekit/agents";import { LLM as OpenAILlm, STT as OpenAISTT, TTS as OpenAITTS } from "@livekit/agents-plugin-openai";import { Room } from "@livekit/rtc-node";import { AccessToken } from "livekit-server-sdk";import { z } from "zod";import { config } from "../lib/config.js";import { coordinator } from "./interview-coordinator.js";import { ScheduleRequestSchema, TimeSlotSchema, DateRangeSchema, type TimeSlot, type ScheduleRequest, type AgentResponse,
Expected output:src/services/livekit-agent.ts creates a voice agent that a candidate or recruiter can speak to in a LiveKit room. The LLM decides which tool to call based on the conversation. Each tool’s parameters have their own Zod schema and type annotations — no any types.
Step 10: Add the Hono MCP server
The Hono app provides MCP-compatible endpoints (/mcp/schedule and /mcp/availability) that accept ContextPacket payloads from the @reaatech/agent-mesh system:
Create a Next.js catch-all route to forward requests to the Hono app:
ts
import { honoApp } from "../../../../src/services/hono-app.js";import { NextRequest, NextResponse } from "next/server";export async function GET(req: NextRequest): Promise<NextResponse> { const res = await honoApp.fetch(req); return new NextResponse(res.body, { status: res.status, statusText: res.statusText, headers: res.headers, });}export async function POST(req: NextRequest): Promise<NextResponse> { const res = await honoApp.fetch(req); return new NextResponse(res.body, { status: res.status, statusText: res.statusText, headers: res.headers, });}export async function PUT(req: NextRequest): Promise<NextResponse> { const res = await honoApp.fetch(req); return new NextResponse(res.body, { status: res.status, statusText: res.statusText, headers: res.headers, });}
Expected output: Two MCP endpoints at app/api/mcp/[...path]/route.ts that proxy to the Hono app. The Hono app parses ContextPacket payloads, extracts interview parameters, and delegates to the coordinator.
Step 11: Wire up the Next.js API routes
The REST API has four route groups: interview CRUD, availability check, and the LiveKit webhook. Each uses NextRequest and NextResponse.json() (never bare Request/Response).
Expected output: All API routes under app/api/ are wired. POST /api/interview returns 201 on success, 400 on validation failure, 409 on panelist unavailable. POST /api/availability returns intersecting free time slots. The webhook starts and stops voice agents on LiveKit room events.
Step 12: Add the instrumentation hook for startup initialization
src/instrumentation.ts runs at Next.js startup in the Node runtime. It validates env vars, initializes the OpenAI client and Langfuse, registers a SIGTERM handler for graceful MCP client shutdown, and schedules hourly memory maintenance:
Expected output:src/instrumentation.ts with the register() function. The next.config.ts sets experimental.instrumentationHook: true — exactly that spelling, otherwise the hook is dead code.
Step 13: Run the tests
The test suite uses Vitest with MSW for HTTP mocking. Every external dependency (Google Calendar, OpenAI, Langfuse, LiveKit) is mocked. Coverage must hit 90% across lines, branches, functions, and statements on runtime code.
Run the full suite:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output: TypeScript type-checks pass with no errors. ESLint passes with no violations. Vitest reports numFailedTests: 0 and numTotalTests >= 60 with all four coverage metrics at 90% or above.
Next steps
Swap the in-memory memory store for Postgres — change storage: { provider: "memory" } to storage: { provider: "postgres", connectionString: process.env.DATABASE_URL } in memory-service.ts for production persistence.
Add agent session monitoring — wire live traces into Langfuse by instrumenting the dispatchToAgent calls with traceLlmCall to see every scheduling decision end-to-end.
Extend the agent registry — register a scorer sub-agent that evaluates candidates against job requirements, then feed its output into the scheduling workflow.
TurnEntrySchema,
type TurnEntry,
SERVICE_NAME,
};
export type {
HandoffPayload,
AgentCapabilities,
RoutingDecision,
HandoffConfig,
HandoffRouter,
Message,
};
import { z } from "zod";
export const InterviewSlotSchema = z.object({
date: z.string(),
startTime: z.string(),
endTime: z.string(),
});
export type InterviewSlot = z.infer<typeof InterviewSlotSchema>;
export const CandidateProfileSchema = z.object({
candidateId: z.string(),
name: z.string(),
email: z.email(),
timezone: z.string(),
});
export type CandidateProfile = z.infer<typeof CandidateProfileSchema>;
export const PanelistSchema = z.object({
panelistId: z.string(),
name: z.string(),
email: z.email(),
calendarId: z.string(),
});
export type Panelist = z.infer<typeof PanelistSchema>;
export const DateRangeSchema = z.object({
start: z.string(),
end: z.string(),
});
export type DateRange = z.infer<typeof DateRangeSchema>;
export const ScheduleRequestSchema = z.object({
candidateId: z.string(),
panelistIds: z.array(z.string()).min(1),
durationMinutes: z.number().int().positive(),
dateRange: DateRangeSchema,
notes: z.string().optional(),
});
export type ScheduleRequest = z.infer<typeof ScheduleRequestSchema>;
export const AvailabilityQuerySchema = z.object({
candidateId: z.string(),
panelistIds: z.array(z.string()),
dateRange: DateRangeSchema,
});
export type AvailabilityQuery = z.infer<typeof AvailabilityQuerySchema>;