After a rough alignment job, a customer posts a one-star review on Google. The shop owner is too busy turning wrenches to reply promptly, and the negative review festers, scaring away potential customers. When they finally do respond, it's rushed and defensive, making things worse. The shop's online reputation is fragile; a few unanswered complaints can tank their rating. The owner needs a way to generate empathetic, accurate responses that reference the actual work order without sounding robotic.
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 tire shops using Next.js 16 (App Router) and Hono. The agent ingests customer reviews from Google, Yelp, or Facebook, runs them through a guardrail chain (PII redaction, toxicity filtering, topic boundary checking, sentiment analysis), routes the request to the best LLM model based on cost and capability, generates a context-aware empathetic response referencing the customer’s work order, and surfaces it in a dashboard for approval. You’ll wire up six REAA packages — agent-memory-core, agent-memory-retrieval, llm-router-core, llm-router-strategies, guardrail-chain, and guardrail-chain-guardrails — plus Langfuse for observability and the OpenAI SDK as your agnostic LLM provider.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
An OpenAI-compatible API key (or any LLM provider that speaks the OpenAI chat completions format)
A Langfuse account or self-hosted instance for tracing (optional but recommended)
Basic familiarity with TypeScript, Next.js App Router, and REST APIs
Step 1: Scaffold the project and install dependencies
Expected output:pnpm install completes without errors. pnpm-lock.yaml is created and all packages appear under node_modules/.
Step 2: Define the domain types
Create src/lib/types.ts. These types model the core entities: reviews, responses, work orders, tone profiles, and the request/result shapes that flow through the pipeline.
Step 3: Create the in-memory store for memory retrieval
The MemoryRetriever from @reaatech/agent-memory-retrieval calls a storage.searchSimilar() method with an embedding vector and options. Create src/adapters/in-memory-store.ts to implement this duck-typed interface using cosineSimilarity from @reaatech/agent-memory-core.
ts
import { type Memory, type MemoryId, type HealthStatus, cosineSimilarity,} from "@reaatech/agent-memory-core";interface SearchOptions { tenantId?: string; limit?: number;}export class InMemoryStore { private memories: Memory[] = []; add(memory: Memory): void { this.memories.push(memory); } async create(memory:
Step 4: Build the simple embedder
The MemoryRetriever expects an embeddingProvider.embed() method. Create src/adapters/simple-embedder.ts — a deterministic hash-to-vector embedder that converts text to a unit vector without any external API calls.
ts
export class SimpleEmbedder { private dimensions: number; constructor(config?: { dimensions?: number }) { this.dimensions = config?.dimensions ?? 128; } async embed(text: string): Promise<number[]> { const vector: number[] = []; for (let i = 0; i < this.dimensions; i++) { vector.push(0); } const chars = text.split(""); for (let i = 0; i < chars.length; i++) { const code = chars[i]?.charCodeAt(0) ?? 0; vector[i % this.dimensions] += code; } const magnitude = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0)); if (magnitude > 0) { for (let i = 0; i < vector.length; i++) { vector[i] /= magnitude; } } return await Promise.resolve(vector); } async embedBatch(texts: string[]): Promise<number[][]> { return Promise.all(texts.map((t) => this.embed(t))); } getModelInfo() { return { name: "simple-embedder", dimensions: this.dimensions, maxInputLength: 8192 }; }}
Expected output: The embedder produces identical vectors for identical input, and empty strings return a uniform zero vector of length 128.
Step 5: Wire up the memory service with REAA packages
Create src/lib/memory.ts. The MemoryService class uses MemoryRetriever and ContextInjector from @reaatech/agent-memory-retrieval, along with Memory, MemoryType, MemoryImportance, MemorySource, MemoryLifecycle, and withRetry from @reaatech/agent-memory-core.
ts
import { type Memory, MemoryType, MemoryImportance, MemorySource, MemoryLifecycle, withRetry, type ConversationTurn,} from "@reaatech/agent-memory-core";import { MemoryRetriever, ContextInjector, RetrievalStrategy,} from "@reaatech/agent-memory-retrieval";import { InMemoryStore } from "../adapters/in-memory-store.js";import { SimpleEmbedder } from "../adapters/simple-embedder.js";import type { Review, WorkOrder } from "./types.js";export class MemoryService { private store: InMemoryStore
Expected output:storeReview maps ratings to importance levels (5-star → CRITICAL, 1-star → TRANSIENT) and embeds the review text into a 128-dimensional vector. retrieveContext wraps the retriever call in automatic retry logic.
Step 6: Create the LLM client abstraction
Create src/lib/llm-client.ts — a factory that wraps the OpenAI SDK with a custom baseURL, making the provider agnostic. Point it at any OpenAI-compatible endpoint.
Step 7: Build the LLM router with cost and capability strategies
Create src/lib/llm-router.ts. The routing service uses ModelDefinitionSchema and RoutingRequestSchema from @reaatech/llm-router-core and the CapabilityBasedStrategy, CostOptimizedStrategy, and StrategyOrchestrator from @reaatech/llm-router-strategies.
ts
import { ModelDefinitionSchema, RoutingRequestSchema, type ModelDefinition, type RoutingRequest, type RoutingContext, type RoutingDecision,} from "@reaatech/llm-router-core";import { CostOptimizedStrategy, CapabilityBasedStrategy, StrategyOrchestrator,} from "@reaatech/llm-router-strategies";const modelCatalog: ModelDefinition[] = [ ModelDefinitionSchema.parse({ id: "workhorse", provider: "agnostic", costPerMillionInput: 1, costPerMillionOutput: 2, maxTokens: 4096, capabilities: ["general"], }), ModelDefinitionSchema.parse({ id: "premium", provider: "agnostic", costPerMillionInput: 15, costPerMillionOutput: 60, maxTokens: 128000, capabilities: ["reasoning", "general", "code"], }),];export class RoutingService { private orchestrator: StrategyOrchestrator; constructor() { this.orchestrator = new StrategyOrchestrator(); this.orchestrator.register( new CapabilityBasedStrategy({ preferredModels: { reasoning: ["premium"], general: ["workhorse"], code: ["premium"], }, defaultModel: "workhorse", }), ); this.orchestrator.register( new CostOptimizedStrategy({ workhorsePool: ["workhorse", "premium"], budgetPerRequest: 0.1, }), ); } evaluate(request: RoutingRequest): RoutingDecision { const validated = RoutingRequestSchema.parse(request); const context: RoutingContext = { timestamp: new Date(), requestId: crypto.randomUUID(), latencyHistory: new Map(), circuitBreakerStates: new Map(), remainingBudget: 1.0, }; const evaluation = this.orchestrator.evaluate(validated, context, modelCatalog); if (!evaluation) { throw new Error("No strategy could select a model"); } return { modelId: evaluation.model.id, strategy: evaluation.strategy.name, estimatedCost: 0, estimatedInputTokens: 0, estimatedOutputTokens: 0, isFallback: false, fallbackPosition: 0, alternativesConsidered: evaluation.selectionResult.alternatives.map((m) => m.id), selectionReason: evaluation.selectionResult.reason, }; }}export const routingService = new RoutingService();
Expected output: The RoutingService defines two models — a cheap “workhorse” for general tasks ($1/M input tokens) and a “premium” model for reasoning tasks ($15/M input tokens). The orchestrator picks the right one based on the request’s required capabilities and cost budget.
Step 8: Build the guardrail chain
Create src/lib/guardrails.ts. The guardrail service chains five guardrails from @reaatech/guardrail-chain-guardrails using the ChainBuilder from @reaatech/guardrail-chain.
Expected output: The guardrail chain runs in under 1000ms with an 8000-token budget. Slow guardrails can be skipped when the budget is tight.
Step 9: Set up observability with Langfuse
Create src/lib/observability.ts — a singleton Langfuse client with graceful degradation (returns null on init failure so callers don’t crash).
ts
import Langfuse from "langfuse";let client: Langfuse | null = null;export function getLangfuseClient(): Langfuse | null { if (client) return client; try { client = new Langfuse({ publicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "", secretKey: process.env.LANGFUSE_SECRET_KEY ?? "", baseUrl: process.env.LANGFUSE_HOST ?? "https://cloud.langfuse.com", }); } catch (err) { console.warn("Langfuse initialization failed:", err); return null; } return client;}export function createTrace(reviewId: string, tenantId: string) { const c = getLangfuseClient(); if (!c) return null; return c.trace({ id: reviewId, name: "review-response", sessionId: tenantId });}export function createSpan( trace: ReturnType<typeof createTrace>, name: string, input: object,) { if (!trace) return null; return trace.span({ name, input });}export function finalizeSpan(span: ReturnType<typeof createSpan>, output: object) { if (span) { span.end({ output }); }}export function finalizeTrace( trace: ReturnType<typeof createTrace>, result: object,) { if (trace) { trace.update({ output: result }); }}
Step 10: Implement the response service (the pipeline orchestrator)
Create src/services/response-service.ts. This is the heart of the agent. It orchestrates the full pipeline: create a Langfuse trace, retrieve work-order context from memory, run input guardrails, route to the best LLM model, generate a response, run output guardrails, store the result, and finalize the trace.
ts
import { memoryService } from "../lib/memory.js";import { routingService } from "../lib/llm-router.js";import { guardrailService } from "../lib/guardrails.js";import { createLLMProvider, DEFAULT_LLM_CONFIG } from "../lib/llm-client.js";import { createTrace, createSpan, finalizeSpan, finalizeTrace } from "../lib/observability.js";import type { Review, ReviewProcessingRequest, ReviewProcessingResult } from "../lib/types.js";import { RoutingRequestSchema } from "@reaatech/llm-router-core";function formatDate(d: Date): string { return d.toLocaleDateString("en-US", { year: "numeric", month: "short"
Step 11: Create the Hono API with review and response routes
Start with the Hono app at src/api/app.ts. It mounts review and response routers and exposes a health endpoint.
ts
import { Hono } from "hono";import { cors } from "hono/cors";import { reviewRoutes } from "./routes/reviews.js";import { responseRoutes } from "./routes/responses.js";type Bindings = { LLM_API_KEY: string; LLM_BASE_URL: string; LANGFUSE_PUBLIC_KEY: string; LANGFUSE_SECRET_KEY: string;};type Variables = { tenantId: string;};const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();app.use("*", cors());app.route("/api/reviews", reviewRoutes);app.route("/api/responses", responseRoutes);app.get("/api/health", (c) => c.json({ status: "ok", timestamp: new Date().toISOString() }));export default app;
Now create the review routes at src/api/routes/reviews.ts. These handle creating, listing, fetching, and responding to reviews — and also store each review as an embeddable memory in the in-memory store.
ts
import { Hono } from "hono";import { zValidator } from "@hono/zod-validator";import { z } from "zod";import type { Review, ReviewSource } from "../../lib/types.js";import { InMemoryStore } from "../../adapters/in-memory-store.js";import { MemoryType, MemoryImportance, MemorySource, MemoryLifecycle } from "@reaatech/agent-memory-core";import type { Memory } from "@reaatech/agent-memory-core";type Variables = { tenantId: string };const CreateReviewSchema = z.object({ platform: z.enum(["GOOGLE",
Now create the response routes at src/api/routes/responses.ts. These handle generating, bulk-generating, history lookup, and approving responses.
Step 13: Configure instrumentation and environment variables
Set up next.config.ts with experimental.instrumentationHook so the startup instrumentation fires, then create src/instrumentation.ts to warm up the Langfuse client on boot.
// src/instrumentation.tsexport async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { getLangfuseClient } = await import("./lib/observability.js"); getLangfuseClient(); }}
Create the .env.example file. Every process.env.X reference in the code must have a placeholder here.
env
# Env vars used by agnostic-review-response-agent-3.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentLLM_API_KEY=<your-llm-api-key>LLM_BASE_URL=https://api.openai.com/v1LLM_MODEL=<your-model-id>LLM_MAX_TOKENS=2048LANGFUSE_PUBLIC_KEY=pk-lf-<your-public-key>LANGFUSE_SECRET_KEY=sk-lf-<your-secret-key>LANGFUSE_HOST=https://cloud.langfuse.comDEFAULT_TENANT_ID=<your-tenant-id>
Step 14: Run the tests
The project includes 91 tests across 41 test suites covering every module — types, adapters, services, routes, and the full Hono API integration. Run them with vitest.
Add a real embedding provider — swap the SimpleEmbedder for OpenAI’s embeddings API or a local model via Ollama to get semantically richer memory retrieval
Connect a database — replace the in-memory Map-based storage with PostgreSQL via Prisma or Drizzle so data survives restarts
Add webhook ingestion — create an endpoint that accepts review notifications from Google Business Profile and Yelp APIs automatically
Add email/SMS alerts — use Resend or Twilio to notify the shop owner when a new negative review comes in
const systemPrompt = `You are a ${req.toneProfile} customer service agent for a tire shop named "Rolling Rubber Tire & Auto". Given the customer review and relevant work order details, write a ${req.toneProfile}, personalized response that acknowledges their specific concern and demonstrates the shop values their business. Keep responses under 300 words.`;
return `Thank you for your feedback, ${review.authorName}. We take every review seriously at our shop. A member of our team will reach out to discuss your experience with your ${formatDate(review.createdAt)} visit. For immediate assistance, please call us at (555) 0123-4567.`;