A bookkeeper at a 3-person CPA firm spends 10+ hours per week manually sorting client receipts forwarded via email or upload. Each receipt must be reviewed to identify the vendor, extract the amount, and assign the correct GL category. During tax season, this backlog balloons, causing overtime and errors. The bookkeeper needs a way to automate this drudgery so they can focus on reconciliations and client advisory.
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.
A bookkeeper at a small CPA firm spends 10+ hours per week manually sorting client receipts — identifying the vendor, extracting the amount, and assigning a GL category. This tutorial walks you through building an automated receipt classifier agent that handles that workflow end to end: it extracts text from PDFs and images via OCR, classifies each receipt with an LLM, validates the output through a guardrail chain, tracks spend and evaluation trajectories, and surfaces everything through a Next.js upload UI. You’ll use the Vercel AI SDK with generateText and Output.object for structured LLM output, unpdf and tesseract.js for document parsing, and REAA (Reusable Enterprise AI Agent) packages for agent mesh, guardrails, budget tracking, golden eval harness, and markdown utilities.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
An OpenAI API key with access to gpt-5.2-mini
A Langfuse account with public and secret keys (for observability tracing)
Basic familiarity with TypeScript, Next.js App Router, and REST APIs
Step 1: Scaffold the project and install dependencies
Create the project directory and cd into it:
terminal
mkdir agnostic-receipt-classifier-agent && cd agnostic-receipt-classifier-agent
Create package.json with exact-pinned dependencies:
Expected output:pnpm install exits 0 with no errors. Your project root contains node_modules/ and pnpm-lock.yaml.
Step 2: Configure environment variables
Create .env.example with placeholder values. The classifier reads the OpenAI key, model name, Langfuse credentials, and a Fastify toggle:
env
# Env vars used by agnostic-receipt-classifier-agent.# Keep placeholders only — never commit real values.OPENAI_API_KEY=<your-openai-key>RECEIPT_CLASSIFIER_MODEL=gpt-5.2-miniLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>ENABLE_FASTIFY=trueFASTIFY_PORT=3001
Copy it to .env.local and fill in your real credentials:
terminal
cp .env.example .env.local
Edit .env.local and replace the placeholder values with your OpenAI API key and Langfuse keys.
Expected output: The project can read process.env.OPENAI_API_KEY, process.env.RECEIPT_CLASSIFIER_MODEL, and the Langfuse vars at runtime.
Step 3: Define the receipt types
Create src/types/receipt.ts. This file serves as a re-export hub for types from the REAA packages and defines the core domain types. ClassificationResult is what the LLM returns; ReceiptDocument is the full record including raw text and metadata; ReceiptPipelineContext tracks state through the pipeline:
Create src/types/index.ts to re-export the domain types:
ts
export type { GlCategory } from "./receipt.js";export type { ReceiptDocument } from "./receipt.js";export type { ClassificationResult } from "./receipt.js";export type { ReceiptPipelineContext } from "./receipt.js";
Expected output:pnpm typecheck passes with no errors.
Step 4: Build the document extractor
Create src/services/document-extractor.ts. This class handles both PDF and image receipt files. It checks the first four bytes of the buffer to determine the file type, then routes to unpdf for PDF extraction or tesseract.js for OCR on images:
Expected output:pnpm typecheck passes. The extractor can tell a PDF (starts with %PDF) from an image and route accordingly.
Step 5: Build the AI receipt classifier
Create src/services/receipt-classifier.ts. This is the core AI module. It uses generateText from the Vercel AI SDK with Output.object to produce structured JSON validated against the ClassificationResult schema via Zod. It includes an automatic retry — if the first LLM call fails, it tries once more before returning a fallback:
Expected output:pnpm typecheck passes. The classifier accepts a text string and returns structured classification data with a CostTelemetry record.
Step 6: Create the guardrail chain
Create src/guardrails/receipt-guardrails.ts. This builds a validation chain using @reaatech/guardrail-chain with three guardrails: amount sanity (rejects $0 or > $1M), GL category validity (routes unknown categories to “Other”), and vendor presence (rejects empty or whitespace-only vendors):
Expected output:pnpm typecheck passes. Each guardrail runs in sequence; if any fails, the chain reports which one and the classification is rejected.
Step 7: Build the budget spend tracker and eval tracker
Create src/budget/spend-tracker.ts. This uses @reaatech/agent-budget-spend-tracker’s SpendStore to record per-classification costs, retrieve cumulative spend by user or client, and detect spend spikes:
Create src/eval/trajectory-tracker.ts. This uses @reaatech/agent-eval-harness-golden to create golden trajectories from each classification, compare new classifications against goldens, and batch-curate them for quality reports:
Expected output:pnpm typecheck passes. Both trackers use the singleton pattern so they’re shared across the pipeline.
Step 8: Add Langfuse observability
Create src/observability/receipt-tracer.ts. This creates a Langfuse trace with a span for each pipeline run, recording the classification tokens, cost, guardrail outcome, and spend increment:
Expected output:pnpm typecheck passes. Each pipeline execution creates a trace in your Langfuse project.
Step 9: Wire the receipt pipeline
Create src/pipeline/receipt-pipeline.ts. This is the orchestrator that connects every component in sequence. Given a raw file buffer, it runs the document extractor, AI classifier, guardrail chain, spend tracker, eval tracker, and tracer — all in one transaction:
ts
import { IncomingRequestSchema, AgentResponseSchema, type AgentResponse, type ContextPacket } from "@reaatech/agent-mesh";import { randomId } from "@reaatech/agents-markdown";import { DocumentExtractor } from "../services/document-extractor.js";import { ReceiptClassifier } from "../services/receipt-classifier.js";import { buildReceiptGuardrailChain, validateClassification } from "../guardrails/receipt-guardrails.js";import { ReceiptSpendTracker } from "../budget/spend-tracker.js";import { ReceiptEvalTracker } from "../eval/trajectory-tracker.js";import { ReceiptTracer } from "../observability/receipt-tracer.js";import type { GuardrailChain } from "@reaatech/guardrail-chain";export
Expected output:pnpm typecheck passes. The pipeline accepts a Uint8Array buffer and returns a structured AgentResponse.
Step 10: Create the Next.js API routes
Create app/api/classify/route.ts — accepts JSON with a text field and returns the structured classification:
ts
import { type NextRequest, NextResponse } from "next/server";import { z } from "zod";import { ReceiptClassifier } from "../../../src/services/receipt-classifier.js";const classifySchema = z.object({ text: z.string().min(1, "Text must not be empty"),});export async function POST(request: NextRequest): Promise<NextResponse> { try { let body: unknown; try { body = await request.json(); } catch { return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); } const parsed = classifySchema.safeParse(body); if (!parsed.success) { const firstIssue = parsed.error.issues[0]; const message = firstIssue.message; return NextResponse.json({ error: message }, { status: 422 }); } const classifier = new ReceiptClassifier(); const classification = await classifier.classify(parsed.data.text); return NextResponse.json(classification); } catch (err) { const message = err instanceof Error ? err.message : "Classification failed"; return NextResponse.json({ error: message }, { status: 500 }); }}
Create app/api/upload/route.ts — accepts a multipart file upload and runs the full pipeline:
Create app/api/health/route.ts — a simple health check:
ts
import { NextResponse } from "next/server";export function GET() { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), });}
Expected output:pnpm typecheck passes. The three routes handle classify, upload, and health respectively.
Step 11: Build the Fastify server
Create src/api/fastify-server.ts. When ENABLE_FASTIFY=true, this sidecar server provides the same classification and upload endpoints plus a spend query endpoint, all on a configurable port:
Expected output:pnpm typecheck passes. The Fastify server starts on port 3001 and exposes four routes.
Step 12: Add instrumentation and the entry point
Create src/instrumentation.ts. This runs at Next.js startup (when NEXT_RUNTIME === "nodejs") and initializes the Langfuse client. The experimental.instrumentationHook: true flag in next.config.ts (set in Step 1) makes sure this function fires:
ts
import { Langfuse } from "langfuse";export function register(): void { if (process.env.NEXT_RUNTIME !== "nodejs") return; try { new Langfuse({ publicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "", secretKey: process.env.LANGFUSE_SECRET_KEY ?? "", }); } catch (err) { console.error("Failed to initialize observability:", err); }}
Create src/index.ts — the package entry point that re-exports all modules and optionally starts the Fastify server:
ts
export const SCAFFOLD_VERSION = "0.1.0" as const;export { ReceiptPipeline } from "./pipeline/receipt-pipeline.js";export { ReceiptClassifier } from "./services/receipt-classifier.js";export { DocumentExtractor } from "./services/document-extractor.js";export { ReceiptSpendTracker } from "./budget/spend-tracker.js";export { ReceiptEvalTracker } from "./eval/trajectory-tracker.js";export { startServer, stopServer } from "./api/fastify-server.js";if (process.env.ENABLE_FASTIFY === "true") { void import("./api/fastify-server.js").then(({ startServer }) => { startServer().catch(console.error); });}
Expected output:pnpm typecheck passes. Starting the dev server with pnpm dev triggers the instrumentation hook.
Step 13: Create the upload UI
Replace app/page.tsx with a client-side upload form. It sends a PDF or image file to /api/upload and displays the classification result:
Expected output: Run pnpm dev, open http://localhost:3000, and upload a test PDF or receipt image. The response shows the extracted vendor, amount, GL category, and confidence score.
Step 14: Run the tests
Create tests/services/receipt-classifier.test.ts that mocks the AI SDK and verifies the classifier handles various receipt types, retries, and error cases:
Expected output: All tests pass, numFailedTests is 0, and the coverage report shows >= 90% line, branch, function, and statement coverage on runtime code under src/ and app/api/.
Next steps
Add multi-tenancy — Extend the Fastify server to authenticate clients by API key and scope spend tracking per firm.
Integrate with accounting software — Route classified receipts directly to QuickBooks or Xero via their APIs, using the GL category mapping.
Add a batch processing mode — Accept a ZIP of receipts and classify them in parallel, producing a CSV report of all classifications.
Deploy as a Docker service — Package the Next.js app and Fastify sidecar into a single Docker Compose stack for on-premise deployment.