A recruiting coordinator at a 10-person firm juggles 40+ interview requests per week, each requiring alignment of candidate availability with 3-5 panelists. Manual email ping-pong causes delays, double-bookings, and lost candidates. The firm can't afford a full scheduling tool. The coordinator needs an agent that handles the entire negotiation and sends calendar invites.
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 Interview Coordination Agent — an agent that automates the back-and-forth of scheduling panel interviews. Given a candidate’s availability and a list of panelists, the agent checks Google Calendar free/busy data, negotiates overlapping time slots across multiple rounds, sends calendar invites with Google Meet links, and generates a runbook with alert and incident-response procedures. It uses capability-based routing to dispatch tasks to specialized sub-agents, stores conversation context in vector memory, and traces every LLM call and calendar operation through Langfuse. It’s built with Next.js (App Router), Hono for voice endpoints, and six @reaatech/* agent packages.
Prerequisites
Node.js 22+ and pnpm 10 installed.
A Google Cloud project with the Calendar API enabled, an OAuth 2.0 client ID, and a refresh token.
API keys for OpenAI, Deepgram, ElevenLabs, and Langfuse.
Familiarity with TypeScript, Next.js App Router routes, and basic agent-pattern concepts.
Step 1: Scaffold the project and configure environment variables
Create a new Next.js project and install the dependencies. The package.json pins every dependency to an exact version — no ^ or ~ ranges.
Expected output:pnpm install creates node_modules/ and pnpm-lock.yaml. The environment file is in place for the config module to read at runtime.
Step 2: Define the data types with Zod schemas
Create src/types/index.ts with Zod schemas that model the entire coordination domain: a time slot, an interview request, a panelist profile, a scheduling proposal, a final result, and conversation turns. It also exports an async schema loader and type re-exports from @reaatech/agent-mesh.
The route handler calls getInterviewSchema() to optionally validate incoming payloads against the agent-mesh schema. The StaticIncomingSchema provides a fallback for basic request shape validation.
Expected output: A clean tsc --noEmit pass. The Zod schemas are ready to validate runtime data at every API boundary.
Step 3: Create the config and observability modules
Create src/lib/config.ts to read all environment variables and validate them at startup:
Then create src/lib/langfuse.ts — a wrapper around the Langfuse SDK that creates traces and spans for LLM calls, calendar API operations, and agent handoff events:
Expected output: The config module reads every env var and the Langfuse module creates real traces with client.trace().
Step 4: Build the LLM service
Create src/lib/llm.ts with three functions: generate for plain text, streamResponse for streaming, and generateStructured for typed JSON output using generateObject from the Vercel AI SDK.
ts
import { generateText, streamText, tool } from "ai";import { openai } from "@ai-sdk/openai";import { config } from "./config.js";import { z } from "zod";export async function generate(prompt: string, system?: string) { try { const result = await generateText({ model: openai(config.openaiModel), system, prompt, }); return { text: result.text, usage: result.usage }; } catch (cause) { throw new Error("LLM call failed", { cause }); }}export function streamResponse(prompt: string, system?: string) { try { const result = streamText({ model: openai(config.openaiModel), system, prompt, }); return { textStream: result.textStream, fullStream: result.fullStream }; } catch (cause) { throw new Error("LLM call failed", { cause }); }}export async function generateStructured<T>(prompt: string, _schema: z.ZodType<T>, system?: string): Promise<T> { try { const ai = await import("ai"); const genObj = (ai as Record<string, unknown>).generateObject as (args: Record<string, unknown>) => Promise<{ object: T }>; const result = await genObj({ model: openai(config.openaiModel), schema: _schema, system, prompt, }); return result.object; } catch (cause) { throw new Error("LLM call failed", { cause }); }}export { tool };
The generateStructured function uses a dynamic import so the generateObject export resolves at runtime — this avoids module-resolution issues in test environments. The coordinator calls it to extract structured time preferences from free-form recruiter notes.
Expected output: You can call generate("Hello") and receive { text: "...", usage: {...} }. generateStructured returns typed objects matching your Zod schema.
Step 5: Wire the Google Calendar service
Create src/lib/calendar.ts to authenticate with Google, query free/busy data, create calendar events with Google Meet conferencing, and find common free slots across a list of panelists.
The findCommonSlots function fetches busy calendars for all panelists in parallel, then filters candidate time windows to those that don’t overlap with any panelist’s booked time and are long enough for the interview.
Expected output:getFreeBusy("user@example.com", new Date(), new Date()) returns an array of busy TimeSlot objects. findCommonSlots returns windows where nobody is booked.
Step 6: Create the agent memory module
Create src/lib/memory.ts that wraps @reaatech/agent-memory — it stores conversation turns as embeddings and retrieves past preferences for a given candidate or panelist.
Expected output:createInterviewMemory() returns an AgentMemory instance configured with OpenAI embeddings and LLM-based extraction of facts, preferences, and corrections from conversation turns.
Step 7: Implement the runbook generator
Create src/lib/runbook.ts to generate an operational runbook for each interview coordination session. It uses @reaatech/agent-runbook-agent to produce AI-generated alert configurations and incident-response procedures.
ts
import { type Runbook, type RunbookSection, AlertSeveritySchema, generateId, validateInput, AnalysisContextSchema } from "@reaatech/agent-runbook";import { createAnalysisAgent, generatePrompt, getPromptTemplate } from "@reaatech/agent-runbook-agent";import { config } from "./config.js";import type { InterviewRequest, Panelist } from "../types/index.js";export function createRunbookAgent() { return createAnalysisAgent({ provider: "openai", model: "gpt-4o-mini", apiKey: config.openaiApiKey, temperature: 0.3, });}export async function generateInterviewRunbook(
Expected output:generateInterviewRunbook(agent, request, panelists, "policy") returns a full Runbook object with four sections including AI-generated alert configs and incident-response steps.
Step 8: Set up the routing and handoff system
Create src/services/router.ts to define how the agent routes interview tasks. You’ll register three specialized agents — a scheduling specialist, a candidate coordinator, and a panelist coordinator — in an AgentRegistry and use CapabilityBasedRouter to dispatch handoff payloads.
Expected output:createAgentRegistry() returns a registry with three agents. routeInterviewRequest(router, registry, payload) routes a scheduling request through the capability-based router.
Step 9: Build the Interview Coordinator
Create src/services/coordinator.ts — the central orchestrator that takes an InterviewRequest, negotiates common time slots across up to 3 attempts (shifting the search window forward by 7-day increments each round), stores conversation history in memory, routes tasks via handoff, and confirms scheduling by creating calendar events for all participants.
ts
import { TypedEventEmitter, withRetry } from "@reaatech/agent-handoff";import type { HandoffPayload, HandoffContext } from "@reaatech/agent-handoff";import { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import type { AgentMemory } from "@reaatech/agent-memory";import { nanoid } from "nanoid";import { defaults } from "../lib/config.js";import { createEvent, findCommonSlots } from "../lib/calendar.js";import { storeConversation } from "../lib/memory.js";import { createRunbookAgent, generateInterviewRunbook } from "../lib/runbook.js";import { generateId } from "@reaatech/agent-runbook";import
When no common slots are found, the coordinator shifts the candidate’s date range by 24 hours per round and creates a fresh 7-day search window — rather than sliding the previous end date indefinitely. This keeps the search bounded while expanding the horizon.
Expected output:processNewRequest() returns a SchedulingProposal with matching slots or a fallback, and confirmScheduling() creates calendar events for every participant and returns a confirmed result with a Meet link.
Step 10: Wire the voice agent, Hono server, and App entry point
Create src/lib/voice-agent.ts for Deepgram speech-to-text and ElevenLabs text-to-speech:
ts
import { DeepgramClient, DeepgramError } from "@deepgram/sdk";import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";import { config } from "./config.js";import { generate } from "./llm.js";export function createVoiceAgent() { const deepgram = new DeepgramClient({ apiKey: config.deepgramApiKey }); const elevenlabs = new ElevenLabsClient({ apiKey: config.elevenLabsApiKey }); return { deepgram, elevenlabs };}export async function startListenSession(deepgram: DeepgramClient) { const connection = await deepgram.listen.v1.connect({
Create src/server/hono.ts — a thin Hono server that mounts a voice endpoint and a health check:
ts
import { Hono } from "hono";export function createHonoServer(port: number) { const app = new Hono(); app.get("/ws/voice", (c) => c.json({ message: "WebSocket endpoint" })); app.get("/health", (c) => c.json({ status: "ok" })); return { app, start(): Promise<{ port: number }> { console.log("Ready on " + String(port)); return Promise.resolve({ port }); }, stop(): Promise<void> { return Promise.resolve(); }, };}
Create src/index.ts — the application entry point that wires everything together. It provides a singleton App class, a standalone createCoordinator() factory for testing, and accessor functions:
ts
import { createInterviewMemory, closeMemory } from "./lib/memory.js";import { createCalendarClient } from "./lib/calendar.js";import { createLangfuseClient } from "./lib/langfuse.js";import { createInterviewRouter, createAgentRegistry, createHandoffOrchestrator } from "./services/router.js";import { InterviewCoordinator } from "./services/coordinator.js";import { TypedEventEmitter } from "@reaatech/agent-handoff";import { createRunbookAgent } from "./lib/runbook.js";import * as llm from "./lib/llm.js";import type { InterviewRequest, SchedulingProposal, SchedulingResult } from "./types/index.js";type CoordinatorEvents = { "coordination:started": { sessionId: string; request: InterviewRequest }; "coordination:proposed": { sessionId: string; proposal: SchedulingProposal }; "coordination:confirmed": { sessionId: string; result: SchedulingResult }; "coordination:failed": { sessionId: string; error: Error };};let appInstance: App | null = null;export class App { coordinator: InterviewCoordinator; registry: ReturnType<typeof createAgentRegistry>; memory: ReturnType<typeof createInterviewMemory>; langfuse: ReturnType<typeof createLangfuseClient>; constructor() { this.langfuse = createLangfuseClient(); this.memory = createInterviewMemory(); this.registry = createAgentRegistry(); const router = createInterviewRouter(); const { emitter: handoffEmitter } = createHandoffOrchestrator(); const coordinatorEmitter = new TypedEventEmitter<CoordinatorEvents>(); const runbook = createRunbookAgent(); const calendar = createCalendarClient(); this.coordinator = new InterviewCoordinator({ router, registry: this.registry, memory: this.memory, runbook, calendar, emitter: coordinatorEmitter, handoffEmitter, llm, }); } async shutdown() { await closeMemory(this.memory); this.langfuse.flush(); console.log("[shutdown] all resources released"); }}export function initializeApp(): App { if (!appInstance) { appInstance = new App(); } return appInstance;}export function createCoordinator(): InterviewCoordinator { const memory = createInterviewMemory(); const registry = createAgentRegistry(); const router = createInterviewRouter(); const { emitter: handoffEmitter } = createHandoffOrchestrator(); const coordinatorEmitter = new TypedEventEmitter<CoordinatorEvents>(); const runbook = createRunbookAgent(); const calendar = createCalendarClient(); return new InterviewCoordinator({ router, registry, memory, runbook, calendar, emitter: coordinatorEmitter, handoffEmitter, llm, });}export function getAgentMemory() { return appInstance?.memory ?? null;}export function getAgentRegistry() { return appInstance?.registry ?? null;}export type { InterviewRequest, SchedulingProposal, SchedulingResult, CoordinatorEvents };export { InterviewCoordinator, TypedEventEmitter };
Expected output:initializeApp() creates a singleton App whose coordinator field is ready to accept interview requests. createCoordinator() creates a standalone coordinator for testing.
Step 11: Create the Next.js API routes and middleware
Create app/api/interviews/route.ts — POST to start coordination and GET to list registered agents:
Expected output:curl -X POST http://localhost:3000/api/interviews with a valid JSON body returns 201 with a sessionId and proposal. curl http://localhost:3000/api/interviews returns the list of registered agents. curl -X POST http://localhost:3000/api/runbooks generates a runbook. All API responses include the X-Langfuse-Trace header.
Step 12: Run the tests
The test suite uses vitest with vi.mock to stub every external dependency — Google Calendar API, Deepgram, ElevenLabs, OpenAI, Langfuse, and all six @reaatech/* packages — so the tests run entirely offline. Run the full suite:
terminal
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: All tests pass and the coverage report shows coverage on the src/ runtime code. The test files cover:
tests/lib/calendar.test.ts — creates a calendar client, finds common slots, creates events, checks free/busy queries.
tests/lib/llm.test.ts — calls generate and generateStructured for successful responses, API errors, and stream iterations.
tests/lib/memory.test.ts — stores and retrieves conversation turns through AgentMemory.
tests/services/coordinator.test.ts — full processNewRequest cycle, negotiation with found slots, fallback when empty, and confirmScheduling.
tests/services/coordinator-negotiate.test.ts — boundary cases: no common slots, calendar API failure, LLM extraction failure.
tests/services/router.test.ts — agent registration, routing, and handoff execution.
tests/lib/runbook.test.ts — runbook generation with section content.
tests/app/api/interviews.test.ts — POST and GET route handlers for the interviews endpoint.
tests/app/api/interviews-id.test.ts — session retrieval and accept/reject actions.
Next steps
Add candidate confirmation via voice — wire the Deepgram transcription and ElevenLabs synthesis into the /ws/voice endpoint so the coordinator can negotiate with a candidate over a voice call instead of a REST API.
Integrate ATS data sources — extend the Panelist type to import availability from Greenhouse, Lever, or Ashby via their APIs, then adjust findCommonSlots to also consider ATS-sourced constraints.
Add re-negotiation support — the current POST /api/interviews/:id with action: "reject" returns a 400. Implement logic to re-enter the negotiation loop with adjusted parameters (wider date range, shorter duration, or substitute panelists).