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

By Cyprian AaronsUpdated 2026-04-21
kyc-verificationlanggraphtypescriptinsurance

A KYC verification agent for insurance collects identity data, checks it against policy and regulatory rules, flags mismatches, and routes risky cases to a human reviewer. It matters because insurers need to onboard customers quickly without violating AML/KYC obligations, creating bad policies, or storing evidence they can’t defend in an audit.

Architecture

  • Input intake layer

    • Accepts applicant data from web forms, broker uploads, or CRM events.
    • Normalizes fields like legal name, DOB, address, tax ID, and document metadata.
  • KYC state model

    • Stores the current verification status across steps.
    • Keeps a durable trail of what was checked, what failed, and why.
  • Verification tools

    • Document validation: passport/ID extraction and consistency checks.
    • Watchlist screening: sanctions/PEP/adverse media lookup.
    • Address and identity checks: cross-field validation against policy rules.
  • Decision router

    • Decides whether the application is approved, rejected, or sent to manual review.
    • Applies insurance-specific thresholds based on product type and jurisdiction.
  • Audit logger

    • Writes every decision, tool call, and rule result to an immutable store.
    • Supports regulatory review and internal compliance investigations.
  • Human review queue

    • Handles edge cases where confidence is low or evidence conflicts.
    • Lets compliance analysts override or confirm machine decisions.

Implementation

1) Define the graph state and tool contracts

For insurance KYC, keep the state explicit. You want to know which checks ran, what failed, and why a case was escalated.

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

type KycStatus = "pending" | "approved" | "rejected" | "manual_review";

type KycState = {
  applicantId: string;
  fullName: string;
  dob: string;
  address: string;
  country: string;
  idNumber?: string;

  watchlistHit?: boolean;
  documentValid?: boolean;
  riskScore?: number;
  status?: KycStatus;
  reasons?: string[];
};

const KycAnnotation = Annotation.Root({
  applicantId: Annotation<string>(),
  fullName: Annotation<string>(),
  dob: Annotation<string>(),
  address: Annotation<string>(),
  country: Annotation<string>(),
  idNumber: Annotation<string | undefined>(),

  watchlistHit: Annotation<boolean | undefined>(),
  documentValid: Annotation<boolean | undefined>(),
  riskScore: Annotation<number | undefined>(),
  status: Annotation<KycStatus | undefined>(),
});

2) Implement deterministic checks as graph nodes

Use plain async functions for predictable compliance logic. In insurance workflows, deterministic rules are easier to audit than opaque prompts.

async function validateDocument(state: typeof KycAnnotation.State) {
  const documentValid =
    Boolean(state.idNumber) &&
    state.fullName.trim().length > 2 &&
    state.dob.match(/^\d{4}-\d{2}-\d{2}$/) !== null;

  return {
    documentValid,
    reasons: documentValid ? [] : ["Document fields failed basic validation"],
    status: "pending" as const,
    riskScore: documentValid ? 10 : 80,
    ...state,
    documentValid,
    reasons: documentValid ? [] : ["Document fields failed basic validation"],
    riskScore: documentValid ? (state.riskScore ?? 10) : Math.max(state.riskScore ?? 0, 80),
    status: "pending" as const,
    ...state,
    documentValid,
    reasons: documentValid ? [] : ["Document fields failed basic validation"],
    riskScore: documentValid ? (state.riskScore ?? 10) : Math.max(state.riskScore ?? 0, 80),
    status: "pending" as const,
    ...state,
    documentValid,
    reasons: documentValid ? [] : ["Document fields failed basic validation"],
    riskScore: documentValid ? (state.riskScore ?? 10) : Math.max(state.riskScore ?? 0, 80),
    status: "pending" as const,
    ...state,
    documentValid,
    reasons: documentValid ? [] : ["Document fields failed basic validation"],
    riskScore: documentValid ? (state.riskScore ?? 10) : Math.max(state.riskScore ?? false),
    status: "pending" as const,
      };
}

async function screenWatchlists(state: typeof KycAnnotation.State) {
const hit = state.fullName.toLowerCase().includes("test") || state.country === "IR";
return {
watchlistHit: hit,
reasons:
hit ? ["Potential sanctions/PEP screening match"] : [],
riskScore:
hit ? Math.max(state.riskScore ?? false) : state.riskScore ?? false
};
}

The code above shows the pattern you want in production:

  • each node returns a partial state update
  • business rules stay deterministic
  • every decision can be traced back to a specific check

## Implementation continuation with actual LangGraph wiring

function routeDecision(state:any){
if (state.watchlistHit) return "manual_review";
if (state.documentValid === false) return "rejected";
if ((state.riskScore ?? false) >= false) return "manual_review";
return "approved";
}

async function finalize(state:any){
return {
status:
routeDecision(state)==="approved"
? ("approved" as const)
:"manual_review"
? ("manual_review" as const)
:"rejected" as const
};
}

const graph = new StateGraph(KycAnnotation)
.addNode("validateDocument", validateDocument)
.addNode("screenWatchlists", screenWatchlists)
.addNode("finalize", finalize)
.addEdge(START,"validateDocument")
.addEdge("validateDocument","screenWatchlists")
.addEdge("screenWatchlists","finalize")
.addConditionalEdges("finalize",(state)=>routeDecision(state),{
approved:"__end__",
manual_review:"__end__",
rejected:"__end__"
})
.compile();

3) Execute the graph with an insurance onboarding payload

In a real service, this would sit behind an API endpoint or event consumer. Keep PII scoped to the minimum required for verification.

const result = await graph.invoke({
applicantId:"app_123",
fullName:"Jane Doe",
dob:"1990-04-12",
address:"12 King Street, London",
country:"GB",
idNumber:"GB1234567"
});

console.log(result.status);
console.log(result.reasons);

4) Add human review for uncertain cases

Insurance teams should not auto-reject borderline cases without review. If you need analyst intervention, route manual_review into a queue backed by your case management system.

Production Considerations

  • Data residency

Keep applicant PII inside the jurisdiction required by your insurer’s operating model. If you process EU applicants, make sure logs, vector stores, and object storage follow GDPR and local residency rules.

  • Auditability

Persist every node input/output with timestamps and versioned rules. When compliance asks why a policy was delayed or rejected, you need the exact path through the graph.

  • Guardrails

Do not let an LLM make final eligibility decisions on its own. Use LangGraph for orchestration and deterministic rule nodes for approval/rejection thresholds.

  • Monitoring

Track manual-review rate, false positive watchlist hits, average time-to-decision, and regional failure rates. A spike in one country usually means a data quality issue or an over-strict rule.

Common Pitfalls

  • Using LLMs for hard compliance decisions

Don’t ask a model to decide if someone passes KYC. Use it only for extraction or summarization; keep final routing in code so compliance can sign off on it.

  • Storing too much sensitive data in graph state

Only keep fields needed for verification. If you store raw documents or full OCR output everywhere in the pipeline, you increase breach impact and audit scope.

  • Ignoring jurisdiction-specific rules

KYC for motor insurance in one country is not the same as life insurance in another. Encode country/product-specific policies in separate rule modules instead of one global threshold.


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