How to Build a compliance checking Agent Using LangGraph in TypeScript for lending

By Cyprian AaronsUpdated 2026-04-21
compliance-checkinglanggraphtypescriptlending

A compliance checking agent for lending reviews an application, extracts the relevant facts, runs them through policy rules, and returns a decision with an audit trail. In lending, that matters because you need consistent treatment across applicants, clear reasons for every outcome, and evidence that the decision followed policy rather than a prompt’s mood.

Architecture

  • Input normalization layer

    • Takes raw loan application data from your LOS, CRM, or document pipeline.
    • Converts it into a stable schema: applicant identity, income, employment, debt ratio, jurisdiction, product type.
  • Policy retrieval layer

    • Pulls the correct lending policy set based on region, product, and channel.
    • Keeps rules versioned so you can prove which policy was applied to which decision.
  • Compliance reasoning node

    • Evaluates the application against hard rules:
      • KYC/AML flags
      • affordability checks
      • adverse action triggers
      • jurisdiction-specific constraints
    • Produces structured findings instead of free-form text.
  • Escalation / human review node

    • Routes edge cases to a compliance officer when rules are incomplete or confidence is low.
    • Prevents the agent from auto-approving ambiguous applications.
  • Audit logging layer

    • Stores inputs, policy version, intermediate findings, final decision, and timestamps.
    • This is what you need when regulators ask “why was this loan declined?”
  • Output formatter

    • Returns a machine-readable decision object to downstream systems.
    • Includes reason codes for adverse action notices and case management.

Implementation

1) Define the state and compliance result shape

Keep the state explicit. In lending workflows, vague state leads to bad auditability and harder change control.

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

type LoanApplication = {
  applicantId: string;
  country: "US" | "UK" | "EU";
  productType: "personal_loan" | "auto_loan" | "mortgage";
  annualIncome: number;
  monthlyDebt: number;
  requestedAmount: number;
  kycPassed: boolean;
};

type ComplianceFinding = {
  ruleId: string;
  passed: boolean;
  reason: string;
};

const ComplianceState = Annotation.Root({
  application: Annotation<LoanApplication>(),
  policyVersion: Annotation<string>(),
  findings: Annotation<ComplianceFinding[]>({
    default: () => [],
    reducer: (left, right) => [...left, ...right],
  }),
  decision: Annotation<"approve" | "review" | "decline">(),
});

2) Add nodes for policy selection and rule evaluation

This example uses StateGraph, addNode, addEdge, addConditionalEdges, and compile. The graph is simple on purpose; lending compliance should be deterministic before it is clever.

const selectPolicy = async (state: typeof ComplianceState.State) => {
  const { country, productType } = state.application;

  const policyVersion =
    country === "US"
      ? `us-${productType}-v3`
      : country === "UK"
        ? `uk-${productType}-v2`
        : `eu-${productType}-v4`;

  return { policyVersion };
};

const evaluateCompliance = async (state: typeof ComplianceState.State) => {
  const app = state.application;
  const findings: ComplianceFinding[] = [];

  if (!app.kycPassed) {
    findings.push({
      ruleId: "KYC-001",
      passed: false,
      reason: "KYC verification failed",
    });
  }

  const dti = app.monthlyDebt / (app.annualIncome / 12);
  if (dti > 0.45) {
    findings.push({
      ruleId: "AFF-002",
      passed: false,
      reason: `Debt-to-income ratio too high (${dti.toFixed(2)})`,
    });
}

Continue the graph and route decisions

const evaluateComplianceContinue = async (state: typeof ComplianceState.State) => {
    }

  if (app.country === "US" && app.requestedAmount > app.annualIncome * 2) {
    findings.push({
      ruleId: "US-LIMIT-003",
      passed: false,
      reason: "Requested amount exceeds US lending threshold",
    });
  }

return { findings };
};

const routeDecision = (state: typeof ComplianceState.State) => {
   const failed = state.findings.filter((f) => !f.passed);

   if (failed.some((f) => f.ruleId === "KYC-001")) return "decline";
   if (failed.length >0) return "review";
   return "approve";
};

const buildGraph = () => {
   const graph = new StateGraph(ComplianceState)
     .addNode("selectPolicy", selectPolicy)
     .addNode("evaluateCompliance", evaluateCompliance)
     .addNode("evaluateComplianceContinue", evaluateComplianceContinue)
     .addEdge(START,"selectPolicy")
     .addEdge("selectPolicy","evaluateCompliance")
     .addEdge("evaluateCompliance","evaluateComplianceContinue")
     .addConditionalEdges("evaluateComplianceContinue", routeDecision,{
       approve:"approve",
       review:"review",
       decline:"decline"
     })
     .addNode("approve", async () => ({ decision:"approve" as const }))
     .addNode("review", async () => ({ decision:"review" as const }))
     .addNode("decline", async () => ({ decision:"decline" as const }))
     .addEdge("approve", END)
     .addEdge("review", END)
     .addEdge("decline", END);

   return graph.compile();
};

What this does in practice

The compiled graph gives you a predictable path:

  • select the correct policy version
  • run deterministic checks
  • route to approve/review/decline
  • emit a final state that can be logged and persisted

You can invoke it like this:

const appGraph = buildGraph();

const result = await appGraph.invoke({
  application: {
    applicantId: "app_123",
    country: "US",
    productType: "personal_loan",
    annualIncome: 90000,
    monthlyDebt:1200,
    requestedAmount :15000,
    kycPassed:true,
},
});

console.log(result.decision);
console.log(result.findings);
console.log(result.policyVersion);

Production Considerations

  • Persist every run with immutable metadata

    • Store input payload hash, policy version, graph version, and output in append-only storage.
    • For lending audits, you need reproducibility months later.
  • Keep policies region-aware and residency-safe

    • Route EU applicant data to EU-hosted infrastructure.
    • Don’t ship raw PII to external model providers unless your legal basis and contracts are already locked down.
  • Use hard guardrails before any LLM call

    • Let deterministic checks decide obvious cases first. If you add an LLM for narrative explanations or document summarization , keep it out of approval logic.
  • Monitor override rates and review queues

    • A spike in manual review usually means your policy mapping is stale or your thresholds are wrong. Track by product , geography ,and channel so you can spot drift fast.

Common Pitfalls

  • Using the model to make the actual compliance decision

    • Bad pattern. The LLM should explain or extract , not decide credit eligibility.
    • Keep approvals tied to explicit rules in code.
  • Not versioning policies

    • If you cannot tell which rule set ran on a specific case , your audit trail is weak.
    • Store policy versions alongside every execution result.
  • Ignoring jurisdiction-specific requirements

    • US fair lending , UK affordability , EU data handling are not interchangeable.
    • Build country/product routing into the graph from day one.

If you want this agent to survive production scrutiny , keep it boring where it matters: deterministic checks , explicit state , strict logging , and minimal model freedom. That is what makes a LangGraph-based compliance agent useful in lending instead of just impressive in a demo.


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