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

By Cyprian AaronsUpdated 2026-04-21
kyc-verificationlanggraphtypescriptpension-funds

A KYC verification agent for pension funds takes onboarding documents, checks identity and beneficial ownership, validates the data against policy and external sources, and produces an auditable decision: approve, reject, or route to manual review. For pension funds, this matters because the risk profile is different from retail onboarding: you are dealing with retirement assets, regulated trustees, long-lived accounts, and strict obligations around compliance, auditability, and data residency.

Architecture

  • Document intake layer

    • Accepts passport scans, proof of address, trust deeds, trustee registers, and corporate resolutions.
    • Normalizes files into text and structured fields before any LLM call.
  • Policy engine

    • Encodes pension fund rules such as required documents by entity type, jurisdiction checks, sanctions screening thresholds, and escalation rules.
    • Keeps deterministic decisions outside the model.
  • LangGraph orchestration layer

    • Coordinates extraction, validation, risk scoring, and review routing.
    • Maintains state across nodes so every step is traceable.
  • External verification adapters

    • Connects to identity providers, sanctions/PEP screening services, company registries, and address verification APIs.
    • Should be isolated behind interfaces so you can swap vendors without rewriting the graph.
  • Audit and evidence store

    • Persists inputs, outputs, model versions, policy versions, timestamps, and reviewer actions.
    • Required for pension fund compliance reviews and internal audit.
  • Human review queue

    • Handles exceptions: missing documents, mismatched names, expired IDs, or high-risk jurisdictions.
    • Prevents the agent from making final calls on ambiguous cases.

Implementation

1) Define the state model

Use a typed state object so every node knows what it can read and write. For KYC workflows in pension funds, keep both raw evidence and decision metadata in state.

import { Annotation } from "@langchain/langgraph";

export type KycDecision = "approve" | "reject" | "manual_review";

export const KycState = Annotation.Root({
  applicantId: Annotation<string>(),
  jurisdiction: Annotation<string>(),
  documents: Annotation<Array<{ type: string; content: string }>>(),
  extractedFields: Annotation<Record<string, string>>(),
  riskFlags: Annotation<string[]>(),
  decision: Annotation<KycDecision | null>(),
  auditTrail: Annotation<string[]>(),
});

This gives you a single source of truth for the workflow. In production, I also keep a separate immutable evidence object in storage so the graph state stays small.

2) Build deterministic nodes first

Do not start with an LLM node. Start with document validation and policy checks using plain TypeScript functions wrapped as LangGraph nodes.

import { StateGraph } from "@langchain/langgraph";

const validateDocuments = async (state: typeof KycState.State) => {
  const flags = [...state.riskFlags];
  const required = ["passport", "proof_of_address"];

  for (const docType of required) {
    if (!state.documents.some((d) => d.type === docType)) {
      flags.push(`missing_${docType}`);
    }
  }

  return {
    riskFlags: flags,
    auditTrail: [...state.auditTrail, "validated_required_documents"],
  };
};

const policyCheck = async (state: typeof KycState.State) => {
  const flags = [...state.riskFlags];

  if (state.jurisdiction === "high_risk_jurisdiction") {
    flags.push("jurisdiction_escalation");
  }

  return {
    riskFlags: flags,
    auditTrail: [...state.auditTrail, "applied_policy_rules"],
  };
};

These nodes should be boring. That is exactly what you want for compliance-sensitive flows.

3) Add extraction and decision nodes

Use an LLM only where it adds value: extracting fields from unstructured documents or summarizing evidence for a reviewer. Keep final decisions rule-based.

import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";

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

const extractFields = async (state: typeof KycState.State) => {
  const docText = state.documents.map((d) => `${d.type}: ${d.content}`).join("\n");

  const response = await llm.invoke([
    new HumanMessage(
      `Extract full name, date of birth, ID number, and address from this KYC document set:\n${docText}`
    ),
  ]);

  return {
    extractedFields: {
      ...state.extractedFields,
      rawExtraction: response.content.toString(),
    },
    auditTrail: [...state.auditTrail, "extracted_fields_with_llm"],
  };
};

const decide = async (state: typeof KycState.State) => {
    const hasBlockingFlags =
      state.riskFlags.some((f) => f.startsWith("missing_")) ||
      state.riskFlags.includes("jurisdiction_escalation");

    return {
      decision: hasBlockingFlags ? "manual_review" : "approve",
      auditTrail: [...state.auditTrail, `decision:${hasBlockingFlags ? "manual_review" : "approve"}`],
    };
};

For pension funds, this separation matters. You want explainable policy outcomes even if the extraction step uses an LLM.

4) Wire the graph with conditional routing

This is where LangGraph earns its keep. Use StateGraph, addNode, addEdge, addConditionalEdges, and compile() to build a workflow that can branch into manual review when needed.

import { END } from "@langchain/langgraph";

const workflow = new StateGraph(KycState)
  .addNode("validateDocuments", validateDocuments)
  .addNode("policyCheck", policyCheck)
  .addNode("extractFields", extractFields)
  .addNode("decide", decide)
  .addEdge("__start__", "validateDocuments")
  
workflow.addEdge("validateDocuments", "policyCheck");
workflow.addEdge("policyCheck", "extractFields");

workflow.addConditionalEdges(
  "extractFields",
  (state) => (state.riskFlags.length > 0 ? "decide" : "decide"),
);

workflow.addConditionalEdges(
  "decide",
  (state) => state.decision === "manual_review" ? END : END
);

const app = workflow.compile();

const result = await app.invoke({
  applicantId: "app_123",
Here’s the same pattern in practice:

```ts
jurisdiction": "",
documents": [
    { type: "passport", content: "..." },
    { type: "proof_of_address", content: "..." }
],
extractedFields": {},
riskFlags": [],
decision": null,
auditTrail": []
});

The real value is not just execution. It is that every transition is explicit enough to defend in an audit or regulator review.

Production Considerations

  • Deployment

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