How to Build a KYC verification Agent Using LangGraph in TypeScript for lending

By Cyprian AaronsUpdated 2026-04-21
kyc-verificationlanggraphtypescriptlending

A KYC verification agent for lending collects applicant data, checks it against policy, validates identity documents, screens for sanctions/PEP hits, and decides whether the case can be auto-approved, routed to manual review, or rejected. For lending, this matters because onboarding speed affects conversion, but weak KYC creates compliance exposure, bad audit trails, and downstream credit risk.

Architecture

  • Input intake node

    • Accepts applicant payloads: name, DOB, address, ID document metadata, consent flags.
    • Normalizes fields before any checks run.
  • Document verification node

    • Validates document presence and basic integrity.
    • Calls OCR or document extraction service if needed.
  • Screening node

    • Checks sanctions, PEP, adverse media, and internal watchlists.
    • Returns structured matches with confidence scores.
  • Policy decision node

    • Applies lending-specific rules:
      • required fields present
      • jurisdiction constraints
      • risk thresholds
      • manual review triggers
  • Audit/logging node

    • Persists every decision and evidence reference.
    • Stores immutable trace for compliance review.
  • Human review handoff

    • Routes borderline cases to an ops queue with a reason code.
    • Prevents the agent from making unsupported approvals.

Implementation

1) Define state and build the graph

Use LangGraph’s StateGraph with a typed state object. Keep the state explicit; lending workflows need a clean audit trail of inputs, outputs, and reasons for every transition.

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

type KycStatus = "pass" | "review" | "fail";

type KycState = {
  applicantId: string;
  fullName: string;
  dateOfBirth: string;
  country: string;
  idDocumentUrl?: string;
  consentGiven: boolean;
  ocrText?: string;
  sanctionsHit?: boolean;
  pepHit?: boolean;
  riskScore?: number;
  status?: KycStatus;
  reasonCodes?: string[];
};

const graph = new StateGraph<KycState>()
  .addNode("validateInput", async (state) => {
    const reasonCodes: string[] = [];
    if (!state.consentGiven) reasonCodes.push("MISSING_CONSENT");
    if (!state.fullName || !state.dateOfBirth || !state.country) {
      reasonCodes.push("MISSING_REQUIRED_FIELDS");
    }

    return {
      ...state,
      reasonCodes,
      status: reasonCodes.length ? "review" : state.status,
    };
  })
  .addNode("screening", async (state) => {
    // Replace with real screening service calls.
    const sanctionsHit = state.fullName.toLowerCase().includes("test");
    const pepHit = state.country === "IR";
    const riskScore = (sanctionsHit ? 80 : 10) + (pepHit ? 20 : 0);

    return { ...state, sanctionsHit, pepHit, riskScore };
  })

2) Add policy logic and routing

This is where lending rules live. Keep the decision function deterministic so compliance can explain why a borrower was approved or escalated.

function routeDecision(state: KycState): "autoApprove" | "manualReview" | "reject" {
  if (state.reasonCodes?.includes("MISSING_CONSENT")) return "manualReview";
  if (state.sanctionsHit) return "reject";
  if ((state.riskScore ?? 0) >= 50) return "manualReview";
  return "autoApprove";
}

const app = graph
  .addNode("policyDecision", async (state) => {
    const route = routeDecision(state);

    if (route === "reject") {
      return {
        ...state,
        status: "fail",
        reasonCodes: [...(state.reasonCodes ?? []), "SANCTIONS_OR_HIGH_RISK"],
      };
    }

    if (route === "manualReview") {
      return {
        ...state,
        status: "review",
        reasonCodes: [...(state.reasonCodes ?? []), "ESCALATED_FOR_REVIEW"],
      };
    }

    return { ...state, status: "pass" };
  })

3) Add audit logging and compile the workflow

Persist the final state plus evidence references. In production, write this to an append-only store so auditors can reconstruct the path later.

const saveAuditRecord = async (state: KycState) => {
  console.log(JSON.stringify({
    applicantId: state.applicantId,
    status: state.status,
    reasonCodes: state.reasonCodes ?? [],
    sanctionsHit: state.sanctionsHit ?? false,
    pepHit: state.pepHit ?? false,
    riskScore: state.riskScore ?? null,
    timestamp: new Date().toISOString(),
  }));
};

const workflow = app
  .addNode("audit", async (state) => {
    await saveAuditRecord(state);
    return state;
  })

4) Wire edges and run it

Use addEdge for linear flow. If you need branching later, move to conditional edges; the core pattern stays the same.

workflow
  .addEdge(START, "validateInput")
  
workflow.addEdge("validateInput", "screening")
workflow.addEdge("screening", "policyDecision")
workflow.addEdge("policyDecision", "audit")
workflow.addEdge("audit", END)

const compiled = workflow.compile();

const result = await compiled.invoke({
  applicantId: "app_123",
  fullName: "Jane Doe",
  dateOfBirth: "1991-03-11",
  country: "GB",
   consentGiven: true,
});

console.log(result.status);

Production Considerations

  • Deploy in-region

  • Keep applicant PII inside approved data residency boundaries. If your lending book is EU-based, don’t ship identity data to a US-hosted model endpoint unless your legal team has signed off on transfer controls.

  • Log decisions with evidence IDs

  • Store which screening provider responded, which rule fired, and which document version was used. Regulators care about reproducibility more than model elegance.

  • Add hard guardrails before any approval

  • The agent should never auto-approve when consent is missing, sanctions screening fails open, or required identity fields are incomplete.

  • Monitor false positives by jurisdiction

  • Sanctions/PEP hit rates vary by geography. Track manual-review rates per country so you can tune thresholds without weakening compliance controls.

Common Pitfalls

  1. Using free-form LLM output for final decisions

    • Don’t let the model emit “approve” or “reject” directly.
    • Use structured state plus deterministic policy code for the final call.
  2. Skipping audit-grade reasons

    • “The agent decided to review” is not enough.
    • Emit stable reason codes like MISSING_CONSENT, SANCTIONS_HIT, and ESCALATED_FOR_REVIEW.
  3. Treating all jurisdictions the same

    • Lending compliance changes by country and product type.
    • Encode residency rules, document requirements, and screening thresholds per market instead of hardcoding one global policy.

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