A GC estimator spends 2-3 days manually measuring plans, counting fixtures, and typing up scopes for subs. A single missed window or miscalculated yardage can blow the bid. They need a way to ingest PDF plans and specs, extract quantities, and generate a structured bill of materials with subcontractor RFP drafts.
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 recipe builds a Bid Takeoff Agent — a Next.js API server that ingests construction PDF plans, extracts quantities via an LLM, generates a structured bill of materials (BOM) as a downloadable spreadsheet, and drafts subcontractor Requests for Proposals (RFPs) for each trade involved. It’s built for estimators at small general contractors who want to cut the 2-3 day manual takeoff process down to minutes. The stack uses six @reaatech/* packages alongside the Vercel AI SDK, unpdf, Langfuse, and xlsx.
Prerequisites
Node.js >= 22 and pnpm 10.x installed
A Qdrant instance running (or accessible). The quickest way is Docker: docker run -p 6333:6333 qdrant/qdrant
An OpenAI API key with access to the model specified by OPENAI_MODEL (defaults to gpt-5.2)
(Optional) A Cohere API key for reranking
(Optional) A Langfuse account for LLM observability (degrades gracefully if missing)
Familiarity with TypeScript, Next.js App Router API routes, and basic RAG concepts
Step 1: Scaffold the Next.js project
Create a new Next.js project with the App Router, TypeScript, and ESLint. This recipe uses the app/ directory for pages and API routes, and src/ for services and libraries.
Expected output: A standard Next.js shell with app/, src/, tsconfig.json, next.config.ts, and package.json with exact-pinned dependencies for next, react, and react-dom.
Fill in your Qdrant URL and OpenAI key inside .env.
Step 2: Define domain types and Zod schemas
Create src/lib/types.ts. This file holds all the domain interfaces the pipeline passes between services. Every business object — a takeoff document, a quantity item on a BOM, a subcontractor RFP — gets a TypeScript interface and a companion Zod schema for runtime validation.
Expected output: A single file that serves as the data contract for the entire pipeline. The Zod schemas enforce that quantities and prices are never negative, and that required fields like trade and scope are non-empty strings.
Step 3: Create constants and configuration
Create src/lib/constants.ts. This keeps default chunk sizes, system prompts for the LLM, the list of subcontractor trades, and a getConfig() function that reads environment variables with sensible defaults.
ts
import type { TakeoffConfig } from "./types.js";export const DEFAULT_CHUNK_SIZE = 512;export const DEFAULT_CHUNK_OVERLAP = 50;export const DEFAULT_TOP_K = 10;export const QUANTITY_EXTRACTION_SYSTEM_PROMPT = `You are an expert construction estimator. Extract all measurable quantities from the provided construction plans and specifications. For each item, identify the category, description, quantity, unit of measure, unit price, and total price. Return the data as a JSON array of objects matching the QuantityItem schema. Be precise and include all items that can be quantified from the text. Use standard construction units (LF for linear feet, SF for square feet, CY for cubic yards, EA for each, etc.).`;export const RFP_GENERATION_SYSTEM_PROMPT = `You are a general contractor preparing a subcontractor Request for Proposal (RFP). Based on the provided trade, scope of work, and relevant document excerpts, draft a structured RFP. Include clear requirements, reference the relevant document sections, and set a standard response deadline. Return the data as a JSON object matching the SubcontractorRfp schema.`;export const SUBCONTRACTOR_TRADES = [ "electrical", "plumbing", "hvac", "framing", "roofing", "concrete", "drywall", "painting", "flooring", "landscaping",] as const;export function getConfig(): TakeoffConfig { return { qdrantUrl: process.env.QDRANT_URL ?? "http://localhost:6333", collectionName: process.env.DEFAULT_COLLECTION_NAME ?? "bid-documents", openaiApiKey: process.env.OPENAI_API_KEY ?? "", langfusePublicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "", langfuseSecretKey: process.env.LANGFUSE_SECRET_KEY ?? "", langfuseHost: process.env.LANGFUSE_HOST ?? "https://cloud.langfuse.com", cohereApiKey: process.env.COHERE_API_KEY ?? undefined, };}export function getModelId(): string { return process.env.OPENAI_MODEL ?? "gpt-5.2";}
Expected output: Constants with system prompts that instruct the LLM to produce structured JSON. The getConfig() function defaults qdrantUrl to the local Docker endpoint if none is set.
Step 4: Build application error hierarchy
Create src/lib/errors.ts. Every service in the pipeline throws typed errors so route handlers can map them to appropriate HTTP status codes.
Expected output: Five error subclasses that carry a machine-readable code and an HTTP statusCode. The toErrorResponse helper converts any thrown value into a safe JSON response shape.
Step 5: Create the PDF ingestion service
Create src/services/pdf-ingestion-service.ts. This service wraps unpdf to extract text from a PDF buffer, and uses @reaatech/hybrid-rag-ingestion to chunk the extracted text for indexing.
ts
import { extractText, getDocumentProxy, getMeta } from "unpdf";import { DocumentValidator, chunkDocument,} from "@reaatech/hybrid-rag-ingestion";import { ChunkingStrategy } from "@reaatech/hybrid-rag";import type { TakeoffDocument } from "../lib/types.js";import { PdfParseError, ValidationError } from "../lib/errors.js";import { DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP } from "../lib/constants.js";export class PdfIngestionService { private documentValidator: DocumentValidator; constructor() { this.documentValidator = new DocumentValidator({
Expected output: A class with four public methods: loadPdf (extract text without metadata), loadPdfWithMetadata (also extracts PDF metadata), chunkTakeoffDocument (recursive chunking via the ingestion package), and validatePdf (checks file size and format). Files over 20 MB are rejected.
Step 6: Build the RAG pipeline service
Create src/services/rag-pipeline-service.ts. This wraps @reaatech/hybrid-rag-pipeline — a hybrid vector + BM25 retrieval pipeline backed by Qdrant. It connects to Qdrant with OpenAI embeddings and optionally uses Cohere for reranking.
Expected output: A service that lazily initializes the pipeline on first use, delegates all operations to the RAGPipeline instance, and wraps errors in typed RagQueryError.
Step 7: Create the AI service for structured extraction
Create src/services/ai-service.ts. This wraps the Vercel AI SDK (ai + @ai-sdk/openai) with Langfuse tracing for observability. The key method is generateStructured, which prompts the LLM and validates the response against a Zod schema.
ts
import { generateText, streamText } from "ai";import { openai } from "@ai-sdk/openai";import Langfuse from "langfuse";import type { z } from "zod";import { getModelId, getConfig } from "../lib/constants.js";import { LlmExtractionError } from "../lib/errors.js";interface GenerateTextResult { text: string; usage: { inputTokens: number; outputTokens: number };}interface GenerateStructuredResult<T> { object:
Expected output: A service that calls OpenAI via generateText, manually extracts JSON from the response (since it uses generateText rather than generateObject), validates the parsed result against a Zod schema, and logs errors to Langfuse.
Step 8: Build the context planner service
Create src/lib/context-planner-service.ts. This uses @reaatech/context-window-planner to fit document chunks into the LLM’s token budget. The priority-greedy strategy makes sure the system prompt fits first, then the most relevant chunks, and drops anything that exceeds the budget.
Expected output: A planner configured with a token budget (passed by the caller — typically 128K) and a reserved buffer for generation output. Items that don’t fit go into PackingResult.dropped and can be summarized or skipped.
Step 9: Create the bill of materials service
Create src/services/bill-of-materials-service.ts. This service takes document text, extracts quantities via the AI service, assembles them into a BillOfMaterials object, and exports it as an .xlsx buffer.
ts
import * as XLSX from "xlsx";import type { AiService } from "./ai-service.js";import type { QuantityItem, BillOfMaterials } from "../lib/types.js";import { QuantityItemSchema, BillOfMaterialsSchema } from "../lib/types.js";import { QUANTITY_EXTRACTION_SYSTEM_PROMPT } from "../lib/constants.js";import { ValidationError, LlmExtractionError } from "../lib/errors.js";import { z } from "zod";export class BillOfMaterialsService { private aiService: AiService; constructor(aiService: AiService) { this.aiService = aiService; } async extractQuantities(documentText: string): Promise<QuantityItem[]> { if (!documentText || documentText.trim().length === 0) { throw new ValidationError("Document text is required for quantity extraction"); } try { const result = await this.aiService.generateStructured( documentText, z.array(QuantityItemSchema), { system: QUANTITY_EXTRACTION_SYSTEM_PROMPT, maxTokens: 4096 }, ); return result.object; } catch (err) { if (err instanceof ValidationError || err instanceof LlmExtractionError) { throw err; } throw new LlmExtractionError("Failed to extract quantities from document", { originalError: err instanceof Error ? err.message : String(err), }); } } generateBillOfMaterials(items: QuantityItem[], projectName: string): BillOfMaterials { const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0); const tax = subtotal * 0.0; const total = subtotal + tax; return { projectName, date: new Date().toISOString().split("T")[0], items, subtotal, tax, total, }; } exportToXlsx(bom: BillOfMaterials): Buffer { const itemsData = bom.items.map((item) => ({ Category: item.category, Description: item.description, Quantity: item.quantity, Unit: item.unit, "Unit Price": item.unitPrice, "Total Price": item.totalPrice, Notes: item.notes ?? "", })); const ws = XLSX.utils.json_to_sheet(itemsData); XLSX.utils.sheet_add_aoa(ws, [["Category", "Description", "Quantity", "Unit", "Unit Price", "Total Price", "Notes"]], { origin: "A1" }); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "BOM"); const buf: Buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer; return buf; } validateBom(data: unknown): BillOfMaterials { return BillOfMaterialsSchema.parse(data); }}
Expected output: The exportToXlsx method creates a worksheet with headers in row A1 and data rows below, writes it to a workbook, and returns the binary buffer that route handlers can base64-encode.
Step 10: Build the subcontractor RFP service
Create src/services/subcontractor-rfp-service.ts. This generates one or more RFPs by matching BOM item categories to standard subcontractor trades (electrical, plumbing, HVAC, etc.), then drafting each via the AI service.
ts
import type { AiService } from "./ai-service.js";import type { SubcontractorRfp, TakeoffDocument, BillOfMaterials } from "../lib/types.js";import { SubcontractorRfpSchema } from "../lib/types.js";import { RFP_GENERATION_SYSTEM_PROMPT, SUBCONTRACTOR_TRADES } from "../lib/constants.js";import { TakeoffContextPlanner } from "../lib/context-planner-service.js";const MAX_TOKENS = 128000;const RESERVED_TOKENS = 2000;export class SubcontractorRfpService { private aiService: AiService; private contextPlanner: TakeoffContextPlanner; constructor
Expected output:generateAllRfps deduplicates trades across BOM items — if your framing category maps to the framing trade, only one RFP is generated for that trade (with descriptions from all matching items).
Step 11: Wire the document processor orchestrator
Create src/services/document-processor-service.ts. This is the top-level pipeline that ties all services together. It also exports a singleton factory createDocumentProcessorService() so route handlers share one instance.
ts
import { PdfIngestionService } from "./pdf-ingestion-service.js";import { RagPipelineService } from "./rag-pipeline-service.js";import { AiService } from "./ai-service.js";import { BillOfMaterialsService } from "./bill-of-materials-service.js";import { SubcontractorRfpService } from "./subcontractor-rfp-service.js";import { TakeoffContextPlanner } from "../lib/context-planner-service.js";import type { TakeoffRequest, TakeoffResult } from "../lib/types.js";import { QuantityItemSchema } from "../lib/types.js";import { QUANTITY_EXTRACTION_SYSTEM_PROMPT } from "../lib/constants.js";import { getConfig } from "../lib/constants.js";import
Expected output: A resilient pipeline: if any step fails (RAG ingest, extraction, RFP generation), the error is captured in the errors array and the result still returns whatever was successfully processed. The orchestrator never throws — downstream callers receive a complete TakeoffResult with partial data.
Step 12: Build the API routes
This recipe exposes four API routes. They all live under app/api/.
import { type NextRequest, NextResponse } from "next/server";import { createDocumentProcessorService } from "../../../src/services/document-processor-service.js";import { AppError, toErrorResponse } from "../../../src/lib/errors.js";import { SUBCONTRACTOR_TRADES } from "../../../src/lib/constants.js";const validTrades = SUBCONTRACTOR_TRADES as readonly string[];export async function POST(req: NextRequest) { try { const body: unknown = await req.json(); const data = body as { trade?: string; scope?: string; docIds?: string[] }; const trade = data.trade; const scope = data.scope; const docIds = data.docIds; if (!trade || !validTrades.includes(trade)) { return NextResponse.json( { error: `Invalid trade. Must be one of: ${validTrades.join(", ")}`, code: "VALIDATION_ERROR", }, { status: 400 }, ); } const processor = createDocumentProcessorService(); const rfpService = processor.getRfpService(); const chunks: Array<{ content: string; id: string }> = []; if (docIds && docIds.length > 0) { for (const docId of docIds) { chunks.push({ id: docId, content: docId }); } } const rfp = await rfpService.generateRfp(trade, scope ?? `${trade} work`, chunks); return NextResponse.json(rfp, { status: 200 }); } catch (err) { if (err instanceof AppError) { return NextResponse.json(toErrorResponse(err), { status: err.statusCode }); } return NextResponse.json( { error: "Internal server error", code: "INTERNAL_ERROR" }, { status: 500 }, ); }}
/api/health — health check
File: app/api/health/route.ts
ts
import { NextResponse } from "next/server";export function GET() { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), });}
Expected output: Four routes that accept requests (multipart form for takeoff, JSON for BOM and RFP, plain GET for health) and return structured JSON. The /api/takeoff endpoint accepts a PDF upload, while /api/bom accepts raw document text for lighter-weight use. The /api/rfp route validates that the requested trade is in the list of known subcontractor trades.
Step 13: Update the entry point and home page
Create src/index.ts as a single re-export so consumers import just DocumentProcessorService:
ts
export { DocumentProcessorService } from "./services/document-processor-service.js";
Update app/page.tsx to show the API endpoints available:
Expected output: The root page renders a clean API reference table with a link to the project documentation. No JSX tests are required — coverage is scoped to runtime TypeScript files (src/**/*.ts, app/**/route.ts) only.
Step 14: Write and run the tests
The test suite covers every source file: domain types, errors, services, route handlers, constants, and utility modules. Each test mocks external dependencies (network, unpdf, xlsx, @reaatech/* packages, Langfuse) via vi.mock and vi.hoisted. Route handler tests import and call route functions directly with a constructed NextRequest — no running server needed.
The vitest config enforces 90% coverage thresholds across lines, branches, functions, and statements for all src/**/*.ts and app/**/route.ts files:
Create vitest.config.ts at the project root with the content above.
Run all tests with coverage:
terminal
pnpm test
Expected output: All 15 test files pass. Sample output from the types test:
code
✓ accepts a valid item
✓ rejects negative quantity
✓ rejects missing required field
✓ accepts a valid BOM
✓ accepts BOM with empty items array
✓ rejects BOM with missing projectName
✓ accepts a valid RFP
✓ rejects RFP with missing trade
Coverage should meet the 90% thresholds on all four axes.
Next steps
Add a chunk summarizer — dropped chunks from the context planner can be summarized with a separate LLM call and re-included, rather than discarded
Wire the UI — build a file-upload page with drag-and-drop that calls /api/takeoff and renders the BOM as a table with a download link for the spreadsheet
Add multi-PDF support — extend processTakeoff to accept an array of files (plans, specs, addenda) and merge their extracted chunks before indexing
Integrate with construction platforms — replace the manual PDF upload with a webhook that receives plan sets directly from Procore, Bluebeam, or similar tools
Add cost estimation — replace the placeholder tax = subtotal * 0.0 with actual markup logic based on your region and project type
Deploy with persistent Qdrant — run Qdrant on a cloud provider (Qdrant Cloud, or use Supabase Vector) so the indexed collection survives restarts