The owner or general manager of a 1-5 location independent restaurant spends hours each week manually crafting responses to Yelp, Google, and TripAdvisor reviews. Responses are often delayed, inconsistent in tone, or skipped entirely, damaging the restaurant's online reputation. With thin margins and a lean team, there's no budget for a dedicated marketing person. The GM needs a way to automatically generate appropriate, on-brand replies that maintain a positive presence without adding to their administrative burden.
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 you through building an Automated Review Response Agent for independent restaurants. You’ll wire together six REAA packages (agent-mesh, guardrail-chain, llm-router-core, llm-cost-telemetry, mcp-server-core, and agent-memory) into a Next.js App Router application that normalizes reviews from Yelp, Google, and TripAdvisor, runs them through a guardrail chain, generates an on-brand reply with the Vercel AI SDK, records cost telemetry, and stores extracted memory for returning customers.
By the end, you’ll have three API endpoints — a health check, a single-review responder, and a batch processor — plus a full test suite with 90%+ coverage.
Prerequisites
Node.js >= 22 and pnpm 10 installed
An OpenAI API key (set as OPENAI_API_KEY in your environment)
Basic familiarity with TypeScript, Next.js App Router, and vitest
A terminal ready to paste commands
Step 1: Scaffold the project and install dependencies
Start with a fresh Next.js 16 project with the App Router. Create your project directory and initialize it:
Next, set up your environment variables. Create .env.example:
env
# Env vars used by agnostic-review-response-agent-4.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# OpenAI API key (required by @ai-sdk/openai)OPENAI_API_KEY=<your-openai-key>
Copy it to .env.local and fill in your key.
Expected output:pnpm install exits 0. Your package.json shows exact versions for all dependencies (no ^ or ~ prefixes).
Step 2: Enable instrumentation in next.config.ts
Since you’ll add an src/instrumentation.ts that initializes REAA packages at server startup, you must enable the experimental instrumentation hook. Update next.config.ts:
Expected output: The file is at the project root. The key instrumentationHook is spelled exactly as shown — a common mistake is clientInstrumentationHook or instrumentation, both of which are wrong.
Step 3: Define the core types with Zod
Create src/types/restaurant-review.ts. These Zod schemas and TypeScript types describe a review coming in from a platform, the response your agent generates, and the restaurant’s brand voice configuration.
Expected output: The file compiles without type errors. The CostSpan type is re-exported from @reaatech/llm-cost-telemetry — you use it in the ReviewProcessingResult discriminated union.
Step 4: Build the brand service
Create src/services/restaurant-brand-service.ts. This module classifies the response tone based on rating and generates the system prompt that tells the LLM how to write each reply.
ts
import type { BrandVoiceConfig, ReviewResponse } from "../types/restaurant-review.js";export function classifyResponseTone(rating: number): ReviewResponse["tone"] { if (rating >= 4) return "grateful"; if (rating === 3) return "appreciative"; if (rating === 2) return "professional"; if (rating === 1) return "apologetic"; throw new RangeError("Invalid rating: " + String(rating) + ". Must be 1-5.");}export function generateSystemPrompt(brand: BrandVoiceConfig): string { const valueLines = brand.brandValues.map((v) => " - " + v).join("\n"); return ( "You are an automated review response agent for \"" + brand.restaurantName + "\".\n" + "Brand tone: " + brand.brandTone + "\n" + "Brand values:\n" + valueLines + "\n" + "Sign each response as: \"" + brand.signatureName + "\"\n" + "Response rules:\n" + "- If rating >= 4: express gratitude and invite return.\n" + "- If rating == 3: thank for feedback, acknowledge any concerns.\n" + "- If rating <= 2: apologize sincerely, address specific complaints, offer to make it right, invite direct contact.\n" + "Write in the brand tone using brand values. Keep responses concise (2-4 sentences)." );}
Expected output:classifyResponseTone(5) returns "grateful" and classifyResponseTone(0) throws a RangeError.
Step 5: Build the platform-agnostic review fetcher
Create src/services/review-fetcher.ts. This module normalizes raw review data from each platform (Yelp, Google, TripAdvisor) into your canonical RestaurantReview shape. Each platform uses different field names — this is where you bridge the gap.
Expected output:normalizeReview({ id: "1", text: "Great!", rating: 5, user: { name: "Alice" }, time_created: "2026-01-01", restaurantId: "r1" }, "yelp") returns a valid RestaurantReview with platform: "yelp" and authorName: "Alice".
Step 6: Build the LLM client
Create src/lib/llm-client.ts. This thin wrapper around the Vercel AI SDK calls OpenAI (via @ai-sdk/openai) and returns a structured LLMResult that your generator can check without catching exceptions.
Expected output: When generateText succeeds, generateReviewResponse returns { ok: true, text: "...", usage: { ... } }. When it throws, the result is { ok: false, error: "..." } — never an unhandled exception.
Step 7: Build the response formatter
Create src/services/response-formatter.ts. This uses @reaatech/mcp-server-core’s ToolResponseSchema to produce well-typed MCP responses that your route handlers return.
ts
import { textContent, errorResponse, ToolResponseSchema, type ToolResponse,} from "@reaatech/mcp-server-core";export function formatAsToolResponse(text: string): ToolResponse { const response: ToolResponse = { content: [textContent(text)] }; return ToolResponseSchema.parse(response);}export function formatErrorResponse(message: string): ToolResponse { const response = errorResponse(message); return ToolResponseSchema.parse(response);}
Expected output:formatAsToolResponse("Thanks!").content[0].type is "text" and formatErrorResponse("fail").isError is true.
Step 8: Wire it all together in the review response generator
Create src/services/review-response-generator.ts. This is the orchestrator — it runs the guardrail chain, calls the LLM, records cost telemetry, stores conversation memory, and produces a ReviewProcessingResult.
ts
import { ChainBuilder, type GuardrailChain, type ChainResult } from "@reaatech/guardrail-chain";import type { AgentMemory } from "@reaatech/agent-memory";import { textContent, ToolResponseSchema } from "@reaatech/mcp-server-core";import { generateId, now, calculateCostFromTokens, CostSpanSchema,} from "@reaatech/llm-cost-telemetry";import type { CostSpan } from "@reaatech/llm-cost-telemetry";import { ModelDefinitionSchema, RoutingRequestSchema } from "@reaatech/llm-router-core";import { IncomingRequestSchema, AgentResponseSchema } from "@reaatech/agent-mesh";import type { RestaurantReview,
Expected output:buildDefaultGuardrailChain() returns a GuardrailChain that accepts benign input. All six REAA packages are imported and used: agent-mesh, guardrail-chain, llm-router-core, llm-cost-telemetry, mcp-server-core, and agent-memory.
Step 9: Create the instrumentation hook
Create src/instrumentation.ts. Next.js runs this at server startup in the Node runtime, where it’s safe to initialize REAA logger and cost-telemetry configuration.
ts
import { setLogger, ConsoleLogger } from "@reaatech/guardrail-chain";import { loadConfig } from "@reaatech/llm-cost-telemetry";export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME !== "nodejs") return; setLogger(new ConsoleLogger()); loadConfig(); await Promise.resolve();}
Expected output: The register() function is async and returns early when NEXT_RUNTIME is "edge". On Node, it initializes the guardrail logger and loads cost-telemetry config without throwing.
Step 10: Build the API routes
Create three API routes under app/api/.
Health check — app/api/health/route.ts
ts
import { NextRequest, NextResponse } from "next/server";import { HealthStatusSchema } from "@reaatech/mcp-server-core";export function GET(_req: NextRequest): NextResponse { void _req; const nodeEnv: string = (process.env as Record<string, string | undefined>).NODE_ENV ?? "development"; const health = { status: "healthy" as const, version: "0.1.0", environment: nodeEnv, uptime: process.uptime(), timestamp: new Date().toISOString(), }; HealthStatusSchema.parse(health); return NextResponse.json(health);}
Single review — app/api/reviews/route.ts
This handler accepts a { review, brand } body, validates it against the IncomingRequestSchema from agent-mesh and your Zod schemas, and returns a generated response.
Expected output:GET /api/health returns { status: "healthy", ... } with a 200 status. POST /api/reviews with valid review and brand data returns 200. POST /api/reviews/batch with an array of reviews returns { results: [...] }.
Step 11: Write the tests
Create the test suite under tests/. The project includes unit tests for every module, integration tests for the API routes, guardrail rejection tests, and end-to-end data-flow tests. Below are the key test files; additional test files covering the formatter, fetcher, cost telemetry, MCP core, and agent-mesh are omitted for brevity (find the complete suite in the finished download).
tests/restaurant-brand-service.test.ts
ts
import { describe, it, expect } from "vitest";import { generateSystemPrompt, classifyResponseTone,} from "../src/services/restaurant-brand-service.js";import type { BrandVoiceConfig } from "../src/types/restaurant-review.js";const brand: BrandVoiceConfig = { restaurantId: "r1", restaurantName: "Luigi's Pizza", brandTone: "warm and friendly", brandValues: ["family", "tradition", "quality"], signatureName: "Luigi", platforms: ["yelp", "google"],};describe("restaurant-brand-service", () => { it("generates system prompt with brand details", () => { const prompt = generateSystemPrompt(brand); expect(prompt).toContain("Luigi's Pizza"); expect(prompt).toContain("warm"); expect(prompt).toContain("family"); expect(prompt).toContain("tradition"); expect(prompt).toContain("Luigi"); expect(prompt).toContain("gratitude"); expect(prompt).toContain("apologize"); }); it("classifies tone correctly for various ratings", () => { expect(classifyResponseTone(5)).toBe("grateful"); expect(classifyResponseTone(4)).toBe("grateful"); expect(classifyResponseTone(3)).toBe("appreciative"); expect(classifyResponseTone(2)).toBe("professional"); expect(classifyResponseTone(1)).toBe("apologetic"); }); it("treats out-of-range rating 6 as >=4 returning grateful", () => { expect(classifyResponseTone(6)).toBe("grateful"); }); it("throws for invalid ratings below 1", () => { expect(() => classifyResponseTone(0)).toThrow(RangeError); expect(() => classifyResponseTone(-1)).toThrow(RangeError); }); it("includes all brand values without truncation", () => { const manyValues: BrandVoiceConfig = { ...brand, brandValues: [ "quality", "service", "value", "fresh", "local", "sustainable", "organic", "craft", "artisan", "traditional", "innovative", "family", "community", "welcoming", "authentic", ], }; const prompt = generateSystemPrompt(manyValues); for (const v of manyValues.brandValues) { expect(prompt).toContain(v); } });});
tests/llm-client.test.ts
ts
import { describe, it, expect, vi } from "vitest";const mockGenerateText = vi.hoisted(() => vi.fn());vi.mock("ai", () => ({ generateText: mockGenerateText,}));import { generateReviewResponse } from "../src/lib/llm-client.js";import type { RestaurantReview, BrandVoiceConfig } from "../src/types/restaurant-review.js";const validReview: RestaurantReview = { id: "1", platform: "yelp", restaurantId: "r1", rating: 5, content: "Amazing food and great service!", authorName: "Jane", reviewDate: "2026-06-25",};const brand: BrandVoiceConfig = { restaurantId: "r1", restaurantName: "Luigi's", brandTone: "warm and friendly", brandValues: ["family", "tradition"], signatureName: "Luigi", platforms: ["yelp"],};describe("llm-client", () => { it("returns ok with text and usage on success", async () => { mockGenerateText.mockResolvedValue({ text: "Thank you for your kind review!", usage: { inputTokens: 20, outputTokens: 8 }, }); const result = await generateReviewResponse({ review: validReview, brand, systemPrompt: "You are a helpful assistant." }); expect(result.ok).toBe(true); if (result.ok) { expect(result.text.length).toBeGreaterThan(0); expect(result.usage.inputTokens).toBeGreaterThan(0); } }); it("returns error result on API failure", async () => { mockGenerateText.mockRejectedValue(new Error("API rate limit exceeded")); const result = await generateReviewResponse({ review: validReview, brand, systemPrompt: "You are a helpful assistant." }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("rate limit exceeded"); expect(result.text).toBe(""); expect(result.usage.inputTokens).toBe(0); } }); it("handles long review content without pre-call truncation", async () => { mockGenerateText.mockResolvedValue({ text: "Thanks!", usage: { inputTokens: 100, outputTokens: 5 }, }); const longReview: RestaurantReview = { ...validReview, content: "A".repeat(3000) }; const result = await generateReviewResponse({ review: longReview, brand, systemPrompt: "You are a helpful assistant." }); expect(result.ok).toBe(true); }); it("handles non-Error thrown exception gracefully", async () => { mockGenerateText.mockRejectedValue("string error"); const result = await generateReviewResponse({ review: validReview, brand, systemPrompt: "You are a helpful assistant." }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toBe("LLM call failed"); } }); it("handles undefined usage tokens", async () => { mockGenerateText.mockResolvedValue({ text: "OK", usage: {}, }); const result = await generateReviewResponse({ review: validReview, brand, systemPrompt: "You are a helpful assistant." }); expect(result.ok).toBe(true); if (result.ok) { expect(result.usage.inputTokens).toBe(0); expect(result.usage.outputTokens).toBe(0); } });});
tests/integration-api-routes.test.ts
This file tests all three API route handlers directly — health check, single review, and batch processing — covering success, validation errors, LLM failures, and edge cases. The following shows the most important test cases; the full file contains 12 tests total.
ts
import { describe, it, expect, vi, afterEach } from "vitest";import { NextRequest } from "next/server";const mockGenerateReviewResponse = vi.hoisted(() => vi.fn());vi.mock("../src/lib/llm-client.js", () => ({ generateReviewResponse: mockGenerateReviewResponse,}));const MockAgentMemory = vi.hoisted( () => class { retrieve = vi.fn().mockResolvedValue([]); extractAndStore = vi.fn().mockResolvedValue([]); close
This file tests the ReviewResponseGenerator end-to-end: positive and negative reviews, returning-customer memory augmentation, and guardrail short-circuits. Four test cases in total — the key guardrail test is shown below.
Expected output: All tests pass. pnpm vitest run --coverage reports 90%+ coverage on lines, branches, functions, and statements.
Step 12: Run the full quality suite
Run typecheck, lint, and the full test suite with coverage:
terminal
pnpm typecheckpnpm lintpnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output:
pnpm typecheck exits 0 with no errors
pnpm lint exits 0 with no warnings
pnpm vitest run --coverage exits 0 with numFailedTests=0, numTotalTests >= 3, and coverage thresholds at 90% or higher for lines, branches, functions, and statements
Next steps
Add more guardrails — wire in real toxicity, PII, or language detection guardrails using ChainBuilder.withGuardrail() with custom guardrail classes
Add a review queue UI — build a Next.js page that lists incoming reviews with approve/reject buttons for generated responses
Connect to live platforms — write scheduled jobs that poll the Yelp Fusion API and Google Business Profile API, feed reviews through this agent, and auto-post responses
BrandVoiceConfig,
ReviewProcessingResult,
LLMResult,
} from "../types/restaurant-review.js";
import { generateSystemPrompt, classifyResponseTone } from "./restaurant-brand-service.js";