A WooCommerce merchant loses sales every night because shoppers have sizing or material questions that go unanswered. The owner or a single contractor handles support during business hours, but after-hours inquiries often result in abandoned carts. Returns due to wrong fit are the #1 cost driver, yet the merchant has no way to proactively guide customers to the right size.
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.
In this tutorial you’ll build a pre-purchase fit advisor for WooCommerce merchants — an AI-powered API that answers product-fit questions 24/7. When a shopper asks “Will these jeans fit a 32-inch waist?”, the advisor searches your product catalog, retrieves any saved sizing preferences for that customer, calls an LLM with full context, and returns a personalised recommendation with a confidence score. Answers are cached so repeated questions get instant responses.
You’ll wire six @reaatech/* packages into a Next.js 16+ App Router project with a Hono router handling the API layer, and the Vercel AI SDK driving LLM calls. By the end you’ll have a working API with three endpoints, a full test suite, and a blueprint you can extend to any product catalog.
Prerequisites
Node.js >= 22 and pnpm 10.x installed on your machine
An OpenAI-compatible API key (any provider supported by the Vercel AI SDK)
Familiarity with TypeScript, Next.js App Router, and basic REST API concepts
A terminal open in an empty project directory
Step 1: Scaffold the Next.js project
Start by creating a package.json with exact-pinned versions for every dependency:
Create the .env.example file with placeholder values for the environment variables the app reads at runtime:
env
# Env vars used by agnostic-pre-purchase-fit-agent.# Keep placeholders only — never commit real values.OPENAI_API_KEY=<your-openai-api-key>LLM_MODEL=gpt-5.2-miniLANGFUSE_SECRET_KEY=<your-langfuse-secret>LANGFUSE_PUBLIC_KEY=<your-langfuse-public>LANGFUSE_BASE_URL=https://cloud.langfuse.comPOSTGRES_URL=<your-postgres-connection-string>
Copy the example to .env and fill in your OPENAI_API_KEY:
terminal
cp .env.example .env
Expected output:pnpm install creates node_modules/ and pnpm-lock.yaml. cat .env shows your API key in place.
Step 2: Configure Next.js and set up instrumentation
Create next.config.ts at the project root. The experimental.instrumentationHook flag is required for src/instrumentation.ts to fire its register() function at startup:
Now create the instrumentation file at src/instrumentation.ts. This initialises Langfuse for observability when the Node.js server starts:
ts
declare global { var __langfuse: object | undefined;}export async function register(): Promise<void> { if (process.env.NEXT_RUNTIME !== "nodejs") return; if (process.env.LANGFUSE_SECRET_KEY && process.env.LANGFUSE_PUBLIC_KEY) { const { Langfuse } = await import("langfuse"); const langfuse = new Langfuse({ secretKey: process.env.LANGFUSE_SECRET_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY, baseUrl: process.env.LANGFUSE_BASE_URL ?? "https://cloud.langfuse.com", }); globalThis.__langfuse = langfuse; }}
The NEXT_RUNTIME guard prevents the dynamic langfuse import from running in the Edge runtime. If Langfuse keys aren’t set, the setup is silently skipped — the app works fine without it.
Expected output:pnpm typecheck exits 0 with no errors.
Step 3: Define core types and schemas
Create src/lib/types.ts. These interfaces describe every data shape the app moves around — products, sizing charts, conversation turns, and API request/response payloads:
Note z.coerce.date() on timestamp — this lets callers send ISO 8601 strings in JSON and have them auto-coerced to Date objects. The .min(1) constraint on messages ensures at least one conversation turn is present.
Expected output: TypeScript validates all interfaces and the Zod schema. Importing ChatRequestSchema and calling parse({messages: []}) throws a ZodError.
Step 4: Create the LLM adapter
Create src/lib/llm.ts. This module wraps the Vercel AI SDK’s generateText with your app’s ConversationTurn type, model configuration, and error handling:
System messages from the conversation are appended to the systemPrompt rather than passed in the messages array, keeping the AI SDK’s system parameter as the single source of truth.
Model resolution falls back through config.model → LLM_MODEL env var → gpt-5.2-mini.
Null token counts from usage are safely defaulted to 0.
The ErrorWithStatus interface extracts HTTP status codes from API errors (429, 503, etc.) so callers can implement appropriate retry logic.
Step 5: Create the database adapter
Create src/lib/db.ts. This provides a typed query wrapper around @vercel/postgres and a helper for registering pgvector types:
ts
import { sql } from "@vercel/postgres";import pgvector from "pgvector/pg";export function registerVectorTypes(client: import("pg").ClientBase): void { void pgvector.registerTypes(client);}export async function query<T = Record<string, unknown>>( sqlStr: TemplateStringsArray | string, ...params: (string | number | boolean | undefined | null)[]): Promise<T[]> { const result = await sql(sqlStr as TemplateStringsArray, ...params); return result.rows as T[];}export function createDbPool(): { query: typeof query; registerVectorTypes: typeof registerVectorTypes } | null { if (process.env.POSTGRES_URL) { return { query, registerVectorTypes }; } return null;}
The adapter is optional — createDbPool() returns null when POSTGRES_URL is not set, so the app works fully in-memory during development or when PostgreSQL isn’t configured.
Expected output: Importing createDbPool without POSTGRES_URL set returns null.
Step 6: Build the product catalog
Create src/services/product-catalog.ts. This is an in-memory catalog seeded with 11 ecommerce products across categories like tops, bottoms, footwear, outerwear, dresses, and accessories. Each product has realistic sizing data:
ts
import type { Product, SizingInfo } from "../lib/types.js";export class ProductCatalog { private products: Product[] = []; constructor() { this.seed(); } private seed(): void { this.products = [ { id: "tshirt-01", name: "Classic Cotton T-Shirt", category: "tops", description: "Soft cotton crew-neck tee for everyday wear.", sizes: ["S", "M", "L", "XL", "2XL",
Now implement the query methods. The search method does case-insensitive matching across name, category, description, and materials — splitting the query into words for partial matches:
The getSizingGuide method returns category-specific sizing data — footwear gets half-size notes for wide feet, bottoms get waist/hip measurements based on numeric sizing, and tops/outerwear/dresses return a standard chest/waist/hip chart:
Close the class and you have a complete product catalog.
Expected output:new ProductCatalog().search("jeans") returns the Slim Fit Stretch Jeans entry. listAll() returns 11 products.
Step 7: Wire up agent memory
Create src/services/memory-service.ts. This uses three REAA packages to give the advisor persistent memory of customer sizing preferences:
ts
import { AgentMemory, OpenAILLMProvider, MemoryType } from "@reaatech/agent-memory";import { ContextInjector } from "@reaatech/agent-memory-retrieval";import { InMemoryMemoryStorage } from "@reaatech/agent-memory-storage";import type { ConversationTurn, CustomerProfile } from "../lib/types.js";export class MemoryService { private memory: AgentMemory; private injector: ContextInjector; constructor() { const storage = new InMemoryMemoryStorage(); this.memory = new AgentMemory({ storage, embedding: { provider: "openai", model: "text-embedding-3-small", apiKey: process.env.OPENAI_API_KEY ?? "", }, extraction: { llmProvider: new OpenAILLMProvider({ apiKey: process.env.OPENAI_API_KEY ?? "", model: "gpt-4o-mini", }), enabledTypes: [ MemoryType.FACT, MemoryType.PREFERENCE, MemoryType.CORRECTION, ], batchSize: 10, confidenceThreshold: 0.7, }, }); this.injector = new ContextInjector(100000, 4); }
Now implement the methods that store and retrieve memories. The _customerId parameter is accepted for future use but explicitly unused with void to satisfy the linter:
The speaker mapping (user/agent) aligns with what @reaatech/agent-memory expects. The ContextInjector token-budgets the injected memories to fit within a 4,000-token limit. getCustomerProfile separates PREFERENCE and FACT memory types into preferences and measurements respectively.
Expected output:new MemoryService() creates the internal AgentMemory without throwing.
Step 8: Add LLM response caching
Create src/services/cache-service.ts. This wraps the @reaatech/llm-cache package to cache fit advice so repeated questions return instantly:
The similarity.threshold: 0.85 means semantically similar questions (e.g. “Will these fit?” and “Do these run true to size?”) can match a cached answer. The ttl.default: 3600 (1 hour) ensures answers stay fresh over time.
Create src/services/fit-advisor.ts. This is the core orchestrator — it receives a question, checks the cache, searches products, retrieves customer memory, calls the LLM, and stores the interaction:
ts
import { CacheService } from "./cache-service.js";import { MemoryService } from "./memory-service.js";import { ProductCatalog } from "./product-catalog.js";import { generateResponse } from "../lib/llm.js";import type { FitAnswer, ConversationTurn } from "../lib/types.js";export class FitAdvisor { constructor( private cacheService: CacheService, private memoryService: MemoryService, private productCatalog: ProductCatalog, private model?: string, ) {}
The answerQuestion method is the main entry point. It starts by validating input, checking the cache, and returning immediately on a hit:
Then it builds a system prompt that includes all this context and calls the LLM:
ts
const productContext = relevantProducts.length > 0 ? `Relevant products:\n${relevantProducts.map((p) => `- ${p.name} (${p.category}): ${p.description}. Sizes: ${p.sizes.join(", ")}. Materials: ${p.materials.join(", ")}`).join("\n")}` : "No specific products match the query. Provide general fit guidance."; const systemPrompt = `You are a helpful pre-purchase fit advisor for an online clothing store. \Your job is to help customers find the right size and fit for products they are interested in.${productContext}${sizingContext}${memoryContext ? `Customer context:\n${memoryContext}` : ""}Answer the customer's question about product fit. Be specific about sizing, mention measurements when available, \and flag uncertainty if you don't have enough information. Include a confidence rating as <confidence>0-100</confidence> in your response.`; const llmResult = await generateResponse(systemPrompt, messages, { model: resolvedModel, });
After the LLM responds, the method parses the confidence tag, constructs the FitAnswer, stores the interaction in memory, and caches the result:
Confidence defaults to 70 if the LLM doesn’t include a <confidence> tag in its response. The cache is always updated after a fresh LLM call so the next identical question returns instantly.
Expected output: Constructing new FitAdvisor(cache, memory, catalog) works. Calling answerQuestion with a question about jeans returns a FitAnswer with sources listing the jeans product.
Step 10: Create the evaluation service
Create src/services/evaluation-service.ts. This uses @reaatech/rag-eval-core and @reaatech/agent-handoff to support RAG evaluation and escalation of low-confidence answers:
This service provides the building blocks for evaluating your RAG pipeline and handling cases where the advisor can’t answer confidently. The core fit-advisor flow works without it, but it’s the foundation for a production-quality evaluation loop.
Expected output:new EvaluationService().createEvalSample("q", "ctx", "gt", "ans") returns a valid EvaluationSample.
Step 11: Create the Hono API handlers
Now you’ll expose the services through REST endpoints. Create the chat handler at src/api/chat.ts:
The handler validates request bodies with the Zod schema, delegates to FitAdvisor.answerQuestion, and returns structured JSON errors for validation failures (400) and server errors (500).
Create the health endpoint at src/api/health.ts:
ts
import type { Context } from "hono";export function healthHandler(c: Context) { return c.json({ status: "ok", timestamp: new Date().toISOString() });}
Create the products search endpoint at src/api/products.ts:
ts
import { ProductCatalog } from "../services/product-catalog.js";import type { Context } from "hono";export function createProductsHandler(catalog: ProductCatalog) { return async (c: Context) => { const query = c.req.query("q") ?? ""; const products = await catalog.search(query); return c.json(products); };}
Now wire them together in src/api/index.ts. This is where the Hono app is created and all services are instantiated:
ts
import { Hono } from "hono";import { createChatHandler } from "./chat.js";import { healthHandler } from "./health.js";import { createProductsHandler } from "./products.js";import { FitAdvisor } from "../services/fit-advisor.js";import { CacheService } from "../services/cache-service.js";import { MemoryService } from "../services/memory-service.js";import { ProductCatalog } from "../services/product-catalog.js";const cacheService = new CacheService();const memoryService = new MemoryService();const productCatalog = new ProductCatalog();const fitAdvisor = new FitAdvisor(cacheService, memoryService, productCatalog);export const app = new Hono();app.post("/api/chat", createChatHandler(fitAdvisor));app.get("/api/health", healthHandler);app.get("/api/products", createProductsHandler(productCatalog));
Expected output: The Hono app exposes three routes. Hitting GET /api/health returns { "status": "ok", "timestamp": "..." }.
Step 12: Connect Next.js to Hono
Create the catch-all route at app/api/[[...route]]/route.ts. This bridges the Hono router into Next.js App Router:
ts
import { handle } from "hono/vercel";import { app } from "../../../src/api/index.js";export const GET = handle(app);export const POST = handle(app);export const PUT = handle(app);
Each exported named function corresponds to an HTTP verb. The [[...route]] catch-all pattern captures all sub-paths under /api/ and passes them to Hono’s router.
Now create the home page at app/page.tsx so the app has a landing page when you visit the root:
tsx
export default function Home() { return ( <main style={{ maxWidth: 720, margin: "0 auto", padding: "2rem 1rem", fontFamily: "system-ui, sans-serif" }}> <h1 style={{ fontSize: "2rem", marginBottom: "0.5rem" }}>Fit Advisor</h1> <p style={{ color: "#666", marginBottom: "2rem", lineHeight: 1.6 }}> Find your perfect size with AI-powered fit guidance. Ask about any product in our catalog — shirts, shoes, jeans, jackets, and more. </p> <section style={{ background: "#f5f5f5", borderRadius: 8, padding: "1.5rem", marginBottom: "1.5rem" }}> <h2 style={{ fontSize: "1.25rem", marginBottom: "0.75rem" }}>How it works</h2> <ol style={{ paddingLeft: "1.25rem", lineHeight: 2 }}> <li>Ask a fit question (e.g. “Will these jeans fit a 32-inch waist?”)</li> <li>The advisor searches our product catalog for relevant items</li> <li>AI analyzes sizing data and customer fit preferences</li> <li>Get a personalised fit recommendation in seconds</li> </ol> </section> <section style={{ background: "#f5f5f5", borderRadius: 8, padding: "1.5rem" }}> <h2 style={{ fontSize: "1.25rem", marginBottom: "0.75rem" }}>Available products</h2> <ul style={{ paddingLeft: "1.25rem", lineHeight: 2 }}> <li>Classic Cotton T-Shirt (S–3XL)</li> <li>Trail Running Shoe (US 6–15)</li> <li>Slim Fit Stretch Jeans (28–42)</li> <li>Insulated Winter Jacket (S–3XL)</li> <li>Classic Fit Chinos (28–38)</li> <li>Waterproof Hiking Boot (US 5–14)</li> <li>Pullover Fleece Hoodie (S–3XL)</li> <li>And more!</li> </ul> </section> <p style={{ marginTop: "2rem", color: "#999", fontStyle: "italic" }}> API endpoint: <code>POST /api/chat</code> — see README for usage. </p> </main> );}
Create app/layout.tsx for shared fonts and metadata:
tsx
import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css";const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"],});const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"],});export const metadata: Metadata = { title: "Fit Advisor — Find Your Perfect Size", description: "AI-powered pre-purchase fit advisor for WooCommerce. Get instant sizing guidance and product-fit answers.",};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode;}>) { return ( <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body> </html> );}
Expected output: Running pnpm dev starts the Next.js dev server. curl http://localhost:3000/api/health returns the health JSON. curl http://localhost:3000/api/products?q=jeans returns the jeans products.
Step 13: Create the entry point exports
Create src/index.ts to re-export everything from a single entry point:
ts
export const SCAFFOLD_VERSION = "0.1.0";export { FitAdvisor } from "./services/fit-advisor.js";export { MemoryService } from "./services/memory-service.js";export { CacheService } from "./services/cache-service.js";export { ProductCatalog } from "./services/product-catalog.js";export { EvaluationService } from "./services/evaluation-service.js";export { generateResponse } from "./lib/llm.js";export { createDbPool, registerVectorTypes } from "./lib/db.js";export type { Product, SizingInfo, FitAnswer, ConversationTurn, ChatRequest, ChatResponse, CustomerProfile,} from "./lib/types.js";
Expected output: Another module can import { FitAdvisor, ProductCatalog } from "./src/index.js" and get everything it needs.
Step 14: Write the test suite
The project includes a comprehensive test suite with vitest. Here are key test files to create.
Create tests/lib/types.test.ts to validate the Zod schemas:
Create tests/lib/llm.test.ts — this mocks the ai and @ai-sdk/openai modules so no real API calls are made. Key tests cover successful responses, LLMError propagation, null token handling, and model resolution:
ts
import { describe, it, expect, vi, beforeEach } from "vitest";const { mockGenerateText } = vi.hoisted(() => ({ mockGenerateText: vi.fn(),}));vi.mock("ai", () => ({ generateText: mockGenerateText,}));vi.mock("@ai-sdk/openai", () => ({ openai: (model: string) => ({ model }),}));import { generateResponse, LLMError } from "../../src/lib/llm.js";interface GenerateTextCallArg { model: { model: string }; system: string; messages: Array<{ role: string; content: string }>;}describe("generateResponse", () => { beforeEach(() => { vi.clearAllMocks(); delete process.env.LLM_MODEL; }); it("returns text and token counts on success", async () => { mockGenerateText.mockResolvedValue({ text: "Hello from LLM", usage: { inputTokens: 10, outputTokens: 5 }, }); const result = await generateResponse("system prompt", [ { speaker: "user", content: "Hi", timestamp: new Date() }, ]); expect(result.text).toBe("Hello from LLM"); expect(result.inputTokens).toBe(10); expect(result.outputTokens).toBe(5); }); it("handles null inputTokens and outputTokens", async () => { mockGenerateText.mockResolvedValue({ text: "Hello", usage: { inputTokens: null, outputTokens: null }, }); const result = await generateResponse("system", [ { speaker: "user", content: "Hi", timestamp: new Date() }, ]); expect(result.inputTokens).toBe(0); expect(result.outputTokens).toBe(0); }); it("throws LLMError with error status when error has status property", async () => { mockGenerateText.mockRejectedValue(Object.assign(new Error("Rate limited"), { status: 429 })); try { await generateResponse("system", [ { speaker: "user", content: "Hi", timestamp: new Date() }, ]); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(LLMError); expect(err).toHaveProperty("statusCode", 429); } }); it("uses fallback model when LLM_MODEL is not set", async () => { mockGenerateText.mockResolvedValue({ text: "ok", usage: { inputTokens: 1, outputTokens: 1 }, }); await generateResponse("system", [ { speaker: "user", content: "Hi", timestamp: new Date() }, ]); const callArgs = mockGenerateText.mock.calls[0]?.[0] as GenerateTextCallArg; expect(callArgs.model.model).toBe("gpt-5.2-mini"); }); it("uses env LLM_MODEL when set", async () => { process.env.LLM_MODEL = "gpt-4"; mockGenerateText.mockResolvedValue({ text: "ok", usage: { inputTokens: 1, outputTokens: 1 }, }); await generateResponse("system", [ { speaker: "user", content: "Hi", timestamp: new Date() }, ]); const callArgs = mockGenerateText.mock.calls[0]?.[0] as GenerateTextCallArg; expect(callArgs.model.model).toBe("gpt-4"); });});
For services that make network calls, use vi.mock to stub the REAA packages. Here’s the cache service test (tests/services/cache-service.test.ts):
For the route handler tests, create tests/app/api/chat.test.ts that invokes the Hono app directly. The test mocks all upstream services and verifies both happy-path responses and error handling:
The full test suite (not shown in full here) additionally covers ProductCatalog search and sizing, CacheService CRUD, FitAdvisor integration flows, the database adapter, and the evaluation service — all with mocked network dependencies.
Step 15: Run the test suite
Create vitest.config.ts with coverage thresholds set at 90% across all metrics:
pnpm typecheckpnpm lintpnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output:
pnpm typecheck exits 0 with no TypeScript errors.
pnpm lint exits 0 with no ESLint warnings.
pnpm vitest exits 0, all tests pass, and coverage metrics all at or above 90%.
Try the API yourself by starting the dev server and sending a curl request:
terminal
pnpm dev# In another terminal:curl http://localhost:3000/api/healthcurl -X POST http://localhost:3000/api/chat \ -H "Content-Type: application/json" \ -d '{"messages":[{"speaker":"user","content":"Will these jeans fit a 32-inch waist?","timestamp":"2025-06-01T00:00:00Z"}],"productId":"jeans-01"}'
Expected output: The health endpoint returns {"status":"ok","timestamp":"..."}. The chat endpoint returns a FitAnswer with an LLM-generated sizing recommendation.
Next steps
Replace the in-memory catalog with a real WooCommerce API integration — fetch products and sizing data live from your store using the WooCommerce REST API.
Swap the in-memory cache and memory for PostgreSQL with pgvector — set POSTGRES_URL in your .env and replace the InMemoryAdapter and InMemoryMemoryStorage with @vercel/postgres-backed implementations for persistent, production-grade storage.
Add a frontend chat widget — build a React component that calls POST /api/chat and renders sizing recommendations inline on your product pages.
Connect Langfuse observability — set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY in your .env, then instrument the FitAdvisor with Langfuse traces to monitor cost, latency, and answer quality.
Train the evaluation pipeline — use EvaluationService.createEvalSample to build a dataset of query-context-answer triples, then run batch experiments with @reaatech/rag-eval-core to measure and improve your fit advisor’s accuracy over time.