A general contractor's estimator spends 2-3 days manually measuring plans, counting fixtures, and typing up scopes for each trade. Mistakes in takeoff lead to under-bid jobs or angry subs. The process is tedious, error-prone, and scales poorly with project volume.
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 Plan Takeoff to Subcontractor RFP Agent — a Next.js API pipeline that converts construction plan set PDFs into a structured bill of materials and generates subcontractor RFP drafts. A general contractor’s estimator spends 2-3 days manually measuring plans, counting fixtures, and typing up scopes for each trade. This agent automates that workflow in minutes.
You’ll wire up six @reaatech/* packages — agents-markdown, agent-mesh, agent-memory, and their companion modules — alongside the Vercel AI SDK, Langfuse for observability, and unpdf for PDF extraction. By the end, you’ll have a running API server with four endpoints and a suite of passing tests with 90%+ coverage.
Prerequisites
Node.js 22+ (the project uses pnpm as the package manager)
pnpm 10 — install with npm install -g pnpm or corepack enable && corepack prepare pnpm@10 --activate
An OpenAI API key — set it in .env as OPENAI_API_KEY (used for the LLM-powered takeoff extraction and RFP generation)
Basic familiarity with Next.js 16 App Router — route handlers, NextRequest/NextResponse, and next.config.ts
Step 1: Scaffold the project
Start from an empty directory. Create the package.json first — this pins every dependency to an exact version so the build is reproducible.
# Env vars used by agnostic-plan-takeoff-to-rfp-agent.# 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-api-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=<your-langfuse-base-url>
Install everything:
terminal
pnpm install
Expected output: pnpm creates node_modules/ and a pnpm-lock.yaml. All packages install cleanly — no audit warnings for the pinned versions.
Step 2: Define the domain types
Create the core types that model the construction takeoff domain. These use Zod schemas for runtime validation and TypeScript types for compile-time safety.
// src/types/index.tsexport { TakeoffItemSchema, TakeoffResultSchema, RfpSectionSchema, RfpDraftSchema, PipelineFailure,} from "./takeoff.js";export type { TakeoffItem, TakeoffResult, RfpSection, RfpDraft, DocumentSource, PipelineInput, PipelineResult, PipelineErrorCode, ConstructionTrade,} from "./takeoff.js";
Expected output: No compile errors. The PipelineFailure class and all Zod schemas are ready to use across the application.
Step 3: Build the PDF extraction service
The extraction service uses unpdf to convert uploaded PDF buffers into plain text. Wrap it with error handling that preserves the PipelineFailure code for downstream diagnostics.
Expected output:extractPdfText takes a Uint8Array and returns the extracted text, page count, and document metadata. Invalid PDFs throw a typed PipelineFailure with code EXTRACTION_FAILED.
Step 4: Build the document parser
The parser takes extracted text and runs it through @reaatech/agents-markdown-parser — it extracts sections, parses tables, and converts table rows into structured bill-of-material items.
ts
// src/services/document-parser.tsimport { extractSections, parseTables, tableToObjects, parseMarkdown, findSectionByTitle as findSection, validateFrontmatterStructure } from "@reaatech/agents-markdown-parser";import type { AgentsMdDocument, ValidationResult } from "@reaatech/agents-markdown";import { VERSION, randomId, groupBy, AgentsMdFrontmatterSchema } from "@reaatech/agents-markdown";import { TakeoffItemSchema, type DocumentSource, type TakeoffItem } from "../types/takeoff.js";export const AGENTS_MARKDOWN_VERSION: string = VERSION;function firstValue(obj: Record<string, unknown>, keys: string[], fallback: string): string
Expected output:extractBillOfMaterials looks for tables under “Materials”, “Specifications”, or “Scope” headings and converts them into typed TakeoffItem objects. It handles varied column naming (e.g. “Category”, “category”, “Trade”) and validates every row through the Zod schema.
Step 5: Build the takeoff engine
The takeoff engine orchestrates extraction and LLM fallback. It first tries table extraction; if that yields nothing, it falls back to the LLM for unstructured spec text. It also deduplicates items with matching category-description pairs by summing their quantities.
ts
// src/services/takeoff-engine.tsimport { groupBy } from "@reaatech/agents-markdown";import type { DocumentSource, TakeoffItem, TakeoffResult } from "../types/takeoff.js";import { extractBillOfMaterials } from "./document-parser.js";import { generateStructuredBom } from "../lib/llm.js";export async function analyzeTakeoff(docSource: DocumentSource): Promise<TakeoffResult> { if (!docSource.text.trim()) { return { projectName: "", items: [], totalPages: 0, metadata: {} }; } let items = extractBillOfMaterials(docSource); if (items.length === 0) { items = await generateStructuredBom(docSource.text); } items = deduplicateItems(items); const grouped = groupBy(items, (item) => item.category); return { projectName: docSource.fileName, items, totalPages: docSource.pageCount, metadata: { categories: Object.keys(grouped).join(",") }, };}function deduplicateItems(items: TakeoffItem[]): TakeoffItem[] { const seen = new Map<string, TakeoffItem>(); for (const item of items) { const key = `${item.category}:${item.description}`; const existing = seen.get(key); if (existing) { seen.set(key, { ...existing, quantity: existing.quantity + item.quantity, }); } else { seen.set(key, item); } } return Array.from(seen.values());}
Expected output: For a document containing a materials table, analyzeTakeoff returns a TakeoffResult with items extracted from tables. For unstructured text, it calls the LLM. Duplicate items (same category + description) are merged by summing quantity.
Step 6: Wire up the LLM layer
The LLM layer wraps the Vercel AI SDK’s generateText with OpenAI. It provides two functions — one for free-form RFP generation and one for structured bill-of-materials extraction using Output.object.
ts
// src/lib/llm.tsimport { generateText, Output } from "ai";import { openai } from "@ai-sdk/openai";import { TakeoffItemSchema, type TakeoffItem as TakeoffItemType, PipelineFailure } from "../types/takeoff.js";import { z } from "zod";export async function generateRfp( prompt: string, system?: string, options?: { temperature?: number; maxTokens?: number },): Promise<{ text: string; usage: { inputTokens: number; outputTokens: number } }> { if (!process.env.OPENAI_API_KEY) { throw new PipelineFailure("EXTRACTION_FAILED", "Missing OPENAI_API_KEY"); } const result = await generateText({ model: openai("gpt-5.2"), system, prompt, temperature: options?.temperature, maxOutputTokens: options?.maxTokens, }); return { text: result.text, usage: { inputTokens: result.usage.inputTokens ?? 0, outputTokens: result.usage.outputTokens ?? 0, }, };}export async function generateStructuredBom(text: string): Promise<TakeoffItemType[]> { if (!process.env.OPENAI_API_KEY) { throw new PipelineFailure("EXTRACTION_FAILED", "Missing OPENAI_API_KEY"); } const { output } = await generateText({ model: openai("gpt-5.2"), prompt: `Extract a bill of materials from the following construction spec text. Return a JSON array of items with id, category, description, quantity, unit, and optional notes.\n\n${text}`, output: Output.object({ schema: z.object({ items: z.array(TakeoffItemSchema), }), }), }); return output.items;}
Expected output: Both functions throw a PipelineFailure if OPENAI_API_KEY is missing. generateStructuredBom uses the AI SDK’s structured output mode to return validated TakeoffItem[] directly.
Step 7: Set up Langfuse observability
Langfuse traces every step of the document pipeline. The module creates a client when credentials are available, or falls back to a no-op client so tracing is optional.
Expected output: Every traced() call creates a Langfuse trace + generation span. If no Langfuse keys are configured, the no-op client silently passes through — no crashes, no console noise.
Step 8: Build the memory service
The memory service wraps @reaatech/agent-memory for long-term storage of takeoff results and RFP drafts, and uses @reaatech/agent-memory-retrieval for context injection. It stores conversation turns from each pipeline run and retrieves them as enriched context for future RFP generations.
Expected output:createMemoryService() returns an in-memory AgentMemory with OpenAI embeddings and LLM-powered extraction. The service emits memory:stored events on every write and can retrieve context by query string.
Step 9: Build the retrieval helper
A small utility that formats retrieved memories into a prompt-friendly XML block for the RFP generator.
ts
// src/services/retrieval-service.tsexport function formatMemoriesForPrompt(memories: string): string { if (!memories.trim()) { return ""; } return `<relevant_memories>The following project context was retrieved from memory:${memories}</relevant_memories>`;}
Expected output: Returns an XML-wrapped block of context text, or empty string for no memories, keeping LLM prompts clean.
Step 10: Build the RFP generator service
The RFP generator takes a takeoff result and trade, builds a system prompt with optional memory context, calls the LLM, validates the response against the RfpDraftSchema, and retries once on failure.
ts
// src/services/rfp-generator.tsimport type { TakeoffResult, ConstructionTrade, RfpDraft } from "../types/takeoff.js";import { RfpDraftSchema, PipelineFailure } from "../types/takeoff.js";import { generateRfp as llmGenerate } from "../lib/llm.js";import { traced } from "../lib/langfuse.js";export async function generateRfp( takeoff: TakeoffResult, trade: ConstructionTrade, memoryContext?: string,): Promise<RfpDraft> { if (takeoff.items.length === 0) { return {
Expected output: When takeoff items exist, generateRfp calls the LLM with a structured prompt, parses the JSON response, validates it against RfpDraftSchema, and retries once if validation fails. Empty takeoffs return a fallback draft with “No takeoff items available”.
Step 11: Build the document pipeline
The DocumentPipeline class orchestrates all six steps: PDF extraction, document parsing, takeoff analysis, memory retrieval, RFP generation, and memory storage. Each step is wrapped with Langfuse tracing and a 60-second timeout.
ts
// src/api/document-pipeline.tsimport { buildTurnEntry } from "@reaatech/agent-mesh-router";import { assertNever } from "@reaatech/agents-markdown";import { IncomingRequestSchema, ContextPacketSchema } from "@reaatech/agent-mesh";import type { IncomingRequest } from "@reaatech/agent-mesh";import { extractPdfText } from "../services/pdf-extraction.js";import { parseDocumentContent } from "../services/document-parser.js";import { analyzeTakeoff } from "../services/takeoff-engine.js";import { generateRfp } from "../services/rfp-generator.js";import { retrieveProjectContext, storeTakeoffMemory } from "../services/memory-service.js";import { traced } from
Expected output:DocumentPipeline.runPipeline processes any number of uploaded PDF documents through all six stages. On success it returns the takeoff result, RFP draft, and memory context. On any failure it returns a typed PipelineFailure with an error code.
Step 12: Create the API route handlers
Create four API routes under app/api/. Each route uses NextRequest and NextResponse from next/server, never bare Request or new Response.
Start with the health check:
ts
// app/api/health/route.tsimport { NextRequest, NextResponse } from "next/server";export function GET(_request: NextRequest): NextResponse { void _request; return NextResponse.json({ status: "ok" });}
The takeoff endpoint receives PDF files via multipart/form-data, validates the request through IncomingRequestSchema from @reaatech/agent-mesh, and runs the full pipeline:
And the RFP retrieval endpoint, which uses memory.retrieve() with the Next 16 async params pattern:
ts
// app/api/rfp/[id]/route.tsimport { NextRequest, NextResponse } from "next/server";import { createMemoryService } from "../../../../src/services/memory-service.js";export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> },): Promise<NextResponse> { const { id } = await params; try { const memory = await createMemoryService(); const results = await memory.retrieve(id, { limit: 1 }); if (results.length === 0) { return NextResponse.json({ error: "RFP not found" }, { status: 404 }); } return NextResponse.json(results[0], { status: 200 }); } catch { return NextResponse.json( { error: "RFP retrieval requires a persistent store (in-memory only in this recipe version)" }, { status: 404 }, ); }}
Finally, update the home page with a quick reference:
tsx
// app/page.tsxexport default function Home() { return ( <main style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto", fontFamily: "system-ui, sans-serif" }}> <h1>Plan Takeoff to Subcontractor RFP Agent</h1> <p> Convert plan sets and spec docs into a bill of materials and RFP drafts in minutes, not days. </p> <h2>API Endpoints</h2> <ul> <li> <strong>POST /api/takeoff</strong> — Upload plan set PDFs, receive bill of materials </li> <li> <strong>POST /api/rfp</strong> — Generate RFP draft from takeoff data </li> <li> <strong>GET /api/rfp/:id</strong> — Retrieve a generated RFP </li> <li> <strong>GET /api/health</strong> — Health check </li> </ul> <p> Built with agnostic LLM provider via the Vercel AI SDK and the <code>@reaatech/*</code> package family. </p> </main> );}
Expected output: All four endpoints compile. pnpm lint and pnpm typecheck pass with zero errors.
Step 13: Create the barrel export
Export all public types and functions from a single entry point so consumers can import from the package root:
ts
// src/index.tsexport { DocumentPipeline, formatPipelineError } from "./api/document-pipeline.js";export { extractPdfText } from "./services/pdf-extraction.js";export { analyzeTakeoff } from "./services/takeoff-engine.js";export { generateRfp } from "./services/rfp-generator.js";export { parseDocumentContent } from "./services/document-parser.js";export { createMemoryService, retrieveProjectContext, storeTakeoffMemory, shutdownMemory,} from "./services/memory-service.js";export type { TakeoffItem, TakeoffResult, RfpSection, RfpDraft, DocumentSource, PipelineInput, PipelineResult, PipelineErrorCode, ConstructionTrade,} from "./types/index.js";
Expected output: All re-exports resolve. The package exposes a clean public API surface.
Step 14: Write the tests
Write tests for each service, route handler, and the pipeline itself. Start with the LLM layer — it tests the API-key guard and structured output:
Test the full document pipeline — extraction failures, generation failures, multi-document merging, the 60-second timeout guard, and catch block for non-PipelineFailure errors:
Test each API route. The takeoff route test covers success, validation failure, missing files, unexpected errors, pipeline failures, default project name, and non-Error throws:
Expected output:pnpm test passes with all tests green and coverage thresholds of 90% or above on all four metrics (lines, branches, functions, statements).
Step 15: Run the full validation suite
Run typecheck, lint, and tests in sequence:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:
pnpm typecheck exits 0 with no TypeScript errors
pnpm lint exits 0 with no ESLint violations
pnpm test (vitest with coverage) reports all tests passing and coverage at 90%+ across all metrics
Next steps
Add persistent storage — Replace the in-memory AgentMemory with a database-backed provider (PostgreSQL via @reaatech/agent-memory’s storage adapters) so RFP drafts survive server restarts.
Support more trade specializations — Extend the ConstructionTrade union type with plumbing, HVAC, and roofing schemas that include trade-specific line items and compliance requirements.
Add a web UI — Build a Next.js client component that accepts file uploads and displays the bill of materials and RFP draft side by side, giving estimators a visual dashboard instead of raw JSON.
Integrate with project management tools — Export generated RFPs to Procore, Bluebeam, or PlanGrid via their REST APIs so the agent fits into existing GC workflows.
{
for (const key of keys) {
const val = obj[key];
if (val != null && typeof val !== "object") return `${val}`;
}
return fallback;
}
function firstNum(obj: Record<string, unknown>, keys: string[], fallback: number): number {
const userPrompt = `Generate a construction RFP for the ${trade} trade based on these takeoff items:\n\n${itemList}\n\nReturn valid JSON with: title, scope (string), sections (array of { heading, content, items (string[]), budgetEstimate? })`;
const result = await traced("rfp-generation", `generate-rfp-${trade}`, async () => {
let lastError: Error | undefined;
let prompt = userPrompt;
for (let attempt = 0; attempt <= 1; attempt++) {
try {
const { text } = await llmGenerate(prompt, systemPrompt, {
temperature: 0.2,
maxTokens: 4096,
});
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(text) as Record<string, unknown>;
} catch {
throw new PipelineFailure("GENERATION_FAILED", "LLM response was not valid JSON");