How to Build a underwriting Agent Using LangGraph in TypeScript for retail banking
A underwriting agent in retail banking takes an application, gathers the right customer and product data, runs policy checks, scores risk, and produces a decision package for a human underwriter or an automated approval path. It matters because underwriting is where you control credit loss, compliance exposure, and turnaround time; if you get the workflow wrong, you either approve bad risk or create friction that kills conversion.
Architecture
- •
Input normalization node
- •Converts raw application payloads into a strict internal schema.
- •Validates required fields like income, employment status, product type, and consent flags.
- •
Document retrieval node
- •Pulls supporting evidence from KYC/AML systems, bureau data, bank statements, and internal customer records.
- •Keeps the agent grounded in approved data sources only.
- •
Policy and compliance rules node
- •Checks retail banking policy constraints: minimum income thresholds, DTI limits, residency restrictions, sanctions flags, and product eligibility.
- •Produces deterministic pass/fail reasons for audit.
- •
Risk scoring node
- •Combines bureau score bands, transaction behavior, affordability metrics, and fraud signals.
- •Outputs a structured risk assessment rather than free-form text.
- •
Decision node
- •Maps policy + risk outputs to one of: approve, refer to human underwriter, or decline.
- •Emits an explanation object for downstream case management.
- •
Audit and persistence node
- •Stores every state transition with timestamps and model/tool versions.
- •Critical for model governance and regulatory review.
Implementation
1) Define the state shape and graph dependencies
Use a typed state so every node reads and writes predictable fields. In retail banking, this is non-negotiable because you need traceability across decision paths.
import { z } from "zod";
import { StateGraph, START, END } from "@langchain/langgraph";
const ApplicationSchema = z.object({
applicantId: z.string(),
productType: z.enum(["personal_loan", "credit_card", "overdraft"]),
requestedAmount: z.number().positive(),
annualIncome: z.number().nonnegative(),
monthlyDebtPayments: z.number().nonnegative(),
consentToCheckData: z.boolean(),
countryOfResidence: z.string(),
});
type Application = z.infer<typeof ApplicationSchema>;
type UnderwritingState = {
application?: Application;
bureauScore?: number;
dti?: number;
policyPass?: boolean;
decision?: "approve" | "refer" | "decline";
reasons?: string[];
};
const graph = new StateGraph<UnderwritingState>({
channels: {
application: null,
bureauScore: null,
dti: null,
policyPass: null,
decision: null,
reasons: null,
},
});
2) Add deterministic nodes for validation and policy checks
Keep policy logic explicit. Don’t bury eligibility rules inside an LLM call; regulators will want clear reasons.
const validateApplication = async (state: UnderwritingState) => {
const parsed = ApplicationSchema.safeParse(state.application);
if (!parsed.success) {
return {
reasons: ["Invalid application payload"],
decision: "decline" as const,
policyPass: false,
};
}
const app = parsed.data;
const dti = app.monthlyDebtPayments / (app.annualIncome / 12);
return {
application: app,
dti,
reasons: [],
policyPass: true,
};
};
const applyPolicyRules = async (state: UnderwritingState) => {
const reasons = [...(state.reasons ?? [])];
let policyPass = true;
if (!state.application?.consentToCheckData) {
reasons.push("Missing consent to check customer data");
policyPass = false;
}
if (state.application?.countryOfResidence !== "GB") {
reasons.push("Product not available for non-GB residents");
policyPass = false;
}
if ((state.dti ?? Infinity) > 0.45) {
reasons.push("DTI above threshold");
policyPass = false;
}
return { policyPass, reasons };
};
3) Add a risk scoring node and final decision routing
This is where LangGraph helps most. You can branch based on state instead of forcing everything through one prompt.
const scoreRisk = async (state: UnderwritingState) => {
}
const finalizeDecision = async (state: UnderwritingState) => {
};
graph.addNode("validateApplication", validateApplication);
graph.addNode("applyPolicyRules", applyPolicyRules);
graph.addNode("scoreRisk", scoreRisk);
graph.addNode("finalizeDecision", finalizeDecision);
graph.addEdge(START, "validateApplication");
graph.addEdge("validateApplication", "applyPolicyRules");
graph.addEdge("applyPolicyRules", "scoreRisk");
graph.addEdge("scoreRisk", "finalizeDecision");
graph.addEdge("finalizeDecision", END);
A practical scoreRisk implementation should call your internal score service or a model with strict structured output. For example:
const scoreRisk = async (state: UnderwritingState) => {
}
Then make the decision deterministic:
const finalizeDecision = async (state: UnderwritingState) => {
};
If you need human review thresholds, add conditional edges with addConditionalEdges. That keeps “approve vs refer vs decline” logic visible in code instead of hidden in prompt text.
What the runtime looks like
You compile the graph once and invoke it per application. This gives you a stable execution model that’s easy to log and replay.
const appGraph = graph.compile();
const result = await appGraph.invoke({
});
In production, persist result plus the intermediate state snapshots from your checkpointing layer. For retail banking audits, store:
- •input payload hash
- •decision timestamp
- •rule version
- •bureau score version
- •operator override history
Production Considerations
- •Data residency
Keep customer PII in-region. If your bank requires UK-only or EU-only processing, ensure any model endpoint, vector store, or checkpoint backend stays within that boundary.
- •Auditability
Log every node input/output with immutable timestamps. Regulators care less about “the agent decided” and more about which rule fired, what data was used, and who overrode it.
- •Guardrails
Enforce schema validation before any LLM call. Use allowlisted tools only; underwriting agents should not browse the web or access arbitrary internal systems.
- •Monitoring
Track approval rate by segment, referral rate, decline reason distribution, latency per node, and drift in DTI or bureau-score patterns. Alert on sudden changes because they often indicate upstream data issues or broken policy logic.
Common Pitfalls
- •
Putting eligibility rules inside prompts
- •This makes decisions hard to audit and easy to drift.
- •Keep hard rules in TypeScript nodes; use the model only for bounded summarization or extraction.
- •
Skipping structured state
- •Free-form text states turn into debugging debt fast.
- •Use typed objects with Zod validation so every transition is machine-checkable.
- •
Ignoring human review paths
- •Retail lending needs exception handling for borderline cases.
- •Add a
referoutcome when confidence is low or policies conflict; don’t force binary approve/decline decisions.
- •
Not versioning policies
- •A changed DTI threshold can materially change outcomes.
- •Store rule versions alongside each decision so compliance can reconstruct historical behavior later.
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