The general manager or owner of a 1-5 location restaurant spends hours each week manually replying to online reviews. Replies are often delayed, inconsistent in tone, or missed entirely, hurting the restaurant's online reputation. With thin margins and no dedicated marketing staff, this task falls through the cracks.
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 a Review Response Agent — an AI-powered service that generates on-brand replies to Yelp, Google, and TripAdvisor reviews for independent restaurants. It uses the Vercel AI SDK for LLM calls, Zod for runtime validation, and six REAA packages for caching, guardrails, agent memory, the A2A protocol, and observability. By the end you’ll have a running Next.js app with five API endpoints, a full test suite, and a service architecture you can extend to any hospitality business.
Prerequisites
Node.js 22+ and pnpm 10+ installed
An OpenAI-compatible API key (any LLM endpoint serving a /chat/completions API works)
Basic familiarity with TypeScript and Next.js App Router
Step 1: Scaffold the Next.js project and install dependencies
Create a new Next.js project with the App Router and Turbopack:
The observability service wires up @reaatech/a2a-reference-observability for structured logging, OpenTelemetry tracing, and metrics. Create src/services/observability-service.ts:
Expected output:pnpm typecheck passes. The dotenv/config import loads environment variables before any process.env read, which is needed for tests where Next.js hasn’t yet bootstrapped the environment.
Step 5: Add semantic caching with @reaatech/llm-cache
Duplicate review patterns are common for high-volume restaurants. The cache service stores generated responses so identical inputs skip the LLM. Create src/services/cache-service.ts:
Expected output:pnpm typecheck passes. The CacheEngine constructor validates its config at startup, so a misconfigured embedder surfaces immediately.
Step 6: Implement tone and safety guardrails
The guardrail service uses @reaatech/guardrail-chain to build a pipeline that checks every generated response before it’s returned. Create src/services/guardrail-service.ts:
ts
import { ChainBuilder, setLogger, ConsoleLogger, createChainContext, type Guardrail, type GuardrailResult, type ChainContext } from "@reaatech/guardrail-chain";export function configure(): void { setLogger(new ConsoleLogger());}const TONE_KEYWORDS: Record<string, string[] | undefined> = { friendly: ["thanks", "appreciate", "welcome", "glad", "happy"], professional: ["thank you", "regards", "sincerely", "appreciate", "valued"], apologetic: ["sorry", "apologize", "regret", "understand", "frustration"], grateful: ["thank you", "appreciate", "grateful", "blessed", "wonderful"], witty: ["fun", "clever", "smile", "laugh", "charming"],};const TOXIC_PATTERNS: RegExp[] = [ /fuck/i, /shit/i, /damn/i, /\bass\b/i, /hate\s+you/i, /stupid/i, /idiot/i,];export class ToneConsistencyGuardrail implements Guardrail<string, string> { readonly id = "tone-consistency"; readonly name = "Tone Consistency"; readonly type = "output" as const; enabled = true; execute(input: string, context: ChainContext): Promise<GuardrailResult<string>> { const tone = context.metadata.tone as string | undefined; if (!tone) { return Promise.resolve({ passed: true, output: input }); } const keywords = TONE_KEYWORDS[tone]; if (!keywords) { return Promise.resolve({ passed: true, output: input }); } const lowerInput = input.toLowerCase(); const matched = keywords.some((kw) => lowerInput.includes(kw)); if (matched) { return Promise.resolve({ passed: true, output: input }); } return Promise.resolve({ passed: false, output: input, error: new Error(`Response does not match tone "${tone}"`), }); }}export class ContentSafetyGuardrail implements Guardrail<string, string> { readonly id = "content-safety"; readonly name = "Content Safety"; readonly type = "output" as const; enabled = true; execute(input: string, _context: ChainContext): Promise<GuardrailResult<string>> { if (input.length === 0) { return Promise.resolve({ passed: true, output: input }); } for (const pattern of TOXIC_PATTERNS) { if (pattern.test(input)) { return Promise.resolve({ passed: false, output: input, error: new Error(`Response contains blocked content matching pattern: ${pattern.source}`), }); } } return Promise.resolve({ passed: true, output: input }); }}const budget = { maxLatencyMs: 500, maxTokens: 2000 };const chain = new ChainBuilder() .withBudget(budget) .withGuardrail(new ToneConsistencyGuardrail()) .withGuardrail(new ContentSafetyGuardrail()) .build();export async function checkResponse( text: string, metadata: Record<string, unknown>,): Promise<{ success: boolean; output?: string; error?: string }> { const context = createChainContext(text, budget, { metadata }); const result = await chain.executeOutput(text, context); return { success: result.success, output: result.output as string | undefined, error: result.error, };}
Expected output: Type-check passes. The chain runs tone-check first, then content-safety. If the LLM produces a response that doesn’t match the restaurant’s brand tone, the guardrail blocks it before it reaches the caller.
Step 7: Build the brand voice memory service
Each restaurant needs its own brand voice profile. The brand voice service uses @reaatech/agent-memory to store and retrieve these profiles. Create src/services/brand-voice-service.ts:
Expected output: Build passes. The agent memory uses embedding-based semantic search, so retrieving “brand-voice mario-trattoria” finds the closest match even when the query isn’t an exact phrasing match.
Step 8: Create the A2A server for agent-to-agent communication
The A2A protocol lets other agents discover and invoke this service. Create src/services/a2a-server.ts:
Expected output: Type-check passes. AgentCardSchema.safeParse guarantees the card JSON matches the A2A protocol specification.
Step 9: Build the core review response agent
The ReviewResponseAgent orchestrates all the services. It builds a deterministic cache key, checks the cache, calls the LLM on a miss, runs guardrails (with one retry), stores the result, and emits metrics. Create src/services/review-agent.ts:
ts
import crypto from "node:crypto";import type { GenerateReviewResponseInput, ReviewResponse } from "../lib/types.js";import { GuardrailViolationError } from "../lib/errors.js";import type { LlmService } from "./llm-service.js";import type { CacheService } from "./cache-service.js";import type { BrandVoiceService } from "./brand-voice-service.js";import type { createLogger } from "@reaatech/a2a-reference-observability";type Logger = ReturnType<typeof createLogger>;type GuardrailCheckFn = (text: string, metadata:
Expected output: The agent’s flow is: cache check → LLM call → guardrail → on failure, retry once with a stricter prompt → if guardrail still fails, throw a GuardrailViolationError.
Step 10: Wire services with lifecycle and instrumentation
The lifecycle module initializes all services in dependency order and stores them in a singleton. Create src/services/lifecycle.ts:
ts
import "dotenv/config";import { LlmService } from "./llm-service.js";import { CacheService } from "./cache-service.js";import { BrandVoiceService } from "./brand-voice-service.js";import { A2AServer } from "./a2a-server.js";import { ReviewResponseAgent } from "./review-agent.js";import * as guardrailModule from "./guardrail-service.js";import { logger, taskCounter, durationHistogram, cacheHitCounter,} from "./observability-service.js";export interface Services { llm: LlmService; cache: CacheService; guardrail: typeof guardrailModule.checkResponse; brandVoice: BrandVoiceService; a2a: A2AServer; reviewAgent: ReviewResponseAgent; logger: typeof logger;}let servicesSingleton: Services | null = null;export function initializeServices(): Services { guardrailModule.configure(); const llm = new LlmService(); const cache = new CacheService(); const brandVoice = new BrandVoiceService(); const a2a = new A2AServer(); const reviewAgent = new ReviewResponseAgent({ llm, cache, guardrail: guardrailModule, brandVoice, logger, taskCounter, durationHistogram, cacheHitCounter, }); const services: Services = { llm, cache, guardrail: guardrailModule.checkResponse, brandVoice, a2a, reviewAgent, logger, }; setServices(services); return services;}export async function shutdownServices(services: Services): Promise<void> { await services.brandVoice.close();}export function getServices(): Services | null { return servicesSingleton;}export function setServices(s: Services): void { servicesSingleton = s;}
Next, create src/instrumentation.ts so Next.js boots the agent on server start:
For the instrumentation hook to fire, enable it in next.config.ts:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { experimental: { instrumentationHook: true, },};export default nextConfig;
Finally, update the barrel export in src/index.ts:
ts
export type { ReviewPlatform, ReviewInput, ReviewResponse, BrandTone, BrandVoice, GenerateReviewResponseInput, HealthStatus } from "./lib/types.js";export { ReviewPlatformSchema, ReviewInputSchema, ReviewResponseSchema, BrandVoiceSchema, BrandToneSchema, GenerateReviewResponseInputSchema, HealthStatusSchema } from "./lib/schemas.js";export { config, type Config } from "./lib/config.js";export { ReviewResponseAgent } from "./services/review-agent.js";export { BrandVoiceService } from "./services/brand-voice-service.js";export { A2AServer } from "./services/a2a-server.js";export { LlmService } from "./services/llm-service.js";export { CacheService } from "./services/cache-service.js";export { configure as configureGuardrail, checkResponse, ToneConsistencyGuardrail, ContentSafetyGuardrail } from "./services/guardrail-service.js";
Expected output:pnpm dev starts the Next.js dev server, the register() function fires (visible in server logs as “Review Response Agent started”), and all services are initialized.
Step 11: Create the API routes
Five API routes expose the agent’s capabilities. Start with the review generation endpoint at app/api/reviews/generate/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import { GenerateReviewResponseInputSchema } from "../../../../src/lib/schemas.js";import { BrandVoiceNotFoundError, GuardrailViolationError, ReviewGenerationError } from "../../../../src/lib/errors.js";import { ReviewResponseAgent } from "../../../../src/services/review-agent.js";import { withTaskSpan, withCorrelationId } from "../../../../src/services/observability-service.js";import { logger, tracer } from "../../../../src/services/observability-service.js";import { LlmService } from "../../../../src/services/llm-service.js";import { CacheService } from "../../../../src/services/cache-service.js";import * as guardrail from "../../../../src/services/guardrail-service.js";import { BrandVoiceService } from "../../../../src/services/brand-voice-service.js";const llm = new LlmService();const cache = new CacheService();const brandVoice = new BrandVoiceService();const agent = new ReviewResponseAgent({ llm, cache, guardrail, brandVoice, logger: withCorrelationId(logger, crypto.randomUUID()), taskCounter: { add: () => {} }, durationHistogram: { record: () => {} }, cacheHitCounter: { add: () => {} },});export async function POST(req: NextRequest): Promise<NextResponse> { try { const body = await req.json() as Record<string, unknown>; const parsed = GenerateReviewResponseInputSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Validation failed", details: parsed.error.issues }, { status: 400 }, ); } const lid = withCorrelationId(logger, crypto.randomUUID()); lid.info({ reqBody: body }, "Generating review response"); const result = await withTaskSpan(tracer, crypto.randomUUID(), "reviews.generate", async (span) => { span.setAttribute("restaurant.id", parsed.data.review.restaurantId); return agent.generateResponse(parsed.data); }); return NextResponse.json(result, { status: 200 }); } catch (error) { if (error instanceof BrandVoiceNotFoundError) { return NextResponse.json({ error: "Brand voice not found" }, { status: 404 }); } if (error instanceof GuardrailViolationError) { return NextResponse.json( { error: "Response blocked by guardrail", details: error.message }, { status: 422 }, ); } if (error instanceof ReviewGenerationError) { return NextResponse.json( { error: "Generation failed", message: error.message }, { status: 500 }, ); } throw error; }}
Next, the brand voice CRUD endpoint at app/api/reviews/brand-voice/route.ts:
Expected output:pnpm typecheck passes. All five routes use the App Router pattern — named GET, POST, and DELETE exports with NextRequest parameters and NextResponse.json() responses.
Step 12: Write the test suite
Create a test infrastructure file at tests/setup.ts that sets up MSW to mock the OpenAI-compatible endpoint:
ts
import { setupServer } from "msw/node";import { http, HttpResponse } from "msw";process.env.LLM_API_KEY = "sk-tes...-key";process.env.LLM_ENDPOINT = "https://api.openai.com/v1";export const server = setupServer( http.post("https://api.openai.com/v1/chat/completions", () => HttpResponse.json({ choices: [{ message: { role: "assistant", content: "Thank you for your review! We appreciate your feedback." } }], usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, }), ),);import { beforeAll, afterEach, afterAll } from "vitest";beforeAll(() => { server.listen({ onUnhandledRequest: "error" }); });afterEach(() => { server.resetHandlers(); });afterAll(() => { server.close(); });
Now create the core agent test at tests/services/review-agent.test.ts. This is the most important test file since it covers cache hits, cache misses, guardrail rejection, and guardrail retry recovery:
ts
import { describe, it, expect, vi } from "vitest";import { ReviewResponseAgent } from "../../src/services/review-agent.js";import { GuardrailViolationError } from "../../src/lib/errors.js";function createMockDeps() { const llm = { generateCompletion: vi.fn().mockResolvedValue({ text: "Thank you for your wonderful feedback! We are grateful.", inputTokens: 10, outputTokens: 8, }), }; const cache = { getCachedResponse: vi.fn().mockResolvedValue({ hit: false }), storeResponse: vi.fn().
Create the end-to-end test at tests/e2e.test.ts to verify the full flow — create a brand voice, generate a response (cache miss), generate the same response again (cache hit), and test guardrail rejection:
ts
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";import { http, HttpResponse } from "msw";import { NextRequest } from "next/server";import { server } from "./setup.js";const mockCacheGet = vi.hoisted(() => vi.fn());const mockCacheSet = vi.hoisted(() => vi.fn());const mockCacheInvalidate = vi.hoisted(() => vi.fn());const mockExtractAndStore = vi.hoisted(() => vi.fn());
Run the full test suite:
terminal
pnpm test
Expected output: All 108 tests pass with coverage meeting ≥90% thresholds on lines, branches, functions, and statements.
Next steps
Add PostgreSQL-backed agent memory — swap storage: { provider: "memory" } for storage: { provider: "postgres", connectionString: process.env.DATABASE_URL } in @reaatech/agent-memory for persistent brand voice storage across server restarts.
Replace the in-memory cache with a Redis adapter — @reaatech/llm-cache accepts alternative storage adapters for persistent, shared caching across multiple server instances.
Add sentiment-aware tone selection — detect review sentiment before generation and automatically select the appropriate tone (apologetic for negative reviews, grateful for positive ones).
Extend to multi-location chains — add a parent-child restaurant hierarchy so a group of restaurants can share brand voice settings while allowing per-location overrides.
function buildSystemPrompt(brandVoice: GenerateReviewResponseInput["brandVoice"], review: GenerateReviewResponseInput["review"]): string {
return `You are the owner/manager of restaurant "${brandVoice.name}". Reply to this ${review.platform} review with tone: ${brandVoice.tone}. Guidelines: ${brandVoice.guidelines.join("; ")}. Use greeting: ${brandVoice.greetingStyle}. Sign: ${brandVoice.signatureName}.`;
}
function buildUserPrompt(review: GenerateReviewResponseInput["review"]): string {
return `Review by ${review.reviewerName}, ${String(review.rating)}/5 stars: ${review.body}`;