A GC estimator spends days manually measuring plan sets and spec docs to produce a bill of materials and subcontractor RFPs. Errors in takeoff lead to underbid losses or overbid rejections. The estimator needs a way to automate extraction of quantities, materials, and specs from PDFs and images, then generate structured RFPs for subs.
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 builds an Auto-Takeoff Agent — a system that automates the construction estimating workflow. Given a set of architectural plan documents (PDFs, images), it runs OCR to extract text and tables, uses an LLM to interpret the data and produce a structured Bill of Materials (BOM), groups BOM line items by trade, and generates subcontractor Request for Proposal (RFP) documents. It also enforces per-job and per-user spending budgets and caches LLM results to save time and cost on repeated inputs.
You’ll use a Next.js App Router frontend and API layer backed by six REAA packages that each handle one piece of the pipeline: document extraction, LLM caching, budget enforcement, task persistence, agent mesh types, and markdown validation. You’ll also add a Fastify server alongside Next.js for the heavy pipeline endpoints. By the end you’ll have a working system you can test with a single POST request.
Prerequisites
Node.js 22+ and pnpm 10
An OpenAI API key (set as OPENAI_API_KEY in .env)
Basic familiarity with Next.js App Router, TypeScript, and Zod schemas
Step 1: Scaffold the project
Start with an empty directory and create the project skeleton. This is a Next.js 16+ App Router project with TypeScript. Create package.json with exact-pinned dependencies:
# Env vars used by agnostic-bid-prep-takeoff-agent.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Required: OpenAI API key for LLM calls via Vercel AI SDKOPENAI_API_KEY=<your-openai-key># Langfuse tracing (optional — remove if not using)LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=<your-langfuse-host># Model ID for LLM inference (optional — defaults to gpt-5.2)MODEL_ID=<your-model-id># Fastify server port (optional — defaults to 3001)FASTIFY_PORT=3001
Run pnpm install to lock all dependencies.
Expected output:pnpm-lock.yaml appears, node_modules/ is populated, and pnpm typecheck exits 0.
Step 2: Define the domain types with Zod schemas
The system processes construction documents through several stages. Each stage has a typed schema. Create src/types/material.ts:
ts
import { z } from "zod";export const MaterialItemSchema = z.object({ name: z.string(), quantity: z.number().nonnegative(), unit: z.string(), category: z.string(), specReference: z.string().optional(),});export type MaterialItem = z.infer<typeof MaterialItemSchema>;
Create src/types/bom.ts — a Bill of Materials is a collection of material items under a project:
ts
import { z } from "zod";import { MaterialItemSchema } from "./material.js";export const BillOfMaterialsSchema = z.object({ projectId: z.string(), items: z.array(MaterialItemSchema), generatedAt: z.coerce.date(),});export type BillOfMaterials = z.infer<typeof BillOfMaterialsSchema>;
Create src/types/plan-set.ts — the input a user uploads:
ts
import { z } from "zod";export const PlanSetDocumentSchema = z.object({ artifactId: z.string(), fileName: z.string(), pageCount: z.number(),});export type PlanSetDocument = z.infer<typeof PlanSetDocumentSchema>;export const PlanSetSchema = z.object({ id: z.string(), documents: z.array(PlanSetDocumentSchema), projectName: z.string(), uploadedAt: z.date(),});export type PlanSet = z.infer<typeof PlanSetSchema>;
Create src/types/trade-scope.ts — line items grouped by construction trade:
ts
import { z } from "zod";import { MaterialItemSchema } from "./material.js";export const TradeScopeSchema = z.object({ trade: z.string(), lineItems: z.array(MaterialItemSchema), notes: z.string().optional(),});export type TradeScope = z.infer<typeof TradeScopeSchema>;
Create src/types/rfp.ts — the final RFP document:
ts
import { z } from "zod";export const RfpDocumentSchema = z.object({ trade: z.string(), scopeOfWork: z.string(), bidDueBy: z.date(), status: z.enum(["pending", "accepted", "rejected"]),});export type RfpDocument = z.infer<typeof RfpDocumentSchema>;
Create src/types/validation.ts that re-exports markdown validation types:
ts
import type { ValidationResult, Finding } from "@reaatech/agents-markdown";export interface TakeoffValidationResult extends Omit<ValidationResult, "type"> { pipelineStage: string;}export type { ValidationResult, Finding };
Create src/types/takeoff-job.ts that re-exports agent mesh message types:
ts
import { IncomingRequestSchema, AgentResponseSchema, HealthStatusSchema, ContextPacketSchema, type IncomingRequest, type AgentResponse,} from "@reaatech/agent-mesh";export { IncomingRequestSchema, AgentResponseSchema, HealthStatusSchema, ContextPacketSchema };export type { IncomingRequest, AgentResponse };
Wire up a barrel export at src/types/index.ts:
ts
export { PlanSetDocumentSchema, type PlanSetDocument, PlanSetSchema, type PlanSet,} from "./plan-set.js";export { MaterialItemSchema, type MaterialItem } from "./material.js";export { BillOfMaterialsSchema, type BillOfMaterials } from "./bom.js";export { TradeScopeSchema, type TradeScope } from "./trade-scope.js";export { RfpDocumentSchema, type RfpDocument } from "./rfp.js";export { IncomingRequestSchema, AgentResponseSchema, type IncomingRequest, type AgentResponse,} from "./takeoff-job.js";export { type TakeoffValidationResult, type ValidationResult, type Finding,} from "./validation.js";
Expected output:pnpm typecheck still passes. You have six Zod schemas covering the full data model — documents, materials, BOMs, trades, and RFPs.
Step 3: Build the document extraction service
The extraction service wraps @reaatech/media-pipeline-mcp-doc-extraction which provides OCR, table extraction, field extraction, and summarization. Create src/services/document-extraction.ts:
Each method delegates to the underlying REAA package and wraps errors in a typed DocumentExtractionError that carries the artifact ID and operation name.
Expected output:pnpm typecheck passes. The createDocumentExtractionService factory returns an operations object with ocr, extractTables, extractFields, and summarize methods.
Step 4: Add LLM caching with cache-manager
LLM calls are expensive. The @reaatech/llm-cache package provides a CacheEngine that stores and retrieves results by exact and semantic similarity. Create src/services/cache-manager.ts:
The createLlmCache function sets up an in-memory cache with OpenAI embeddings for semantic matching. The getCachedOrCompute helper checks the cache first — if there’s an exact or semantic (cosine similarity >= 0.8) match, it returns the cached value instead of calling the LLM.
Expected output:pnpm typecheck passes. You can create a CacheEngine instance and use getCachedOrCompute around any async compute function.
Step 5: Wire up the budget engine
Construction budget tracking prevents runaway LLM spend. The @reaatech/agent-budget-engine package provides a BudgetController with per-scope limits, soft/hard caps, and event-driven alerts. Create src/services/budget-manager.ts:
Two budgets are defined: a wildcard scope (*) at $5.00 as a global ceiling, and a per-plan-set scope at $1.00 so one job can’t exhaust the whole account. The hard-stop event logs an error when a budget is fully spent; the threshold-breach event warns at 80% usage.
Expected output: Running a quick test confirms createBudgetManager() returns a controller with check, record, and getState methods. Budget events fire when spending approaches limits.
Step 6: Create task persistence
Every takeoff job and RFP needs durable storage. The @reaatech/a2a-reference-persistence package provides InMemoryTaskStore and FileSystemTaskStore. Create a shared store singleton and a wrapper module in src/store.ts:
ts
import { InMemoryTaskStore } from "@reaatech/a2a-reference-persistence";export const taskStore = new InMemoryTaskStore();
Then create src/services/task-persistence.ts with CRUD wrappers:
ts
import { InMemoryTaskStore, FileSystemTaskStore } from "@reaatech/a2a-reference-persistence";export class NotFoundError extends Error { constructor(id: string) { super(`Task not found: ${id}`); this.name = "NotFoundError"; }}export interface TaskStoreConfig { outputPath?: string;}export function createTaskStore(config?: TaskStoreConfig) { if (config?.outputPath) { return new FileSystemTaskStore({ path: config.outputPath }); } return new InMemoryTaskStore();}type TaskStore = InMemoryTaskStore | FileSystemTaskStore;export async function createJob( store: TaskStore, task: Record<string, unknown>,): Promise<Record<string, unknown>> { await store.create(task as never); return task;}export async function getJob( store: TaskStore, id: string, options?: { historyLength?: number },): Promise<Record<string, unknown>> { const task = await store.get(id, options); if (!task) { throw new NotFoundError(id); } return task;}export async function updateJob( store: TaskStore, id: string, updates: Record<string, unknown>,): Promise<Record<string, unknown>> { const updated = await store.update(id, updates); if (!updated) { throw new NotFoundError(id); } return updated;}export async function listJobs( store: TaskStore, options?: { contextId?: string; status?: string; pageSize?: number; pageToken?: string; historyLength?: number; },) { return store.list(options);}export async function addArtifactToJob( store: TaskStore, jobId: string, artifact: Record<string, unknown>,): Promise<void> { await store.addArtifact(jobId, artifact as never);}export async function cancelJob( store: TaskStore, id: string,) { return store.cancel(id);}
Expected output:pnpm typecheck passes. You can create, read, update, and list jobs. Both in-memory and file-system backends are available.
Step 7: Implement the LLM processor
The LLM processor is the brain of the system. It uses the Vercel AI SDK (ai + @ai-sdk/openai) with Zod schema-guided structured output, optional caching, budget checks, and Langfuse tracing. Create src/services/llm-processor.ts:
The file then exports three functions. First, interpretExtractedData takes OCR text and tables and returns a structured object matching the given Zod schema. It builds a prompt from the extracted text and tables, optionally checks the cache and budget, calls generateText with Output.object() for structured output, and records spend:
ts
export async function interpretExtractedData( ocrText: string, tables: string[], fieldSchema?: z.ZodType, deps?: LlmServiceDeps,): Promise<unknown> { try { const prompt = [ "Extract structured data from the following construction document text and tables.", "", "=== TEXT ===", ocrText, ...(tables.length > 0 ? ["", "=== TABLES ===", ...tables] :
Next, generateBom converts extracted material items into a full Bill of Materials:
ts
export async function generateBom( extractedItems: unknown[], bomSchema: z.ZodType, deps?: LlmServiceDeps,): Promise<unknown> { try { const prompt = [ "Generate a Bill of Materials from the following extracted construction line items.", "", "=== ITEMS ===", JSON.stringify(extractedItems, null, 2), ].join("\n"); if (deps?.cacheEngine) { const cacheKey = `bom:${
Finally, generateRfp produces subcontractor RFP text scoped to a single trade:
ts
export async function generateRfp( tradeScope: { trade: string; lineItems: unknown[]; notes?: string }, projectContext?: Record<string, string>, deps?: LlmServiceDeps,): Promise<string> { try { const prompt = [ `Write a professional subcontractor Request for Proposal for the ${tradeScope.trade} trade.`, "", "=== SCOPE ITEMS ===", JSON.stringify(tradeScope.lineItems, null, 2),
Each function follows the same pattern: build a prompt, check the budget, check the cache via getCachedOrCompute, call generateText, record spend, then return the result. If the budget is exceeded, a BudgetExceededError is thrown before any LLM call. Zod schema validation runs on the structured output to catch malformed responses early.
Expected output:pnpm typecheck passes. The module exports three async functions that accept optional deps for budget and cache integration.
Step 8: Orchestrate the takeoff pipeline
The takeoff engine ties together document extraction, LLM processing, budget enforcement, and task persistence into a single pipeline. Create src/services/takeoff-engine.ts:
ts
import { type BillOfMaterials, BillOfMaterialsSchema } from "../types/bom.js";import type { PlanSetDocument } from "../types/plan-set.js";import { interpretExtractedData, generateBom, BudgetExceededError, type LlmServiceDeps } from "./llm-processor.js";import { createLlmCache, getCachedOrCompute } from "./cache-manager.js";import { BudgetScope } from "@reaatech/agent-budget-types";import { MaterialItemSchema } from "../types/material.js";export interface PipelineServices { ops: { ocr: (config: { artifactId: string; format?: string
The pipeline processes documents in phases: extraction runs per-document with graceful error handling (a failed OCR doesn’t stop the batch), then a budget preflight check runs, then the LLM interprets all extracted data into a BOM (cached by plan set ID, combining OCR text with field extraction results when available). Each LLM call records spend through the budget controller. If validation fails, the job is persisted with a validation_failed status rather than throwing. On success, the completed job is persisted.
Expected output:pnpm typecheck passes. The processPlanSet function handles 0, 1, or many documents, partial failures, budget enforcement, and BOM validation.
Step 9: Generate subcontractor RFPs from the BOM
Once a BOM is ready, the RFP generator groups line items by trade and produces a sub-RFP for each. Create src/services/rfp-generator.ts:
If the BOM has concrete (Structural), wiring (Electrical), and piping (Plumbing) items, this function generates three separate RFPs — one per trade — each with a scope-of-work paragraph generated by the LLM and a default 14-day bid deadline.
Expected output:pnpm typecheck passes. An empty BOM returns an empty result; a BOM with items grouped by category produces one RFP per trade.
Step 10: Wire Next.js API routes
The Next.js App Router exposes the pipeline over HTTP. Create the health check at app/api/health/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";export function GET(_req: NextRequest) { void _req; return NextResponse.json({ status: "ok", version: "0.1.0" });}
Create the takeoff endpoint at app/api/takeoff/route.ts. It accepts a POST with a list of document references, validates the input with the IncomingRequestSchema from @reaatech/agent-mesh, creates a DocumentExtractionService and BudgetManager instance, then calls processPlanSet:
ts
import { NextRequest, NextResponse } from "next/server";import { IncomingRequestSchema } from "../../../src/types/takeoff-job.js";import { BudgetExceededError } from "../../../src/services/llm-processor.js";import { processPlanSet } from "../../../src/services/takeoff-engine.js";import { createDocumentExtractionService, extractDocumentText, extractTables, extractStructuredFields,} from "../../../src/services/document-extraction.js";import { createBudgetManager, recordSpend } from "../../../src/services/budget-manager.js";import { taskStore } from "../../../src/store.js";import type { PlanSetDocument } from "../../../src/types/plan-set.js";import type { FieldSchema }
Create the takeoff lookup and cancel routes at app/api/takeoff/[id]/route.ts:
Create the RFP generation endpoint at app/api/rfp/route.ts. It accepts a BOM JSON body, validates it against BillOfMaterialsSchema, and calls generateSubcontractorRfps:
ts
import { NextRequest, NextResponse } from "next/server";import { generateSubcontractorRfps } from "../../../src/services/rfp-generator.js";import { BillOfMaterialsSchema } from "../../../src/types/bom.js";import type { BillOfMaterials } from "../../../src/types/bom.js";import { taskStore } from "../../../src/store.js";export async function POST(req: NextRequest) { try { const body = (await req.json()) as { bom: BillOfMaterials; projectContext?: Record<string, string>; }; const { bom, projectContext } = body; try { BillOfMaterialsSchema.parse(bom); } catch { return NextResponse.json( { error: "invalid bom" }, { status: 400 }, ); } const result = await generateSubcontractorRfps(bom, projectContext, { taskStore, } as never); return NextResponse.json(result); } catch { return NextResponse.json( { error: "internal server error" }, { status: 500 }, ); }}
Create the RFP lookup route at app/api/rfp/[id]/route.ts:
Expected output:pnpm typecheck passes. The Next.js routes each export named functions (GET, POST, DELETE) that use NextRequest and NextResponse.json(). All route handlers use params as a Promise (Next.js 15+ convention).
Step 11: Add the home page and Fastify server
Update the home page at app/page.tsx to document the available endpoints:
tsx
export default function Home() { return ( <div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif", maxWidth: 800, margin: "0 auto" }}> <h1>Auto-Takeoff Agent for Small GC Bid Prep</h1> <p>Convert plan sets to BOM + sub RFPs in minutes, not days.</p> <h2>API Endpoints</h2> <ul> <li><code>POST /api/takeoff</code> — Submit a plan set for takeoff</li> <li><code>GET /api/takeoff/:id</code> — Get takeoff job status</li> <li><code>DELETE /api/takeoff/:id</code> — Cancel a takeoff job</li> <li><code>POST /api/rfp</code> — Generate subcontractor RFPs from a BOM</li> <li><code>GET /api/rfp/:id</code> — Get an RFP document</li> <li><code>GET /api/budget</code> — View budget status</li> <li><code>GET /api/health</code> — Health check</li> </ul> </div> );}
Now add a Fastify server alongside Next.js for the heavy pipeline endpoints. Create src/server.ts:
ts
import Fastify from "fastify";import type { BillOfMaterials } from "./types/bom.js";import { generateSubcontractorRfps } from "./services/rfp-generator.js";import { getJob } from "./services/task-persistence.js";import { processPlanSet } from "./services/takeoff-engine.js";import { createDocumentExtractionService, type FieldSchema } from "./services/document-extraction.js";import { createBudgetManager, getBudgetStatus } from "./services/budget-manager.js";import { BudgetScope } from "@reaatech/agent-budget-types";import { taskStore } from "./store.js";export async function start() {
Create the barrel export at src/index.ts to expose the public API:
ts
export { createDocumentExtractionService, extractDocumentText, extractTables, extractStructuredFields, summarizeContent, DocumentExtractionError,} from "./services/document-extraction.js";export { createLlmCache, getCachedOrCompute,} from "./services/cache-manager.js";export { createBudgetManager, preflightCheck, recordSpend, getBudgetStatus,} from "./services/budget-manager.js";export { createTaskStore, createJob, getJob, updateJob, listJobs, addArtifactToJob, cancelJob, NotFoundError,} from "./services/task-persistence.js";export { interpretExtractedData, generateBom, generateRfp, LLMServiceError, BudgetExceededError,} from "./services/llm-processor.js";export { processPlanSet } from "./services/takeoff-engine.js";export { generateSubcontractorRfps } from "./services/rfp-generator.js";export { start } from "./server.js";export function getVersion(): string { return "0.1.0";}export * from "./types/index.js";
Expected output:pnpm typecheck and pnpm lint both pass. You now have two HTTP servers (Next.js on port 3000, Fastify on port 3001) sharing the same service layer.
Step 12: Run the tests and verify
All modules have comprehensive test coverage at 90%+ thresholds. Run the test suite:
terminal
pnpm test
Expected output: All tests pass with coverage above 90%. The test suite covers: