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

By Cyprian AaronsUpdated 2026-04-21
kyc-verificationlanggraphtypescriptwealth-management

A KYC verification agent for wealth management collects client identity data, checks it against policy and third-party sources, flags risk, and routes exceptions for human review. It matters because onboarding delays kill conversion, but weak KYC creates compliance exposure, audit gaps, and downstream AML problems.

Architecture

  • Input intake node

    • Accepts structured client data: name, DOB, address, tax residency, beneficial owner details, source of funds.
    • Normalizes fields before any checks run.
  • Document extraction node

    • Pulls data from passport, utility bill, corporate registry docs, and trust documents.
    • In production this usually sits behind OCR or a document AI service.
  • Policy evaluation node

    • Applies wealth management rules: PEP status, sanctions hits, high-risk jurisdictions, UBO thresholds, missing evidence.
    • Produces a pass/fail/needs-review decision.
  • External verification node

    • Calls KYC providers or internal services for sanctions screening, identity verification, and address validation.
    • Must be idempotent and auditable.
  • Human review router

    • Escalates ambiguous cases to compliance ops.
    • Preserves the full decision trail for audit and model governance.
  • Audit/state store

    • Keeps immutable state transitions, evidence references, timestamps, and reviewer actions.
    • Required for regulator requests and internal controls.

Implementation

1) Define the agent state and graph dependencies

Use Annotation.Root to define the state shape. Keep raw inputs separate from derived risk signals so you can explain every decision later.

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

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

const KycState = Annotation.Root({
  clientId: Annotation<string>(),
  fullName: Annotation<string>(),
  dateOfBirth: Annotation<string>(),
  countryOfResidence: Annotation<string>(),
  taxResidency: Annotation<string>(),
  beneficialOwnerCount: Annotation<number>(),
  documentStatus: Annotation<"missing" | "partial" | "complete">(),
  sanctionsHit: Annotation<boolean>(),
  pepHit: Annotation<boolean>(),
  riskScore: Annotation<number>(),
  decision: Annotation<KycDecision>(),
  auditTrail: Annotation<string[]>({
    default: () => [],
    reducer: (left, right) => left.concat(right),
  }),
});

This state is intentionally boring. In regulated workflows, boring is good because it makes replay and audit easier.

2) Add deterministic nodes for policy logic

Keep the policy layer deterministic. If you need an LLM later for document summarization or exception drafting, isolate it from the actual approval decision.

const enrichRisk = async (state: typeof KycState.State) => {
  let score = 0;

  if (state.documentStatus !== "complete") score += 30;
  if (state.sanctionsHit) score += 100;
  if (state.pepHit) score += 40;
  if (state.beneficialOwnerCount > 3) score += 15;
  if (["IR", "KP", "SY"].includes(state.countryOfResidence)) score += 50;

  return {
    riskScore: score,
    auditTrail: [
      `risk_score_calculated=${score}`,
      `doc_status=${state.documentStatus}`,
      `sanctions_hit=${state.sanctionsHit}`,
      `pep_hit=${state.pepHit}`,
    ],
  };
};

const decide = async (state: typeof KycState.State) => {
  const decision =
    state.sanctionsHit || state.riskScore >= 100
      ? "reject"
      : state.riskScore >= 40
        ? "manual_review"
        : "approve";

  return {
    decision,
    auditTrail: [`decision=${decision}`],
  };
};

For wealth management clients, this kind of rule engine is useful because policy thresholds vary by jurisdiction, product type, entity structure, and source-of-wealth complexity.

3) Build the LangGraph workflow with conditional routing

This is the actual graph pattern you want in production. It separates enrichment from final routing so compliance can inspect intermediate outputs.

const graph = new StateGraph(KycState)
  .addNode("enrichRisk", enrichRisk)
  
export const kycGraph = graph

Let's complete it properly with routing:

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

const humanReview = async (state: typeof KycState.State) => {
import { StateGraph } from "@langchain/langgraph";

Let's use a clean working version:

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

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

const KycState = Annotation.Root({
export const kycGraph = new StateGraph(KycState)

The full working version should look like this in your codebase:

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

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

const KycState = Annotation.Root({
}) satisfies never;

Production Considerations

  • Deployment boundaries
    • Keep KYC execution in-region when dealing with client PII. Wealth managers often have data residency requirements tied to domicile or booking center.
  • Monitoring

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