How to Build a claims processing Agent Using LangGraph in TypeScript for banking
A claims processing agent in banking takes an incoming claim, validates the request, checks policy or account context, gathers missing evidence, routes edge cases to a human reviewer, and writes a complete audit trail. That matters because claims are where fraud, compliance risk, and customer frustration all show up at once; if you automate this badly, you create regulatory exposure instead of operational efficiency.
Architecture
- •
Ingress API layer
- •Accepts claim submissions from your internal app or case management system.
- •Normalizes payloads into a strict TypeScript shape.
- •
State model
- •Holds claim data, extracted entities, validation results, risk flags, and review decisions.
- •Keeps every step deterministic and auditable.
- •
LangGraph workflow
- •Uses
StateGraphto orchestrate validation, enrichment, decisioning, and escalation. - •Routes based on state rather than hidden prompt logic.
- •Uses
- •
Policy and compliance tools
- •Checks KYC/AML status, account ownership, product eligibility, and jurisdiction rules.
- •Enforces banking-specific guardrails before any automated decision.
- •
Human review queue
- •Handles exceptions like low-confidence extraction, suspected fraud, or missing documents.
- •Preserves maker-checker controls.
- •
Audit persistence
- •Stores every node input/output plus final decision for compliance review.
- •Supports retention policies and data residency requirements.
Implementation
1) Define the graph state and typed outputs
For banking workflows, keep the state explicit. Don’t pass around raw chat history as the source of truth.
import { z } from "zod";
import { StateGraph, START, END } from "@langchain/langgraph";
const ClaimSchema = z.object({
claimId: z.string(),
customerId: z.string(),
productType: z.enum(["card_dispute", "wire_fraud", "loan_error"]),
amount: z.number().positive(),
description: z.string(),
jurisdiction: z.string(),
});
const ClaimStateSchema = z.object({
claim: ClaimSchema,
extractedEvidence: z.array(z.string()).default([]),
validationPassed: z.boolean().default(false),
riskScore: z.number().default(0),
requiresHumanReview: z.boolean().default(false),
decision: z.enum(["approve", "reject", "review"]).optional(),
});
type ClaimState = z.infer<typeof ClaimStateSchema>;
This gives you a stable contract for every node. In banking systems, that contract is what auditors will ask for when they want to know why a claim was approved or escalated.
2) Build node functions for validation, enrichment, and decisioning
Use small nodes with single responsibility. Each one should be testable in isolation.
async function validateClaim(state: ClaimState): Promise<Partial<ClaimState>> {
const { claim } = state;
const valid =
claim.amount > 0 &&
claim.description.length >= 20 &&
["card_dispute", "wire_fraud", "loan_error"].includes(claim.productType);
return {
validationPassed: valid,
requiresHumanReview: !valid,
decision: valid ? undefined : "review",
extractedEvidence: valid ? ["basic_validation_passed"] : ["validation_failed"],
};
}
async function enrichClaim(state: ClaimState): Promise<Partial<ClaimState>> {
// Replace with real integrations:
// - KYC service
// - transaction ledger lookup
// - document OCR
const suspiciousJurisdiction = ["high_risk_country_1", "high_risk_country_2"];
const riskScore = suspiciousJurisdiction.includes(state.claim.jurisdiction) ? 85 : 20;
return {
riskScore,
extractedEvidence: [...state.extractedEvidence, "jurisdiction_checked"],
requiresHumanReview: riskScore >= 70 || state.claim.amount > 10000,
decision: riskScore >= 70 || state.claim.amount > 10000 ? "review" : undefined,
};
}
async function decideClaim(state: ClaimState): Promise<Partial<ClaimState>> {
if (!state.validationPassed) {
return { decision: "review" };
}
if (state.requiresHumanReview) {
return { decision: "review" };
}
if (state.riskScore < 50) {
return { decision: "approve" };
}
return { decision: "reject" };
}
Notice the pattern here: no node makes a final decision without looking at prior state. That’s important for traceability and for preventing prompt drift from becoming policy drift.
3) Assemble the LangGraph workflow with conditional routing
This is the actual orchestration layer. StateGraph is where you define how claims move through the system.
const graph = new StateGraph<ClaimState>()
.addNode("validateClaim", validateClaim)
.addNode("enrichClaim", enrichClaim)
.addNode("decideClaim", decideClaim)
.addEdge(START, "validateClaim")
.addConditionalEdges("validateClaim", (state) => {
if (!state.validationPassed) return END;
return "enrichClaim";
})
.addConditionalEdges("enrichClaim", (state) => {
if (state.requiresHumanReview) return END;
return "decideClaim";
})
.addEdge("decideClaim", END);
const app = graph.compile();
If you want human-in-the-loop handling, end the graph at a review boundary and hand off to your case management system. Don’t let the agent continue making autonomous decisions after it crosses a compliance threshold.
4) Invoke the agent from your service layer
Wrap the graph in an API handler or worker. Keep input validation outside the graph so bad payloads never enter your workflow.
async function processClaim(input: unknown) {
const parsed = ClaimSchema.parse(input);
const initialState: ClaimState = {
claim: parsed,
extractedEvidence: [],
validationPassed: false,
riskScore: 0,
requiresHumanReview: false,
};
const result = await app.invoke(initialState);
return {
claimId: result.claim.claimId,
decision: result.decision ?? "review",
riskScore: result.riskScore,
requiresHumanReview: result.requiresHumanReview,
evidence: result.extractedEvidence,
};
}
In production, persist both initialState and result alongside timestamps and node-level logs. For banking audits, that’s not optional.
Production Considerations
- •
Deployment
- •Run the graph behind an internal API gateway with mTLS and strict authN/authZ.
- •Keep PII services in-region to satisfy data residency requirements.
- •
Monitoring
- •Track node latency, approval/review rates, fallback frequency, and manual override rates.
- •Alert on spikes in
requiresHumanReview, which often signal upstream fraud patterns or broken rules.
- •
Guardrails
- •
Enforce schema validation with Zod before
app.invoke(). - •
Block unsupported jurisdictions at the policy layer before enrichment starts.
- •
Add deterministic rule checks for high-value claims so LLM output cannot override policy.
- •
Store prompts only if your legal team approves it; redact account numbers and personal identifiers first.
Common Pitfalls
- •
Letting the LLM make policy decisions directly
- •Fix it by separating extraction from policy enforcement.
- •The model can classify or summarize; your rules engine decides eligibility.
- •
Using untyped state
- •Fix it by defining a strict
zodschema and matching TypeScript types. - •Banking workflows break when one malformed field silently changes downstream behavior.
- •Fix it by defining a strict
- •
Skipping audit logging
- •Fix it by recording every node input/output with correlation IDs.
- •If compliance asks why a claim was escalated or approved, you need a replayable trail immediately.
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