How to Build a document extraction Agent Using LangGraph in TypeScript for fintech

By Cyprian AaronsUpdated 2026-04-21
document-extractionlanggraphtypescriptfintech

A document extraction agent takes raw financial documents — invoices, bank statements, KYC forms, trade confirmations — and turns them into structured data your systems can validate, store, and act on. In fintech, that matters because manual extraction is slow, error-prone, and expensive, and every mistake can become a compliance issue or a customer-facing defect.

Architecture

For a fintech-grade document extraction agent, keep the graph small and explicit:

  • Ingestion node

    • Accepts PDFs, images, or text from object storage or an upload service.
    • Normalizes the input into a stable document payload with metadata like documentId, customerId, and jurisdiction.
  • OCR / text extraction node

    • Runs OCR for scanned documents.
    • Produces raw text plus page-level confidence scores.
  • LLM extraction node

    • Converts raw text into a strict JSON shape.
    • Extracts fields like account number, invoice total, currency, dates, legal entity name, and tax identifiers.
  • Validation node

    • Checks schema validity and business rules.
    • Flags mismatches like totals not adding up or unsupported currencies.
  • Human review / exception node

    • Routes low-confidence or policy-sensitive documents to an ops queue.
    • Preserves an audit trail for every manual override.
  • Persistence / audit node

    • Writes extracted data, confidence scores, model version, prompt version, and decision path.
    • This is non-negotiable in regulated workflows.

Implementation

1) Define the state and the graph shape

Use a typed state so every node knows exactly what it can read and write. In LangGraph JS/TS, StateGraph is the main entry point.

import { Annotation, StateGraph, START, END } from "@langchain/langgraph";

const DocumentState = Annotation.Root({
  documentId: Annotation<string>(),
  customerId: Annotation<string>(),
  jurisdiction: Annotation<string>(),
  rawText: Annotation<string>(),
  extracted: Annotation<Record<string, unknown> | null>(),
  confidence: Annotation<number>(),
  validationErrors: Annotation<string[]>(),
  needsReview: Annotation<boolean>(),
});

type DocumentStateType = typeof DocumentState.State;

This gives you a single source of truth for the workflow. In fintech systems, that matters because downstream services need stable contracts.

2) Add nodes for extraction and validation

Keep OCR outside the LLM if possible. The graph should consume text that already has provenance attached.

import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";

const ExtractionSchema = z.object({
  documentType: z.string(),
  counterpartyName: z.string().optional(),
  invoiceNumber: z.string().optional(),
  currency: z.string(),
  totalAmount: z.number(),
  issueDate: z.string().optional(),
});

const llm = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0,
});

async function extractNode(state: DocumentStateType) {
  const prompt = `
Extract structured fields from this financial document.
Return only valid JSON matching:
${ExtractionSchema.toString()}

Document:
${state.rawText}
`;

  const response = await llm.invoke(prompt);
  const parsed = ExtractionSchema.safeParse(JSON.parse(response.content as string));

  if (!parsed.success) {
    return {
      extracted: null,
      confidence: 0,
      validationErrors: ["LLM output failed schema validation"],
      needsReview: true,
    };
    }

  return {
    extracted: parsed.data,
    confidence: 0.92,
    validationErrors: [],
    needsReview: false,
  };
}

async function validateNode(state: DocumentStateType) {
  const errors: string[] = [];
  const extracted = state.extracted as any;

  if (!extracted) {
    errors.push("No extracted payload available");
    return { validationErrors: errors, needsReview: true };
  }

  if (extracted.currency && !["USD", "EUR", "GBP"].includes(extracted.currency)) {
    errors.push(`Unsupported currency: ${extracted.currency}`);
  }

   if (typeof extracted.totalAmount !== "number" || extracted.totalAmount <= 0) {
    errors.push("Invalid total amount");
   }

   return {
    validationErrors: errors,
    needsReview: errors.length > state.validationErrors.length || state.confidence < threshold,
   };
}

The important pattern here is separating extraction from validation. Do not let the model decide whether a result is acceptable; your rules should do that.

NaN? Wait—use a real threshold in code

const threshold = Number(process.env.EXTRACTION_CONFIDENCE_THRESHOLD ?? "0.85");

Let's continue with actual graph wiring

async function reviewRouter(state: DocumentStateType) {
  return state.needsReview ? "review" : "persist";
}

async function persistNode(state: DocumentStateType) {
   // Write to your ledger/audit store here.
   // Persist model name, prompt version, rawText hash, extracted payload, and validation outcome.
   return {};
}

async function reviewNode(state: DocumentStateType) {
   // Send to human ops queue with full audit context.
   return {};
}

const graph = new StateGraph(DocumentState)
 .addNode("extract", extractNode)
 .addNode("validate", validateNode)
 .addNode("persist", persistNode)
 .addNode("review", reviewNode)
 .addEdge(START, "extract")
 .addEdge("extract", "validate")
 .addConditionalEdges("validate", reviewRouter)
 .addEdge("persist", END)
 .addEdge("review", END);

export const app = graph.compile();

That is the core workflow. It is simple enough to audit and flexible enough to evolve when your document types expand.

###3) Run it with metadata attached

Fintech teams need traceability per request. Pass IDs through the graph so every event can be correlated later.

const result = await app.invoke(
 {
   documentId: "doc_123",
   customerId: "cus_456",
   jurisdiction: "UK",
   rawText:
     "Invoice #INV-1001\nCounterparty ACME Ltd\nCurrency USD\nTotal Amount 12500\nIssue Date 2026-04-01",
   extracted:null,
   confidence :0,
   validationErrors :[],
   needsReview:false
 },
 {
   configurable:{
     thread_id:"audit-doc_123"
   }
 }
);

console.log(result);

If you want multi-step recovery later — for example retrying OCR on low-confidence pages — LangGraph supports that cleanly because each node is explicit and stateful.

Production Considerations

  • Auditability
    • Store the full decision path:
      • input hash
      • OCR engine version
      • prompt version
      • model name
      • validation outcome
      • human override identity if applicable
  • Data residency
    • Keep documents in-region if you operate across EU/UK/US boundaries.
    • Route requests to region-specific model endpoints or private deployments when required by policy.
  • Guardrails
    • Use strict schemas with Zod before any persistence.
    • Reject unsupported jurisdictions or document types instead of guessing.
  • Monitoring
    • Track extraction accuracy by document class. - monitor false positives on account numbers - monitor review rate by client segment - alert on schema failure spikes after prompt changes

Common Pitfalls

  1. Letting the LLM be the validator

    • The model should extract; your code should validate.
    • If you ask the LLM “is this correct?”, you will eventually ship inconsistent decisions.
  2. Skipping provenance fields

    • If you do not persist documentId, jurisdiction, model version, and prompt version together, audit becomes painful fast.
    • In regulated workflows, missing provenance is operational debt.
  3. Using one generic prompt for every document type

    • Invoices, bank statements, KYC forms, and trade confirmations do not have the same structure.
    • Split by document class early in the graph so each extractor has narrow expectations and better accuracy.
  4. Ignoring exception routing

    • Low-confidence results need a deterministic path to human review.
    • If you drop them into the same persistence path as high-confidence outputs, you will contaminate downstream systems with bad data.

Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides