How to Build a KYC verification Agent Using LangGraph in TypeScript for insurance
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
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
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