The restaurant manager spends 30 minutes each morning scanning review sites, but replies are rushed, defensive, or forgotten entirely. Inconsistent tone frustrates guests and hurts the restaurant's online reputation. With thin margins and a skeleton crew, there's no dedicated marketing person to handle this. A single bad review left unanswered can snowball into lost reservations.
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.
Running a small restaurant means you already have your hands full with the dining room, the kitchen, and the payroll — and yet every morning there’s a fresh crop of Yelp, Google, and TripAdvisor reviews that need responses. Reply too slowly or with a defensive tone and you risk losing future reservations. This tutorial builds a Review Response Automator — a Next.js API that ingests restaurant reviews, classifies sentiment using LLMs, generates on-brand responses, escalates negative experiences to a human, and keeps a memory of every interaction. By the end you’ll have a running API that processes a review end-to-end in a single POST call.
Prerequisites
Node.js >= 22 and pnpm 10.x installed on your machine
An OpenAI API key with access to chat models (the recipe uses gpt-5.2 as the default)
A Firecrawl API key for scraping review pages
A Langfuse account and API keys (public + secret) for observability tracing
Basic familiarity with TypeScript and REST APIs
Step 1: Scaffold the project and install dependencies
Create an empty directory and add a package.json with the dependencies pinned below — every version is exact (no ^ or ~ prefixes). Then run pnpm install to hydrate node_modules.
Expected output:pnpm install completes with no errors and a pnpm-lock.yaml is generated.
Step 2: Set environment variables
Create a .env.local file (or copy .env.example) with the keys the API needs at runtime:
env
# Env vars used by agnostic-review-response-agent-2.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-key>FIRECRAWL_API_KEY=<your-firecrawl-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=<your-langfuse-host>
Expected output: No visible output, but your app can now read these variables via process.env.
Step 3: Define the domain types
Create src/types.ts with the interfaces and union types that model a restaurant review ecosystem. Every service downstream imports these types.
Then create src/lib/brand-voice.ts — this is the prompt-engineering layer that turns a RestaurantProfile into a system message for the LLM:
ts
import type { RestaurantProfile, Review } from "../types";export function buildSystemPrompt(profile: RestaurantProfile): string { const guidelines = profile.toneGuidelines.length > 0 ? `- Tone guidelines: ${profile.toneGuidelines.join("; ")}` : ""; return [ `You are writing a public response on behalf of "${profile.name}".`, `Brand voice: ${profile.brandVoice}`, guidelines, "", "Rules:", "- Always thank the reviewer by referencing their specific feedback.", "- Keep responses concise and genuine — never defensive.", "- For positive reviews: express gratitude and invite them back.", "- For negative reviews: apologize sincerely, acknowledge the issue, and mention improvements without making excuses.", "- Never make promises you cannot keep (no \"we'll fix it\" unless certain).", "- Never use excessive emoji or overly casual language.", "- Sign with the restaurant name or a generic \"Management\" signature.", ] .filter(Boolean) .join("\n");}export function formatReviewForPrompt(review: Review): string { return [ `Author: ${review.authorName}`, `Rating: ${String(review.rating)}/5`, `Title: ${review.title}`, `Content: ${review.content}`, ].join("\n");}
Expected output: Both files typecheck. buildSystemPrompt returns a multi-line string with the restaurant’s name interpolated into the first line.
Step 6: Build the LLM service
Create src/services/llm-service.ts. This service wraps the Vercel AI SDK’s generateText and generateObject functions, calling OpenAI through the @ai-sdk/openai adapter.
ts
import { generateText, generateObject } from "ai";import { openai } from "@ai-sdk/openai";import type { Review, RestaurantProfile, GenerationOptions, SentimentClassification, KeyPoints } from "../types";import { SentimentOutputSchema, KeyPointsSchema } from "../schemas";import { buildSystemPrompt, formatReviewForPrompt } from "../lib/brand-voice";export type { KeyPoints };export class LlmService { constructor(private readonly modelId: string) {} async classifySentiment(reviewContent: string): Promise<SentimentClassification> { const result = await generateObject({ model: openai(this.modelId), schema: SentimentOutputSchema, prompt: `Classify this restaurant review:\n\n${reviewContent}`, }); return result.object; } async generateReviewResponse( review: Review, profile: RestaurantProfile, options?: GenerationOptions, ): Promise<string> { const result = await generateText({ model: openai(this.modelId), system: buildSystemPrompt(profile), messages: [{ role: "user", content: formatReviewForPrompt(review) }], maxTokens: options?.maxLength ?? 500, } as Parameters<typeof generateText>[0] & { maxTokens: number }); return result.text; } async extractKeyPoints(reviewContent: string): Promise<KeyPoints> { const result = await generateObject({ model: openai(this.modelId), schema: KeyPointsSchema, prompt: `Extract key points from this restaurant review:\n\n${reviewContent}`, }); return result.object; }}
Expected output:pnpm typecheck passes. The service depends on OPENAI_API_KEY being set at runtime.
Step 7: Add observability with Langfuse
Create src/services/observability-service.ts. This wraps the Langfuse SDK to trace every review-processing run and every LLM call. The constructor reads baseUrl from LANGFUSE_BASE_URL and passes it through to the Langfuse client.
Guardrail service (src/services/guardrail-service.ts) — validates that incoming review content and outgoing responses meet quality and length criteria using the @reaatech/guardrail-chain package:
ts
import { ChainBuilder, setLogger, ConsoleLogger, type Guardrail, type GuardrailResult, type ChainContext,} from "@reaatech/guardrail-chain";const INPUT_MAX_LENGTH = 5000;class InputGuardrail implements Guardrail<string, string> { readonly id = "input-content-check"; readonly name = "Input Content Check"; readonly type = "input" as const; enabled = true; execute(input: string, _context: ChainContext): Promise<GuardrailResult<string>> { if (!input || input.trim().length === 0) { return Promise.resolve({ passed: false, error: new Error("content empty") }); } if (input.length > INPUT_MAX_LENGTH) { return Promise.resolve({ passed: false, error: new Error("content exceeds maximum length") }); } return Promise.resolve({ passed: true, output: input }); }}class OutputGuardrail implements Guardrail<string, string> { readonly id = "output-brand-check"; readonly name = "Output Brand Check"; readonly type = "output" as const; enabled = true; execute(input: string, _context: ChainContext): Promise<GuardrailResult<string>> { if (!input || input.trim().length === 0) { return Promise.resolve({ passed: false, error: new Error("response is empty") }); } return Promise.resolve({ passed: true, output: input }); }}export class GuardrailService { private chain = new ChainBuilder() .withBudget({ maxLatencyMs: 1000, maxTokens: 2000 }) .withGuardrail(new InputGuardrail()) .withGuardrail(new OutputGuardrail()) .withSlowGuardrailSkipping(true) .withErrorHandling({ maxRetries: 2, retryDelayMs: 200 }) .build(); constructor() { setLogger(new ConsoleLogger()); } async validateReviewContent(text: string): Promise<{ passed: boolean; error?: string }> { const result = await this.chain.execute(text); if (result.success) { return { passed: true }; } return { passed: false, error: result.error ?? "validation failed" }; } async validateResponse(text: string): Promise<{ passed: boolean; error?: string }> { const result = await this.chain.execute(text); if (result.success) { return { passed: true }; } return { passed: false, error: result.error ?? "validation failed" }; }}
Handoff service (src/services/handoff-service.ts) — manages human escalation via typed events and provides a retry wrapper for flaky operations:
Scraper service (src/services/scraper-service.ts) — uses the Firecrawl SDK to scrape review pages. The npm package is @mendable/firecrawl-js but the module you import from is "firecrawl":
Expected output: All four files typecheck independently. Each class can be instantiated once the relevant env vars are set.
Step 9: Build the A2A agent card service
Create src/services/a2a-service.ts — this exposes an Agent-to-Agent (A2A) protocol card so other agents can discover and interact with your review agent programmatically:
Expected output:pnpm typecheck passes. The A2A imports are verified against the @reaatech/a2a-reference-core, @reaatech/a2a-reference-client, and @reaatech/a2a-reference-server packages.
Step 10: Wire the review pipeline
Create src/services/review-pipeline.ts — this class orchestrates every service you’ve built into a single processReview method that runs the real-world flow: guardrail → sentiment analysis → escalation check → response generation → output guardrail → memory storage → observability trace.
ts
import type { Review, RestaurantProfile, GenerationOptions, ReviewProcessingResult } from "../types";import type { LlmService } from "./llm-service";import type { MemoryService } from "./memory-service";import type { GuardrailService } from "./guardrail-service";import type { HandoffService } from "./handoff-service";import type { ObservabilityService } from "./observability-service";import crypto from "crypto";export class ReviewPipeline { constructor( private readonly llm: LlmService, private readonly
Expected output:pnpm typecheck passes. The pipeline is a plain class — no HTTP or framework coupling — so it works under any Node.js context.
Step 11: Create the Next.js API routes
Now wire the pipeline into the App Router. Start with the health endpoint at app/api/health/route.ts:
ts
import type { NextRequest } from "next/server";import { NextResponse } from "next/server";export async function GET(_req?: NextRequest) { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), });}
Then the main reviews endpoint at app/api/reviews/route.ts — this accepts POST (process a single review) and GET (list reviews with query filters):
ts
import { NextRequest, NextResponse } from "next/server";import { ReviewSchema, ReviewQuerySchema } from "@/src/schemas";import { LlmService } from "@/src/services/llm-service";import { MemoryService } from "@/src/services/memory-service";import { GuardrailService } from "@/src/services/guardrail-service";import { HandoffService } from "@/src/services/handoff-service";import { ObservabilityService } from "@/src/services/observability-service";import { ReviewPipeline } from "@/src/services/review-pipeline";import crypto from "crypto";const pipeline = new ReviewPipeline( new LlmService("gpt-5.2"), new MemoryService(), new GuardrailService(), new HandoffService(), new ObservabilityService(),);export async function POST(req: NextRequest) { try { const body: unknown = await req.json(); const parseResult = ReviewSchema.safeParse(body); if (!parseResult.success) { return NextResponse.json({ error: "invalid_review" }, { status: 400 }); } const data = parseResult.data; const review: import("@/src/types").Review = { id: crypto.randomUUID(), source: data.source, authorName: data.authorName, rating: data.rating, title: data.title, content: data.content, scrapedAt: new Date(), status: "pending", ...(data.url ? { url: data.url } : {}), }; const result = await pipeline.processReview(review, { name: "Unknown Restaurant", toneGuidelines: ["warm and professional"], brandVoice: "Friendly and appreciative neighborhood restaurant", responseTimeGoalHrs: 24, }); return NextResponse.json(result, { status: 201 }); } catch { return NextResponse.json({ error: "invalid_review" }, { status: 400 }); }}export function GET(req: NextRequest) { const queryResult = ReviewQuerySchema.safeParse( Object.fromEntries(req.nextUrl.searchParams.entries()), ); if (!queryResult.success) { return NextResponse.json({ error: "invalid_query" }, { status: 400 }); } return NextResponse.json({ items: [], total: 0, page: queryResult.data.page, });}
The individual review lookup at app/api/reviews/[id]/route.ts uses Next.js’s async params pattern:
Here is a walkthrough of what each test file covers:
tests/schemas.test.ts — validates that ReviewSchema accepts a well-formed review, rejects empty content, rejects out-of-range ratings, and that ReviewQuerySchema rejects invalid source enum values.
tests/services/llm-service.test.ts — mocks generateObject and generateText; tests classifySentiment, generateReviewResponse, extractKeyPoints, and the boundary case of a maxLength cap.
tests/services/memory-service.test.ts — mocks AgentMemory; asserts storeReviewInteraction calls extractAndStore, runMaintenance and close resolve.
tests/services/guardrail-service.test.ts — tests the input/content guardrail with empty, valid, and too-long content; tests the output guardrail with empty and valid responses; tests the full chain integration.
tests/services/handoff-service.test.ts — tests that escalateToHuman emits events to listeners, that shouldEscalate returns the correct boolean for positive/negative/low-confidence reviews, and that withRetry retries on failure.
tests/services/scraper-service.test.ts — mocks Firecrawl endpoints; tests scrapeReviewPage and extractReviewsFromPage with success, error, and empty-URL scenarios.
tests/services/a2a-service.test.ts — tests buildAgentCard returns a valid AgentCard, createClient returns an A2AClient, sendTask and getTaskStatus propagate correctly.
tests/services/observability-service.test.ts — mocks Langfuse; asserts traceReviewProcessing and traceLlmCall call the underlying SDK without throwing.
tests/services/review-pipeline.test.ts — the core integration-tested-in-isolation: happy path returns a response, guardrail failure returns passed: false, escalation for negative review returns response: null, batch processing handles partial failures.
tests/app/api/reviews.test.ts — full route handler tests: valid POST returns 201, empty content returns 400, empty body returns 400, GET with valid query returns 200, invalid query returns 400.
tests/app/api/reviews-id.test.ts — GET by ID returns 404 (no persistence in this recipe).
tests/app/api/agent-card.test.ts — GET returns 200 with name, capabilities, skills.
tests/app/api/health.test.ts — GET returns 200 with status: "ok".
tests/middleware.test.ts — asserts X-Request-Id is set on API routes.
tests/integration/review-flow.test.ts — end-to-end test: submits a valid review via POST /api/reviews, asserts 201 with guardrailPassed: true; submits empty review, asserts 400; submits a 1-star negative review, asserts escalated: true and response: null.
Run the full suite with:
terminal
pnpm test
Run type checking and linting separately:
terminal
pnpm typecheckpnpm lint
Expected output:
pnpm typecheck exits 0 with no errors.
pnpm lint exits 0 with no warnings.
pnpm test prints a JSON report with numFailedTests: 0, numTotalTests >= 3, and coverage metrics (lines, branches, functions, statements) all above 90%.
Next steps
Replace the in-memory storage with a real database — wire PostgreSQL or SQLite into the GET /api/reviews endpoint so you can store, list, and paginate past reviews.
Add a calendar-scheduling A2A skill — extend the agent card with a second skill that books follow-up calls when a review is escalated, using the @reaatech/a2a-reference-server package to build a sub-agent.
Hook up Slack or email notifications — subscribe to the handoff service’s escalation events to push alerts to the restaurant manager’s team channel instead of relying on polling.