The restaurant manager or host at a small hospitality group dreads the nightly rush of last-minute cancellations and no-shows. They manually call through a waitlist, often too late to fill the table. This reactive process loses revenue every shift, especially during peak hours. With no tech budget for expensive reservation systems, they rely on spreadsheets and gut feel. The manager needs a predictive system that automatically backfills cancellations by contacting waitlisted guests via SMS or voice, maximizing table turns without extra labor.
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 a Predictive Reservation Cancellation Backfill system for small hospitality groups. When a guest cancels or no-shows, the system automatically predicts cancellation risk for upcoming reservations, activates your waitlist via SMS, and orchestrates voice calls — all through an AI voice-agent pipeline. You’ll wire six REAA packages (voice-agent-core, voice-agent-telephony, agent-mesh, agent-handoff, mcp-server-tools, llm-cache) into a Next.js 16 App Router project with a Hono WebSocket server, Twilio telephony, Langfuse observability, and the Vercel AI SDK.
Prerequisites
Node.js >= 22 and pnpm >= 10 installed
A Twilio account with an SMS-capable phone number and Voice API access
An OpenAI API key
A PostgreSQL database (or Vercel Postgres-compatible connection string)
A Langfuse account (optional — observability works without it)
Basic familiarity with TypeScript, Next.js App Router, and async patterns
Step 1: Scaffold the project and add dependencies
Start from an empty Next.js 16 App Router project shell. The scaffold agent has already created the root config files — you’ll add the runtime dependencies.
Open package.json and add the dependencies section with exact-pinned versions:
Expected output: pnpm resolves all 29 packages, writes pnpm-lock.yaml, and exits with no errors.
Step 2: Create the environment config
Write your .env.example with every environment variable the system reads:
env
# Env vars used by agnostic-reservation-cancellation-predictor.# Keep placeholders only — never commit real values.NODE_ENV=development# OpenAI (via @ai-sdk/openai for agnostic LLM provider)OPENAI_API_KEY=<your-openai-key># Twilio (SMS and voice notifications)TWILIO_ACCOUNT_SID=<your-twilio-account-sid>TWILIO_AUTH_TOKEN=<your-twilio-auth-token>TWILIO_PHONE_NUMBER=<your-twilio-phone># DatabasePOSTGRES_URL=<your-postgres-connection-string># Langfuse observabilityLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=<your-langfuse-base-url># Model configurationPREDICTION_MODEL=gpt-5.2-mini# LLM Cache configurationLLM_CACHE_EMBEDDING_MODEL=text-embedding-3-smallLLM_CACHE_SIMILARITY_THRESHOLD=0.8LLM_CACHE_DEFAULT_TTL=3600
Expected output: A .env.example file with 13 environment variables. No real secrets — only process.env.X references in the code.
Step 3: Build the typed configuration module
Create src/lib/config.ts that reads all environment variables and validates required ones at import time:
ts
export const config = { openaiApiKey: process.env.OPENAI_API_KEY ?? "", twilioAccountSid: process.env.TWILIO_ACCOUNT_SID ?? "", twilioAuthToken: process.env.TWILIO_AUTH_TOKEN ?? "", twilioPhoneNumber: process.env.TWILIO_PHONE_NUMBER ?? "", postgresUrl: process.env.POSTGRES_URL ?? "", langfusePublicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "", langfuseSecretKey: process.env.LANGFUSE_SECRET_KEY ?? "", langfuseBaseUrl: process.env.LANGFUSE_BASE_URL ?? "", predictionModel: process.env.PREDICTION_MODEL ?? "gpt-5.2-mini", embeddingModel: process.env.LLM_CACHE_EMBEDDING_MODEL ?? "text-embedding-3-small", similarityThreshold: Number(process.env.LLM_CACHE_SIMILARITY_THRESHOLD ?? "0.8"), defaultTTL: Number(process.env.LLM_CACHE_DEFAULT_TTL ?? "3600"),};if (!config.openaiApiKey) throw new Error("OPENAI_API_KEY is required");if (!config.twilioAccountSid) throw new Error("TWILIO_ACCOUNT_SID is required");if (!config.twilioAuthToken) throw new Error("TWILIO_AUTH_TOKEN is required");if (!config.postgresUrl) throw new Error("POSTGRES_URL is required");
Expected output: When imported and any of the four required env vars is missing, the module throws immediately at import time. In test environments you mock this module to supply values.
Step 4: Create the database layer
The database layer wraps @vercel/postgres with CRUD operations for three tables: reservations, waitlist, and cancellations.
Create src/lib/db.ts:
ts
import { sql, createPool } from "@vercel/postgres";import { config } from "./config";export const pool = createPool({ connectionString: config.postgresUrl });export { sql };export async function createTables(): Promise<void> { await pool.sql` CREATE TABLE IF NOT EXISTS reservations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), restaurant_id TEXT NOT NULL, guest_name TEXT NOT NULL, party_size INT NOT NULL, reservation_time TIMESTAMPTZ NOT NULL, status TEXT NOT NULL DEFAULT 'confirmed', phone TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); `;
Expected output: Eleven exported functions: createTables, getReservations, getReservationById, updateReservationStatus, getCancellationHistory, addToWaitlist, getWaitlistByRestaurant, markWaitlistNotified, recordCancellation, plus the pool and sql exports. All queries use Postgres parameterized syntax (${param}) to prevent injection.
Step 5: Wire LLM prediction with the Vercel AI SDK
Create src/lib/llm.ts with two functions that call OpenAI through @ai-sdk/openai. Each wraps the generateText call in try/catch and traces errors via Langfuse:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import { config } from "./config";import { getObservability } from "./observability";export class PredictionError extends Error { constructor(msg: string, public cause?: unknown) { super(msg); }}export async function predictCancellationRisk(guestHistory: string[]): Promise<{ riskScore: number; reason: string; confidence: number;}> { try { const { text } = await generateText({ model: openai(config.predictionModel), system: "You are a restaurant analytics assistant. Return JSON only.", prompt: `Analyze this cancellation history and return a risk score prediction: ${JSON.stringify(guestHistory)}`, }); return JSON.parse(text) as { riskScore: number; reason: string; confidence: number }; } catch (cause) { try { const obs = getObservability(); const trace = obs.trace({ name: "predict-cancellation-risk", metadata: { error: String(cause) } }); trace.span({ name: "error" }); } catch { /* observability not available */ } throw new PredictionError("Failed to predict cancellation risk", cause); }}export async function generateWaitlistMessage( guestName: string, restaurantName: string, partySize: number,): Promise<string> { try { const { text } = await generateText({ model: openai(config.predictionModel), system: "You write polite SMS messages for restaurant waitlist guests. Keep under 320 characters.", prompt: `Write an SMS inviting ${guestName} to come to ${restaurantName} for a table for ${String(partySize)} that just opened up.`, }); return text; } catch (cause) { try { const obs = getObservability(); const trace = obs.trace({ name: "generate-waitlist-message", metadata: { error: String(cause) } }); trace.span({ name: "error" }); } catch { /* observability not available */ } throw new PredictionError("Failed to generate waitlist message", cause); }}
Expected output: Two async functions that call generateText. On LLM failure, both throw a typed PredictionError and log a trace span to Langfuse.
Step 6: Add LLM cache and observability
Create src/lib/cache.ts — a singleton CacheEngine from @reaatech/llm-cache with an OpenAIEmbedder:
Then create src/lib/observability.ts — a singleton wrapper around Langfuse:
ts
import { Langfuse } from "langfuse";import { config } from "./config";let _langfuse: Langfuse | null = null;export function initializeObservability(): void { _langfuse = new Langfuse({ publicKey: config.langfusePublicKey, secretKey: config.langfuseSecretKey, baseUrl: config.langfuseBaseUrl, });}export function getObservability(): Langfuse { if (!_langfuse) { throw new Error("Observability not initialized. Call initializeObservability() first."); } return _langfuse;}export async function shutdownObservability(): Promise<void> { if (_langfuse) { await _langfuse.shutdownAsync(); _langfuse = null; }}
Expected output: Two modules — one sets up similarity-cached LLM completions with configurable TTL and segmentation; the other provides initializeObservability, getObservability, and shutdownObservability wrapping Langfuse.
Step 7: Build the reservation service
The reservation service is the central orchestrator for cancellation and no-show handling. Create src/services/reservation-service.ts:
ts
import { getReservations, getReservationById, updateReservationStatus, recordCancellation, getCancellationHistory,} from "../lib/db";import { activateBackfill } from "./waitlist-service";export async function handleCancellation(reservationId: string, reason?: string) { let reservation; try { reservation = await getReservationById(reservationId); } catch { reservation = null; } if (!reservation) { throw new Error(`Reservation not found: ${reservationId}`); } await updateReservationStatus(reservationId, "cancelled"); await recordCancellation(reservationId, reason); const partySize = reservation.party_size as number; if (partySize >= 2) { await activateBackfill(reservation); } return { ok: true };}export async function getCancellationStats(restaurantId: string) { const history = await getCancellationHistory(restaurantId); const byHour: Record<number, number> = {}; const byDay: Record<number, number> = {}; for (const entry of history) { const date = new Date(entry.cancelled_at as string); const hour = date.getHours(); const day = date.getDay(); byHour[hour] = (byHour[hour] ?? 0) + 1; byDay[day] = (byDay[day] ?? 0) + 1; } return { totalCancellations: history.length, byHourOfDay: byHour, byDayOfWeek: byDay, };}export async function checkForNoShows(restaurantId: string) { const now = new Date().toISOString(); const reservations = await getReservations(restaurantId); const noShows: Record<string, unknown>[] = []; for (const reservation of reservations) { if ( reservation.status === "confirmed" && reservation.reservation_time && String(reservation.reservation_time) < now ) { try { await handleCancellation(reservation.id as string, "no-show"); noShows.push(reservation); } catch { // reservation may already be handled } } } return noShows;}
Expected output: Three exports. handleCancellation marks a reservation as cancelled, records the cancellation, and triggers backfill for parties of 2+. getCancellationStats returns hour-of-day and day-of-week histograms. checkForNoShows finds past-due confirmed reservations and auto-cancels them.
Step 8: Build the predictor service
The predictor service brings the LLM into the cancellation pipeline. Create src/services/predictor-service.ts:
ts
import { getReservations } from "../lib/db";import { getCancellationStats } from "../services/reservation-service";import { predictCancellationRisk } from "../lib/llm";export async function predictSlotRisk(restaurantId: string, timeSlot: string) { const stats = await getCancellationStats(restaurantId); const result = await predictCancellationRisk([JSON.stringify(stats)]); return { restaurantId, timeSlot, riskScore: result.riskScore, reason: result.reason, confidence: result.confidence, };}export async function suggestOverbooking(restaurantId: string, timeSlot: string) { const prediction = await predictSlotRisk(restaurantId, timeSlot); let extraContacts = 0; if (prediction.riskScore >= 80) { extraContacts = 3; } else if (prediction.riskScore >= 60) { extraContacts = 2; } else if (prediction.riskScore >= 40) { extraContacts = 1; } return { ...prediction, suggestedExtraContacts: extraContacts };}export async function getHighRiskReservations(restaurantId: string, threshold = 70) { const reservations = await getReservations(restaurantId); const highRisk: Record<string, unknown>[] = []; for (const reservation of reservations) { const timeSlot = String(reservation.reservation_time ?? ""); if (!timeSlot) continue; const prediction = await predictSlotRisk(restaurantId, timeSlot); if (prediction.riskScore >= threshold) { highRisk.push({ reservation, prediction }); } } return highRisk;}
Expected output:predictSlotRisk returns a risk score 0-100 with an explanation and confidence. suggestOverbooking maps that score to extra waitlist contacts (0-3). getHighRiskReservations filters upcoming reservations above a configurable threshold.
Step 9: Build the waitlist backfill service
The waitlist service contacts guests via SMS when a cancellation opens a slot. Create src/services/waitlist-service.ts:
ts
import { getWaitlistByRestaurant, markWaitlistNotified } from "../lib/db";import { generateWaitlistMessage } from "../lib/llm";import { sendSms } from "./telephony-handler";import { config } from "../lib/config";export async function findMatchingWaitlistEntries(restaurantId: string, partySize: number) { return getWaitlistByRestaurant(restaurantId, partySize);}export async function contactWaitlistEntry(entry: Record<string, unknown>, message: string) { const phone = entry.phone as string; if (!phone) { throw new Error("Waitlist entry has no phone number"); } await sendSms(phone, config.twilioPhoneNumber, message); await markWaitlistNotified(entry.id as string);}export async function activateBackfill(cancelledReservation: Record<string, unknown>) { const restaurantId = cancelledReservation.restaurant_id as string; const partySize = cancelledReservation.party_size as number; let contacted = 0; const entries = await findMatchingWaitlistEntries(restaurantId, partySize); for (const entry of entries) { const entryName = entry.guest_name as string; const message = await generateWaitlistMessage(entryName, restaurantId, partySize); await contactWaitlistEntry(entry, message); contacted++; } return { contacted, confirmed: contacted > 0 };}export async function getBackfillStatus(restaurantId: string) { const entries = await getWaitlistByRestaurant(restaurantId, 9999); let contacted = 0; let pending = 0; let lastContactAt: string | null = null; for (const entry of entries) { if (entry.notified) { contacted++; if (entry.contacted_at && (!lastContactAt || String(entry.contacted_at) > lastContactAt)) { lastContactAt = String(entry.contacted_at); } } else { pending++; } } return { contacted, pending, lastContactAt };}
Expected output:findMatchingWaitlistEntries queries un-notified entries whose party size fits. contactWaitlistEntry sends an SMS and marks the entry. activateBackfill orchestrates the flow: find → generate message → contact each entry. getBackfillStatus returns aggregate counts.
Step 10: Create the voice pipeline and telephony handler
The voice pipeline uses @reaatech/voice-agent-core to manage real-time audio sessions. Create src/services/voice-pipeline.ts:
ts
import { createPipeline, createLatencyBudget, initializeSessionManager, defineConfig, LatencyBudgetEnforcer, MockSTTProvider, MockTTSProvider, MockMCPClient, type PipelineEvent, type AudioChunk,} from "@reaatech/voice-agent-core";import { getObservability } from "../lib/observability";let pipeline: ReturnType<typeof createPipeline> | null = null;export function createVoicePipeline( configInput: Record<string, unknown>, sttProvider?: MockSTTProvider
Then create the telephony handler at src/services/telephony-handler.ts — this wires Twilio media streams to the REAA voice pipeline:
ts
import { createTwilioHandler, type TwilioMediaStreamHandler } from "@reaatech/voice-agent-telephony";import { processVoiceInput } from "./voice-pipeline";import twilio from "twilio";import { config } from "../lib/config";import { withRetry } from "@reaatech/agent-handoff";import type { AudioChunk } from "@reaatech/voice-agent-core";export function createTelephonyHandler(): TwilioMediaStreamHandler { const h = createTwilioHandler({ bargeInEnabled: true, minSpeechDuration: 300, confidenceThreshold: 0.7, }); h.on("connected", () => { console.log("Twilio handler connected"); }); h.on("disconnected", () => { console.log("Twilio handler disconnected"); }); h.on("audio:received", (chunk: AudioChunk) => { const sid = h.getCallSid(); if (sid) void processVoiceInput(sid, chunk).catch(() => {}); }); h.on("error", (err: Error) => { console.error("Twilio handler error:", err.message); }); h.on("barge-in:detected", () => { void h.clearAudio(); }); h.on("call:end", () => { void import("./voice-pipeline").then(({ endPipelineSession }) => { const sid = h.getCallSid(); if (sid) void endPipelineSession(sid); }); }); return h;}export function createTwilioClient() { return twilio(config.twilioAccountSid, config.twilioAuthToken);}export async function sendSms(to: string, from: string, body: string) { const client = createTwilioClient(); return withRetry( () => client.messages.create({ body, to, from }), { maxRetries: 3, backoff: "exponential", baseDelayMs: 500, maxDelayMs: 10000, shouldRetry: (error: unknown) => { const twilioError = error as { status?: number }; return twilioError.status === 429 || twilioError.status === 503; }, }, );}export async function initiateVoiceCall(to: string, from: string, twimlUrl: string) { const client = createTwilioClient(); try { return await client.calls.create({ to, from, url: twimlUrl }); } catch (cause) { throw new Error(`Failed to initiate voice call: ${String(cause)}`); }}
Expected output: Two modules. The voice pipeline creates a configurable STT/TTS/MCP pipeline with latency budget enforcement and Langfuse tracing. The telephony handler wraps Twilio media streams, routes audio into the pipeline, handles barge-in, and retries SMS sends with exponential backoff via withRetry from @reaatech/agent-handoff.
Step 11: Create the MCP tools
The system exposes six tools through @reaatech/mcp-server-tools. Each uses zod for input validation and defineTool for registration.
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { getReservationById, getCancellationHistory } from "../lib/db";import { predictCancellationRisk } from "../lib/llm";export default defineTool({ name: "predict-cancellation-risk", description: "Predict cancellation risk for a specific reservation", inputSchema: z.object({ reservationId: z.string(), }), handler: async ({ reservationId }) => { const reservation = await getReservationById(reservationId as string); const restaurantId = (reservation as Record<string, string>).restaurant_id || ""; const history = await getCancellationHistory(restaurantId); const historyEntries = history.map((h) => JSON.stringify(h)); historyEntries.push(JSON.stringify(reservation)); const prediction = await predictCancellationRisk(historyEntries); return { content: [{ type: "text", text: JSON.stringify(prediction) }] }; },});
Next, src/tools/slot-risk.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { predictSlotRisk } from "../services/predictor-service";export default defineTool({ name: "slot-risk", description: "Predict cancellation risk for a time slot", inputSchema: z.object({ restaurantId: z.string(), timeSlot: z.string(), }), handler: async ({ restaurantId, timeSlot }) => { const result = await predictSlotRisk(restaurantId as string, timeSlot as string); return { content: [{ type: "text", text: JSON.stringify(result) }] }; },});
Then src/tools/activate-waitlist.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { handleCancellation } from "../services/reservation-service";export default defineTool({ name: "activate-waitlist", description: "Activate waitlist backfill for a cancelled reservation", inputSchema: z.object({ reservationId: z.string(), }), handler: async ({ reservationId }) => { const result = await handleCancellation(reservationId as string); return { content: [{ type: "text", text: JSON.stringify(result) }] }; },});
And src/tools/waitlist-status.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { getBackfillStatus } from "../services/waitlist-service";export default defineTool({ name: "waitlist-status", description: "Get backfill status for a restaurant", inputSchema: z.object({ restaurantId: z.string(), }), handler: async ({ restaurantId }) => { const status = await getBackfillStatus(restaurantId as string); return { content: [{ type: "text", text: JSON.stringify(status) }] }; },});
Add two CRUD tools — src/tools/list-reservations.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { getReservations } from "../lib/db";export default defineTool({ name: "list-reservations", description: "List upcoming reservations for a restaurant", inputSchema: z.object({ restaurantId: z.string(), limit: z.number().optional(), }), handler: async ({ restaurantId, limit }) => { const reservations = await getReservations(restaurantId as string); const result = limit ? reservations.slice(0, limit as number) : reservations; return { content: [{ type: "text", text: JSON.stringify(result) }] }; },});
And src/tools/get-reservation.tool.ts:
ts
import { defineTool } from "@reaatech/mcp-server-tools";import { z } from "zod";import { getReservationById } from "../lib/db";export default defineTool({ name: "get-reservation", description: "Look up a reservation by ID", inputSchema: z.object({ reservationId: z.string(), }), handler: async ({ reservationId }) => { const reservation = await getReservationById(reservationId as string); return { content: [{ type: "text", text: JSON.stringify(reservation) }] }; },});
Finally, create src/tools/index.ts with tool discovery:
ts
import { registerTool, discoverTools, getTools, clearTools } from "@reaatech/mcp-server-tools";export async function initializeTools() { const tools = await discoverTools(); return tools;}export { registerTool };export function listTools() { return getTools();}export function clearAllTools() { clearTools();}
Expected output: Six defineTool exports and a tool index module. Each tool has a name, description, inputSchema (zod object), and async handler returning MCP-compatible content blocks.
Step 12: Create the Next.js API routes
The App Router exposes the reservation pipeline as REST endpoints. These routes sit at app/api/ and import your services and database layer directly.
Create app/api/reservations/route.ts — a route handler with GET, POST, and PATCH verbs:
ts
import { NextRequest, NextResponse } from "next/server";import { getReservations, updateReservationStatus } from "../../../src/lib/db";import { pool } from "../../../src/lib/db";import { handleCancellation } from "../../../src/services/reservation-service";export async function GET(req: NextRequest) { const restaurantId = req.nextUrl.searchParams.get("restaurantId"); if (!restaurantId) { return NextResponse.json({ error: "restaurantId is required" }, { status: 400 }); } const rows = await getReservations(restaurantId); return NextResponse.json(rows);}export async function PATCH(req: NextRequest) { let body: { reservationId?: string; status?: string; reason?: string }; try { body = (await req.json()) as { reservationId?: string; status?: string; reason?: string }; } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const { reservationId, status, reason } = body; if (!reservationId || !status) { return NextResponse.json({ error: "reservationId and status are required" }, { status: 400 }); } if (status === "cancelled") { await handleCancellation(reservationId, reason); } else { await updateReservationStatus(reservationId, status); } return NextResponse.json({ ok: true });}export async function POST(req: NextRequest) { let raw: unknown; try { raw = await req.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const body = raw as { restaurantId?: string; guestName?: string; partySize?: number; reservationTime?: string; phone?: string }; const { restaurantId, guestName, partySize, reservationTime, phone } = body; if (!restaurantId || !guestName || !partySize || !reservationTime) { return NextResponse.json( { error: "restaurantId, guestName, partySize, and reservationTime are required" }, { status: 400 }, ); } const { rows } = await pool.sql` INSERT INTO reservations (restaurant_id, guest_name, party_size, reservation_time, phone) VALUES (${restaurantId}, ${guestName}, ${partySize}, ${reservationTime}, ${phone ?? null}) RETURNING * `; return NextResponse.json(rows[0], { status: 201 });}
Next, app/api/predictions/route.ts with a single GET endpoint that returns high-risk reservations or a slot-specific prediction:
ts
import { NextRequest, NextResponse } from "next/server";import { predictSlotRisk, getHighRiskReservations } from "../../../src/services/predictor-service";export async function GET(req: NextRequest) { const restaurantId = req.nextUrl.searchParams.get("restaurantId"); if (!restaurantId) { return NextResponse.json({ error: "restaurantId is required" }, { status: 400 }); } const timeSlot = req.nextUrl.searchParams.get("timeSlot"); if (timeSlot) { const result = await predictSlotRisk(restaurantId, timeSlot); return NextResponse.json(result); } const result = await getHighRiskReservations(restaurantId); return NextResponse.json(result);}
Finally, a minimal health check at app/api/health/route.ts:
ts
import { NextResponse } from "next/server";export function GET() { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });}
Expected output: Four API route files under app/api/. All use NextRequest and NextResponse.json() instead of bare Request / Response. The reservations route supports listing, creating, and cancelling reservations in a single file. The predictions and waitlist routes accept query parameters via nextUrl.searchParams.
Step 13: Create the Hono WebSocket server and Next.js instrumentation
The Hono server at src/hono-app.ts handles Twilio webhooks and WebSocket media streams:
ts
import { serve } from "@hono/node-server";import type { Server } from "http";import { Hono } from "hono";import { WebSocketServer } from "ws";import { IncomingRequestSchema } from "@reaatech/agent-mesh";import { createTelephonyHandler } from "./services/telephony-handler";import { processVoiceInput, endPipelineSession } from "./services/voice-pipeline";const app = new Hono();app.post("/voice", async (c) => { const raw: unknown = await c.req.json(); IncomingRequestSchema.parse(raw); const host = c.req.header("host") || "localhost:3001"; const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response> <Connect> <Stream url="wss://${host}/stream"/> </Connect></Response>`; return c.body(twiml, 200, { "Content-Type": "text/xml" });});app.get("/health", (c) => { return c.json({ status: "ok" });});app.post("/status", async (c) => { const raw: unknown = await c.req.json(); try { const { getObservability } = await import("./lib/observability"); const obs = getObservability(); const trace = obs.trace({ name: "twilio-status-callback", metadata: { payload: raw } }); void trace.span({ name: "callback-received" }); } catch { /* observability not available */ } return c.json({ ok: true });});export function startHonoServer(port = 3001) { const honoServer = serve({ fetch: app.fetch, port }); const wss = new WebSocketServer({ server: honoServer as Server }); wss.on("connection", (ws) => { const handler = createTelephonyHandler(); const sessionId = crypto.randomUUID(); void handler.acceptConnection(ws); ws.on("message", (rawData) => { try { const raw = rawData as Buffer; const data = JSON.parse(raw.toString()) as { event?: string; media?: { payload?: string } }; if (data.event === "media" && data.media && data.media.payload) { const chunk = { buffer: Buffer.from(data.media.payload, "base64"), sampleRate: 8000, encoding: "pcm" as const, channels: 1, timestamp: Date.now(), }; void processVoiceInput(sessionId, chunk); } } catch { /* ignore malformed messages */ } }); ws.on("close", () => { void endPipelineSession(sessionId); }); }); console.log(`Hono + WS server listening on port ${String(port)}`);}
Then create src/instrumentation.ts — Next.js uses this to initialize observability at server startup:
ts
export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { initializeObservability } = await import("./lib/observability"); initializeObservability(); }}
Enable the instrumentation hook in next.config.ts:
import { IncomingRequestSchema, type IncomingRequest, AgentResponseSchema, type AgentResponse, ContextPacketSchema, type ContextPacket } from "@reaatech/agent-mesh";export { IncomingRequestSchema, IncomingRequest, AgentResponseSchema, AgentResponse, ContextPacketSchema, ContextPacket };
And src/types/handoff.ts:
ts
import { createHandoffConfig, TypedEventEmitter, withRetry, pickDefined, type HandoffPayload, type RoutingDecision, type HandoffConfig, type Message } from "@reaatech/agent-handoff";export { createHandoffConfig, TypedEventEmitter, withRetry, pickDefined, HandoffPayload, RoutingDecision, HandoffConfig, Message };
Expected output: A Hono app listening on port 3001 with three routes (/voice, /health, /status) and a WebSocket server that pipes Twilio media into the voice pipeline. Next.js instrumentation initializes Langfuse on server boot. Two type re-export files provide typed access to agent-mesh and agent-handoff schemas.
Step 14: Run the type checker and test suite
Run the type checker to confirm everything compiles cleanly:
terminal
pnpm typecheck
Expected output:tsc --noEmit exits with code 0 and prints no errors.
Run the linter:
terminal
pnpm lint
Expected output: ESLint passes with zero warnings.
Now run the tests with coverage:
terminal
pnpm test
Expected output: All 109 tests pass, numFailedTests=0, and coverage thresholds (lines/branches/functions/statements >= 90%) are met for source files under src/.
You can also run the preflight validator to confirm the artifact passes all gates:
terminal
node /home/rick/solutions-worker/bin/preflight.js
Expected output:{"ok": true, ...} with no critical or error-level findings.
Next steps
Replace mock providers — swap MockSTTProvider, MockTTSProvider, and MockMCPClient in the voice pipeline with production Deepgram and Twilio Media Stream providers.
Add a dashboard — build a Next.js page under app/dashboard/ that shows real-time risk scores, backfill status, and cancellation trends using the getBackfillStatus and getCancellationStats APIs.
Add SMS confirmation parsing — listen for incoming Twilio SMS replies and automatically confirm or release the backfill slot when a waitlisted guest responds. For repeated telephony failures, integrate @reaatech/agent-handoff’s circuit breaker pattern into initiateVoiceCall.
await
pool.
sql
`
CREATE TABLE IF NOT EXISTS waitlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
restaurant_id TEXT NOT NULL,
guest_name TEXT NOT NULL,
party_size INT NOT NULL,
phone TEXT,
notified BOOLEAN DEFAULT FALSE,
contacted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
`;
await pool.sql`
CREATE TABLE IF NOT EXISTS cancellations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reservation_id UUID REFERENCES reservations ON DELETE CASCADE,
cancelled_at TIMESTAMPTZ DEFAULT NOW(),
reason TEXT
);
`;
}
export async function getReservations(restaurantId: string) {
const { rows } = await pool.sql`SELECT * FROM reservations WHERE restaurant_id = ${restaurantId}`;
return rows;
}
export async function getReservationById(id: string) {
const { rows } = await pool.sql`SELECT * FROM reservations WHERE id = ${id}`;
return rows[0] ?? null;
}
export async function updateReservationStatus(id: string, status: string) {
const { rows } = await pool.sql`UPDATE reservations SET status = ${status} WHERE id = ${id} RETURNING *`;
return rows[0];
}
export async function getCancellationHistory(restaurantId: string) {