As a listing agent, you spend hours rewriting the same property description for MLS, Zillow, social media, and printed brochures. Each platform has different character limits, tone requirements, and SEO keywords. You often miss updates across channels, leading to inconsistent messaging and lost showings. This manual copy-paste grind eats into prospecting time and frustrates your team.
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.
You’re a listing agent rewriting the same property description for MLS, Zillow, social media, and printed brochures — each with different character limits, tone requirements, and SEO keywords. This recipe builds a Listing Copy Multiplier that takes one markdown property draft and generates platform-optimized copy for all four channels. You’ll wire up a Fastify backend, Next.js App Router API routes, and an MCP server, using six @reaatech packages.
Prerequisites
Node.js >= 22 (check with node --version)
pnpm 10 (check with pnpm --version)
An OpenAI-compatible LLM API key (any provider that exposes a /chat/completions endpoint)
Familiarity with TypeScript and Next.js App Router patterns
All env vars are server-only — no NEXT_PUBLIC prefix needed
Step 1: Scaffold the project and install dependencies
Start with a fresh Next.js project and pin every dependency to an exact version.
Set up .env.example with the LLM provider config and server port:
env
# Env vars used by agnostic-listing-copy-multiplier-2.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# LLM provider configuration (provider-agnostic)LLM_API_KEY=<your-llm-api-key>LLM_BASE_URL=<llm-api-base-url>LLM_MODEL_ID=<model-id># Fastify serverPORT=3001# MCP transport (stdio or streamable-http)MCP_TRANSPORT=stdio
Expected output:pnpm install completes without errors, and pnpm typecheck passes.
Step 2: Define the core domain types
Create src/lib/types.ts. This file holds the types for property listings, platform constraints, the copy generation pipeline, and Zod schemas for validation.
Expected output:pnpm typecheck passes. The schemas produce typed inputs via z.input<>.
Step 3: Define the platform constraints and prompt builder
Create src/lib/platforms.ts. Each platform gets its own tone, character limit, and required sections. The generatePlatformPrompt function turns a PropertyListing into a structured LLM prompt.
Expected output:getPlatformConstraints("mls") returns the MLS profile with maxChars: 2000. All four platforms have distinct character limits and section requirements.
Step 4: Build the provider-agnostic LLM client
Create src/lib/llm.ts. FetchLlmProvider calls any OpenAI-compatible chat completions endpoint. createLlmRouter uses @reaatech/llm-router-strategies for cost-optimized model selection, and generateWithRouter ties them together.
ts
import { ModelDefinitionSchema, type ModelDefinition, type RoutingRequest, type RoutingContext, type RoutingResult } from "@reaatech/llm-router-core";import { CostOptimizedStrategy, StrategyOrchestrator } from "@reaatech/llm-router-strategies";export class LlmError extends Error { constructor(message: string, public status: number, public body?: string) { super(message); this.name = "LlmError"; }}export interface LlmGenerateOptions { prompt: string; system
Expected output:createLlmProvider() with valid env vars returns a FetchLlmProvider. A call to generate({ prompt: "test" }) returns { text, usage }.
Step 5: Build the document service
Create src/services/document-service.ts. This handles parsing property draft markdown into a structured document, validating it against the REAA schema, and converting between markdown and the PropertyListing type.
ts
import { parseMarkdown, getSectionTitles } from "@reaatech/agents-markdown-parser";import { validate } from "@reaatech/agents-markdown-validator";import type { AgentsMdDocument, ValidationResult } from "@reaatech/agents-markdown";import type { PropertyListing } from "../lib/types.js";export async function parsePropertyDraft(content: string): Promise<AgentsMdDocument> { return parseMarkdown(content, "./property-draft.md");}export function validatePropertyDocument(doc: AgentsMdDocument): ValidationResult { return validate(doc);
Expected output: A property draft with sections for Address, Details, and Description gets parsed into a Partial<PropertyListing> with extracted bedrooms, address, and description fields.
Step 6: Build the copy generation pipeline
Create src/services/copy-generator.ts. This takes a CopyGenerationRequest, generates copy for each target platform in parallel with concurrency capped at 4 via p-limit, and validates the results.
ts
import { randomId } from "@reaatech/agents-markdown";import type { ModelDefinition, RoutingRequest, RoutingContext } from "@reaatech/llm-router-core";import { StrategyOrchestrator } from "@reaatech/llm-router-strategies";import type { PropertyListing, PlatformConstraints, CopyVariant, CopyGenerationRequest, CopyGenerationResult } from "../lib/types.js";import { generatePlatformPrompt, PLATFORMS } from "../lib/platforms.js";import type { LLMProvider } from "../lib/llm.js";import pLimit from "p-limit";export async function generateCopyForPlatform( property: PropertyListing, platform: PlatformConstraints, llmProvider
Expected output:generateAllCopy({ property, targetPlatforms: ["mls", "zillow"] }, mockProvider) returns a result with exactly 2 variants, one per platform.
Step 7: Build the validation service
Create src/services/validation-service.ts. This validates property drafts against the REAA Agents Markdown schema and checks generated copy against platform constraints.
ts
import { validate } from "@reaatech/agents-markdown-validator";import { parseMarkdown } from "@reaatech/agents-markdown-parser";import type { ValidationResult, Finding } from "@reaatech/agents-markdown";import type { PlatformConstraints, CopyGenerationResult } from "../lib/types.js";export async function validatePropertyDraft(draft: string): Promise<{ valid: boolean; errors: Finding[]; warnings: Finding[] }> { const doc = await parseMarkdown(draft, "./property-draft.md"); const result = validate(doc); return { valid: result.valid, errors: result.errors, warnings: result.warnings };}export function validatePlatformCopy(body: string, platform: PlatformConstraints): Finding[] { const findings: Finding[] = []; if (body.length > platform.maxChars) { findings.push({ rule: "char-limit", severity: "warning", message: `Body exceeds ${String(platform.maxChars)} character limit (${String(body.length)} chars)`, autoFixable: false, }); } for (const section of platform.requiredSections) { const headingPattern = new RegExp(`^##\\s*${section}$`, "im"); if (!body.toLowerCase().includes(section.toLowerCase()) && !headingPattern.test(body)) { findings.push({ rule: "required-section", severity: "warning", message: `Missing required section: "${section}"`, autoFixable: false, }); } } return findings;}export function validateGeneratedResult(result: CopyGenerationResult): { valid: boolean; issues: Finding[] } { const issues: Finding[] = []; for (const variant of result.variants) { const variantIssues = validatePlatformCopy(variant.body, variant.constraints); issues.push(...variantIssues); } return { valid: issues.length === 0, issues };}export function formatValidationSummary(result: ValidationResult): string { return `Validation: valid=${String(result.valid)}, errors=${String(result.errors.length)}, warnings=${String(result.warnings.length)}`;}
Expected output: A draft with missing frontmatter returns valid: false. Copy at exactly the platform’s maxChars length passes without warnings.
Step 8: Create the MCP server
Create src/mcp/copy-mcp-server.ts. This exposes the copy generation, validation, and platform discovery as MCP tools that AI agents can call directly. It extends the base server from @reaatech/agents-markdown-mcp-server.
ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";import { createMcpServer as createBaseMcpServer, startMcpServer as startBaseMcpServer } from "@reaatech/agents-markdown-mcp-server";import { createLlmProvider, type LLMProvider } from "../lib/llm.js";import { PLATFORMS } from "../lib/platforms.js";import { propertyDraftToListing } from "../services/document-service.js";import { validatePlatformCopy } from "../services/validation-service.js";import { generateAllCopy } from "../services/copy-generator.js";import type { PropertyListing, PlatformType } from "../lib/types.js";
Expected output:createListingCopyMcpServer(mockProvider) returns a server with tools including generate_copy, validate_copy, list_platforms, and regenerate_copy. Unknown tool names return an error.
Step 9: Create the Fastify backend
Create src/api/server.ts. The Fastify backend provides the REST API — POST /api/copy/generate, POST /api/copy/validate, GET /api/copy/platforms, and GET /api/health.
ts
import Fastify, { type FastifyInstance } from "fastify";import cors from "@fastify/cors";import { VERSION } from "@reaatech/agents-markdown";import { LlmError, createLlmProvider, type LLMProvider } from "../lib/llm.js";import type { CopyGenerationRequest } from "../lib/types.js";import { PLATFORMS } from "../lib/platforms.js";import { validatePropertyDraft } from "../services/validation-service.js";import { generateAllCopy } from "../services/copy-generator.js";import { ZodError } from "zod";let llmProvider: LLMProvider;export function buildApp(): FastifyInstance { llmProvider = createLlmProvider(); const app = Fastify({ logger: true }); app.register(cors); app.post("/api/copy/generate", async (request, reply) => { const body = request.body as CopyGenerationRequest | undefined; if (!body?.property) { return reply.status(400).send({ error: "missing property" }); } const result = await generateAllCopy(body, llmProvider); return reply.status(200).send(result); }); app.post("/api/copy/validate", async (request, reply) => { const body = request.body as { draft?: string }; if (!body.draft || typeof body.draft !== "string") { return reply.status(400).send({ error: "missing draft" }); } const result = await validatePropertyDraft(body.draft); return reply.status(200).send(result); }); app.get("/api/copy/platforms", async (_request, reply) => { return reply.status(200).send(PLATFORMS); }); app.get("/api/health", async (_request, reply) => { return reply.status(200).send({ status: "ok", version: VERSION }); }); app.setErrorHandler((error, _request, reply) => { if (error instanceof ZodError) { return reply.status(400).send({ error: "validation failed", details: error.issues }); } if (error instanceof LlmError) { return reply.status(502).send({ error: "llm call failed", message: error.message }); } return reply.status(500).send({ error: "internal server error" }); }); return app;}const port = parseInt(process.env.PORT ?? "3001", 10);const app = buildApp();app.listen({ port, host: "0.0.0.0" }).catch((err: unknown) => { console.error(err); process.exit(1);});export { llmProvider };
Then create src/index.ts to re-export buildApp:
ts
export { buildApp } from "./api/server.js";
Expected output:buildApp() returns a configured Fastify instance. A POST to /api/copy/validate with an empty body returns 400.
Step 10: Create the Next.js API routes
Next.js API routes provide a web-based interface alongside the Fastify backend. Create these route handlers under app/api/.
app/api/copy/route.ts — the main copy generation endpoint:
ts
import { NextRequest, NextResponse } from "next/server";import { createLlmProvider, type LLMProvider } from "../../../src/lib/llm.js";import type { CopyGenerationRequest } from "../../../src/lib/types.js";import { generateAllCopy } from "../../../src/services/copy-generator.js";let llmProvider: LLMProvider | undefined;function getProvider(): LLMProvider { if (!llmProvider) { llmProvider = createLlmProvider(); } return llmProvider;}export async function POST(req: NextRequest) { try { const body = (await req.json()) as CopyGenerationRequest | undefined; if (!body?.property) { return NextResponse.json({ error: "missing property" }, { status: 400 }); } const result = await generateAllCopy(body, getProvider()); return NextResponse.json(result); } catch { return NextResponse.json({ error: "internal server error" }, { status: 500 }); }}
app/api/copy/platforms/route.ts — list supported platforms:
ts
import { type NextRequest, NextResponse } from "next/server";import { PLATFORMS } from "../../../../src/lib/platforms.js";export function GET(request: NextRequest) { void request; return NextResponse.json(PLATFORMS);}
app/api/copy/validate/route.ts — validate a property draft:
ts
import { NextRequest, NextResponse } from "next/server";import { validatePropertyDraft } from "../../../../src/services/validation-service.js";export async function POST(req: NextRequest) { try { const body = (await req.json()) as { draft?: string }; if (!body.draft || typeof body.draft !== "string") { return NextResponse.json({ error: "missing draft" }, { status: 400 }); } const result = await validatePropertyDraft(body.draft); return NextResponse.json(result); } catch { return NextResponse.json({ error: "internal server error" }, { status: 500 }); }}
app/api/health/route.ts — health check:
ts
import { type NextRequest, NextResponse } from "next/server";import { VERSION } from "@reaatech/agents-markdown";export function GET(request: NextRequest) { void request; return NextResponse.json({ status: "ok", version: VERSION });}
Expected output: All route handlers use NextRequest and NextResponse.json(). A GET to /api/health returns { status: "ok", version: "..." }.
Step 11: Add instrumentation
When running inside Next.js, the instrumentation hook initializes the LLM provider so all routes share one instance. Create src/instrumentation.ts:
ts
import type { LLMProvider } from "./lib/llm.js";declare global { var __llmProvider: LLMProvider | undefined;}export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { const { createLlmProvider } = await import("./lib/llm.js"); globalThis.__llmProvider = createLlmProvider(); }}
import type { PropertyListing, CopyGenerationRequest } from "../src/lib/types.js";import type { LLMProvider } from "../src/lib/llm.js";export function createTestPropertyListing(overrides?: Partial<PropertyListing>): PropertyListing { return { address: { street: "123 Main St", city: "Anytown", state: "CA", zip: "90210", neighborhood: "Downtown" }, listPrice: 850000, bedrooms: 4, bathrooms: 3, sqft: 2800, yearBuilt: 2005, features: { bedrooms: 4, bathrooms: 3, sqft: 2800, pool: true, views: "Mountain", kitchenFeatures: ["Granite counters", "Stainless steel"], additionalFeatures: ["Fireplace", "Hardwood floors"], }, description: "Beautiful family home with mountain views. Open floor plan, gourmet kitchen, and private backyard.", agentName: "Jane Doe", brokerage: "Premier Realty", ...overrides, };}export function createTestCopyRequest(overrides?: Partial<CopyGenerationRequest>): CopyGenerationRequest { return { property: createTestPropertyListing(), targetPlatforms: ["mls", "zillow", "social-media", "brochure"], ...overrides, };}export function createMockLlmProvider(responses?: Record<string, string>): LLMProvider { return { generate(_options) { const text = responses?.[_options.model ?? "default"] ?? "Generated copy for property at 123 Main St."; return Promise.resolve({ text, usage: { inputTokens: 10, outputTokens: 5 } }); }, };}
Now run the quality gate:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:pnpm typecheck exits 0. pnpm lint passes. pnpm test reports zero failing tests with coverage on lines, branches, functions, and statements all at or above 90%.
Next steps
Add more platforms — Extend the PLATFORMS array with Realtor.com, Redfin, or Facebook Marketplace; each needs its own maxChars, tone, and requiredSections.
Persist generation history — Store CopyGenerationResult entries in SQLite or Postgres so agents can review past generations.
Deploy via Docker — Containerize the Fastify backend alongside the Next.js frontend for a production-grade hosted service.
const result = { result: "regeneration_submitted", platformName: args.platformName, feedback: args.feedback, note: `Regeneration for ${args.platformName} accepted with feedback: "${args.feedback}". Submit a new copy generation request with the feedback incorporated.` };