A solo broker or small-team agent spends 45+ minutes rewriting the same listing description for MLS, Zillow, social media, and a printed brochure. Each platform has different character limits, tone preferences, and keyword requirements. This repetitive copy work eats into prospecting and showing time, and often results in inconsistent branding across channels.
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 Listing Copy Multiplier — a Next.js API service that generates 4 platform-optimized listing descriptions (MLS, Zillow, Social Media, Brochure) from a single real-estate draft in under 2 minutes. You’ll wire 6 REAA packages (@reaatech/hybrid-rag, @reaatech/agents-markdown, @reaatech/llm-cache, @reaatech/agent-eval-harness-golden, @reaatech/llm-router-core, @reaatech/otel-genai-semconv-core) into the Next.js App Router, add ChromaDB for vector storage, Langfuse for tracing, and an MSW-mocked test suite. By the end you’ll have a working POST /api/generate endpoint that calls the AI SDK, caches results, stores to Chroma, and traces to Langfuse.
Prerequisites
Node.js >= 22 and pnpm 10 installed
OpenAI API key (set as OPENAI_API_KEY in .env)
Langfuse account (free tier — set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY; the service degrades gracefully without them)
Basic familiarity with Next.js App Router, TypeScript, and vitest
Step 1: Scaffold the Next.js project and install dependencies
Create the Next.js project with the App Router, then install every exact-pinned dependency.
Expected output:pnpm install exits 0. The package.jsondependencies block now lists all 12 packages with exact versions. The scripts block includes "typecheck": "tsc --noEmit", "lint": "eslint .", and "test": "vitest run --coverage --reporter=json --outputFile=vitest-report.json".
Step 2: Set up environment variables
Create .env.example with the env vars the services read at runtime:
env
# Env vars used by agnostic-listing-copy-multiplier.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=https://cloud.langfuse.comCHROMA_URL=http://localhost:8000LLM_CACHE_SIMILARITY_THRESHOLD=0.85LLM_CACHE_DEFAULT_TTL=3600LLM_CACHE_ENABLED=true
Copy it to .env.local and fill in real values for development:
terminal
cp .env.example .env.local
Expected output:cat .env.local shows your actual OpenAI and Langfuse keys alongside the other defaults. The LLM_CACHE_ENABLED flag controls whether ListingService skips caching entirely when set to "false".
Step 3: Define the domain types
Four type files model the problem domain. Create them under src/types/.
src/types/listing.ts — the ListingDraft extends Document from @reaatech/hybrid-rag with real-estate-specific fields:
Expected output:getPlatformById("mls") returns the MLS object with characterLimit: 5000. getPlatformById("nintendo64") throws "Unknown platform ID: nintendo64". listPlatforms() returns all 4 platform entries.
Step 5: Build the prompt builder
src/lib/prompt-builder.ts constructs the system prompt and the per-platform listing prompt:
ts
import { type ListingDraft } from "../types/listing.js";import { type Platform } from "../types/platform.js";export function buildSystemPrompt(agentName?: string, agencyName?: string): string { const agent = agentName ?? "your client"; const agency = agencyName ?? "the brokerage"; return `You are a real estate listing copywriter for ${agent} / ${agency}. Generate a platform-optimized listing description that matches the given platform's tone, character limit, and sections. Return STRICT JSON with keys "title", "body", "summary". Do NOT include any text before or after the JSON object.`;}export function buildListingPrompt(draft: ListingDraft, platform: Platform): string { const featuresText = draft.features.length > 0 ? `Features:\n${draft.features.map((f) => `- ${f}`).join("\n")}` : "Features: None listed"; let description = draft.content; if (description.length > 10000) { description = description.slice(0, 10000) + "\n[truncated]"; } const charLimit = String(platform.characterLimit); const priceText = draft.price.toLocaleString(); return `Generate a listing copy for the "${platform.name}" platform.Platform Requirements:- Character limit: ${charLimit}- Tone: ${platform.tone}- Target keywords: ${platform.keywords.join(", ")}- Required sections: ${platform.sections.join(", ")}Property Details:${description}${featuresText}Price: $${priceText}Bedrooms: ${String(draft.bedrooms)}Bathrooms: ${String(draft.bathrooms)}Square Feet: ${String(draft.squareFeet)}Property Type: ${draft.propertyType}Return JSON with keys "title", "body", "summary". The body must stay within the ${charLimit} character limit.`;}
Expected output:buildSystemPrompt("Jane", "Premier") contains both names. buildListingPrompt(draft, mlsPlatform) includes the platform name “MLS Listing”, the character limit “5000”, the tone “professional”, and the property details. A draft with content over 10,000 characters gets truncated with "[truncated]".
Step 6: Create the GenerationError class and the REAA hub module
src/services/errors.ts — a typed error for LLM failures:
src/listing-copy.ts — this single module imports all 6 REAA packages so the build validators can verify every package is wired. It also exports buildServices(), a factory that creates the full dependency graph:
ts
import { type Document } from "@reaatech/hybrid-rag";import { randomId } from "@reaatech/agents-markdown";import { CacheEngine, InMemoryAdapter, type EmbeddingProvider } from "@reaatech/llm-cache";import { createGolden, quickCreateGolden, compareAgainstGolden } from "@reaatech/agent-eval-harness-golden";import { ModelDefinitionSchema, type ModelDefinition } from "@reaatech/llm-router-core";import { SpanBuilder } from "@reaatech/otel-genai-semconv-core";import type { Trajectory } from "@reaatech/agent-eval-harness-types";import { ListingService } from "./services/listing-service.js";export { ListingService } from "./services/listing-service.js";
Expected output: Running grep -r "@reaatech/" src/ shows all 6 package names: hybrid-rag, agents-markdown, llm-cache, agent-eval-harness-golden, llm-router-core, and otel-genai-semconv-core.
Step 7: Build the VariantGenerator with LLM caching
src/services/variant-generator.ts is the core generation engine. It takes a CacheEngine and an optional model, generates text via the AI SDK, caches results, and handles parse failures gracefully:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import { CacheEngine } from "@reaatech/llm-cache";import { buildSystemPrompt, buildListingPrompt } from "../lib/prompt-builder.js";import { GenerationError } from "./errors.js";import { type ListingDraft } from "../types/listing.js";import { type Platform } from "../types/platform.js";import { type ListingVariant } from "../types/variant.js";export class VariantGenerator { private cache: CacheEngine;
Key behaviors:
If useCache is true, the generator checks the cache first by prompt content
If the LLM returns non-JSON text, the raw text becomes the body with a warning prefix for title
If the OpenAI API returns a 500, GenerationError is thrown with the platformId attached
Empty platform lists return [] immediately without calling the LLM
Expected output: Calling generatePlatformVariant(validDraft, mlsPlatform) returns a ListingVariant with platformId: "mls", characterCount > 0, and cacheHit: false. Calling it a second time with the same parameters returns cacheHit: true.
Step 8: Build the ListingService orchestrator
src/services/listing-service.ts ties the VariantGenerator, CacheEngine, and SpanBuilder together. It validates input with Zod, checks LLM_CACHE_ENABLED, creates an OTel span, and returns a GenerateResponse:
ts
import { randomId } from "@reaatech/agents-markdown";import { SpanBuilder } from "@reaatech/otel-genai-semconv-core";import { CacheEngine, InMemoryAdapter, type EmbeddingProvider } from "@reaatech/llm-cache";import { type ListingDraft } from "../types/listing.js";import { GenerateRequestSchema, type GenerateResponse } from "../types/generation.js";import { getPlatformById } from "../lib/platforms.js";import { VariantGenerator } from "./variant-generator.js";function createNoopEmbedder(): EmbeddingProvider { return { embed() { return Promise
Expected output:new ListingService().generate(validDraft, ["mls"]) returns a GenerateResponse with 1 variant, a non-empty requestId, and totalLatencyMs >= 0. The LLM_CACHE_ENABLED=false env var forces cache bypass.
Step 9: Create the Chroma vector storage service
src/services/chroma-service.ts connects to a ChromaDB instance for storing and retrieving listing variants by similarity:
The service is non-blocking — if Chroma is unreachable, methods log a warning and return empty results rather than crashing the request.
Expected output: A ChromaService constructed with a bad URL silently succeeds on both storeListingVariants and findSimilarListings (returns empty array).
Step 10: Create the Langfuse tracing service
src/services/langfuse-service.ts creates Langfuse traces when configured, and silently no-ops when env vars are missing:
Expected output: With LANGFUSE_PUBLIC_KEY unset, new LangfuseService().traceGeneration(...) resolves silently. With all 3 env vars set, it creates a trace and posts to the Langfuse ingestion API.
Step 11: Create the EvaluationService
src/services/evaluation-service.ts wraps the createGolden and compareAgainstGolden functions from @reaatech/agent-eval-harness-golden:
Expected output:createGoldenFromVariant(variant, ["platform", "mls"]) returns a golden object. compareVariantAgainstGolden(variant, golden) returns a comparison result object.
Step 12: Create the API routes
Two Next.js App Router routes expose the service.
app/api/generate/route.ts — the main POST handler:
app/api/platforms/route.ts — a simple GET to list available platforms:
ts
import { NextResponse } from "next/server";import { listPlatforms } from "../../../src/lib/platforms.js";export function GET() { return NextResponse.json({ platforms: listPlatforms() }, { status: 200 });}
Key details:
POST /api/generate uses NextRequest/NextResponse.json() (not bare Request/Response)
Input is validated with GenerateRequestSchema.safeParse() — Zod errors return 400 with the issue details
LLM failures return 500 with a message string
GET /api/platforms returns all 4 platform definitions
Expected output: A curl POST with a valid draft and ["mls"] returns a 200 JSON response with { variants: [...], requestId: "...", totalLatencyMs: ..., totalTokens: {...} }. A POST missing the draft field returns 400 with { error: "Invalid request", details: [...] }.
Step 13: Wire the main entry point
Replace the placeholder src/index.ts with re-exports:
ts
export { GenerationError } from "./services/errors.js";export { ListingService } from "./services/listing-service.js";export { VariantGenerator } from "./services/variant-generator.js";export { ChromaService } from "./services/chroma-service.js";export { LangfuseService } from "./services/langfuse-service.js";export { EvaluationService } from "./services/evaluation-service.js";export { buildServices } from "./listing-copy.js";export * from "./lib/platforms.js";export * from "./lib/prompt-builder.js";export * from "./types/index.js";
Expected output:pnpm typecheck exits 0. Every export resolves to an existing module.
Step 14: Write the test suite
Before writing tests, create vitest.config.ts at the project root to wire up MSW setup, coverage thresholds, and test discovery:
tests/services/variant-generator.test.ts — the core generator test covering happy path, caching, non-JSON fallback, and API errors:
ts
import { describe, it, expect, vi, beforeEach } from "vitest";import { CacheEngine, InMemoryAdapter } from "@reaatech/llm-cache";import { VariantGenerator } from "../../src/services/variant-generator.js";import { buildListingPrompt } from "../../src/lib/prompt-builder.js";import { getPlatformById } from "../../src/lib/platforms.js";import { type ListingDraft } from "../../src/types/listing.js";import { type Platform } from "../../src/types/platform.js";import { http, HttpResponse } from "msw";import { server } from "../setup.js";import { GenerationError } from "../../src/services/errors.js";
tests/api/generate-route.test.ts — integration test for the route handler:
Expected output:pnpm test exits 0 with numFailedTests=0, numTotalTests >= 50, and all four coverage metrics (lines, branches, functions, statements) at 90% or above.
Step 15: Run the verification suite
Run the full quality gate:
terminal
pnpm typecheckpnpm lintpnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Then run the project validator:
terminal
node /home/rick/solutions-worker/bin/preflight.js
Expected output: All three commands exit 0. pnpm typecheck produces zero TypeScript errors. pnpm lint produces zero ESLint issues. The coverage report shows lines, branches, functions, and statements all at 90%+. The preflight script prints {"ok": true, ...}.
Next steps
Add a frontend. Create a simple app/page.tsx with a form where an agent types a draft description, selects platforms, and sees the generated variants side by side.
Connect a real ChromaDB instance. Run Chroma via Docker (docker run -p 8000:8000 chromadb/chroma) and change CHROMA_URL to your local instance for real vector search.
Add similarity-based cache. Set LLM_CACHE_SIMILARITY_THRESHOLD to a value like 0.92 so that semantically similar listing drafts return cached results rather than hitting the LLM every time.
export
{ VariantGenerator }
from
"./services/variant-generator.js"
;
export { ChromaService } from "./services/chroma-service.js";
export { LangfuseService } from "./services/langfuse-service.js";
export { EvaluationService } from "./services/evaluation-service.js";
export * from "./lib/platforms.js";
export * from "./lib/prompt-builder.js";
export * from "./types/index.js";
export function createNoopEmbedder(): EmbeddingProvider {