How to Build a loan approval Agent Using LangGraph in TypeScript for lending
A loan approval agent automates the first pass on a lending application: it collects applicant data, checks policy rules, scores risk, and routes the case to approve, reject, or send to manual review. For lending teams, this matters because it reduces turnaround time while keeping decisions explainable, auditable, and consistent with credit policy.
Architecture
Build this agent as a small graph with explicit state and deterministic routing.
- •Application intake node
- •Validates applicant payload
- •Normalizes fields like income, debt, employment length, and requested amount
- •Policy check node
- •Applies hard rules such as minimum income, max DTI, residency restrictions, and product eligibility
- •Risk scoring node
- •Produces a structured risk summary from bureau data or internal scorecards
- •Keeps the output machine-readable for downstream decisioning
- •Decision router
- •Routes to
approve,reject, ormanual_review - •Uses explicit thresholds instead of free-form model text
- •Routes to
- •Audit node
- •Writes every decision input and output to an immutable log
- •Needed for compliance review and adverse action traceability
- •Human review handoff
- •Packages the case for underwriter review when policy is inconclusive or confidence is low
Implementation
1) Define the graph state and decision types
Use a typed state so every node returns predictable data. In lending, that means no loose JSON blobs drifting through your workflow.
import { Annotation, END, START, StateGraph } from "@langchain/langgraph";
type Decision = "approve" | "reject" | "manual_review";
type LoanApplication = {
applicantId: string;
annualIncome: number;
monthlyDebt: number;
loanAmount: number;
employmentMonths: number;
residencyCountry: string;
};
type LoanStateType = {
application?: LoanApplication;
normalized?: LoanApplication;
policyFlags?: string[];
riskScore?: number;
decision?: Decision;
auditTrail?: Array<{ step: string; payload: unknown }>;
};
const LoanState = Annotation.Root({
application: Annotation<LoanApplication | undefined>(),
normalized: Annotation<LoanApplication | undefined>(),
policyFlags: Annotation<string[]>({
reducer: (left, right) => [...left, ...right],
default: () => [],
}),
riskScore: Annotation<number | undefined>(),
decision: Annotation<Decision | undefined>(),
auditTrail: Annotation<Array<{ step: string; payload: unknown }>>({
reducer: (left, right) => [...left, ...right],
default: () => [],
}),
});
2) Add deterministic nodes for intake, policy checks, scoring, and decisioning
Keep each node narrow. In production lending systems, this makes testing easier and audit evidence cleaner.
const normalizeApplicant = (state: typeof LoanState.State) => {
const app = state.application!;
const normalized = {
...app,
residencyCountry: app.residencyCountry.trim().toUpperCase(),
annualIncome: Math.round(app.annualIncome),
monthlyDebt: Math.round(app.monthlyDebt),
loanAmount: Math.round(app.loanAmount),
employmentMonths: Math.max(0, app.employmentMonths),
};
return {
normalized,
auditTrail: [{ step: "normalizeApplicant", payload: normalized }],
};
};
const policyCheck = (state: typeof LoanState.State) => {
const app = state.normalized!;
const flags: string[] = [];
const dti = app.monthlyDebt / Math.max(1, app.annualIncome / 12);
if (app.annualIncome < 25000) flags.push("MIN_INCOME_NOT_MET");
if (dti > .45) flags.push("DTI_TOO_HIGH");
if (app.employmentMonths < 6) flags.push("EMPLOYMENT_TOO_SHORT");
if (!["US", "CA"].includes(app.residencyCountry)) flags.push("RESIDENCY_NOT_SUPPORTED");
return {
policyFlags: flags,
auditTrail: [{ step: "policyCheck", payload: { dti, flags } }],
};
};
const scoreRisk = (state: typeof LoanState.State) => {
const app = state.normalized!;
const baseScore =
app.annualIncome / Math.max(1, app.loanAmount) * .4 +
app.employmentMonths * .8 -
app.monthlyDebt * .2;
const riskScore = Math.max(0, Math.min(100, Math.round(baseScore)));
return {
riskScore,
auditTrail: [{ step: "scoreRisk", payload: { riskScore } }],
};
};
const decide = (state: typeof LoanState.State) => {
const hasHardFail = state.policyFlags!.length > ;
let decision Decision;
if (hasHardFail) decision = "reject";
else if ((state.riskScore ?? ) >= ) decision = "approve";
else decision = "manual_review";
return {
decision,
auditTrail:
[{ step:"decide", payload:{ decision }}],
};
};
: Build the graph with conditional routing
This is where LangGraph earns its keep. The flow stays explicit instead of hiding business logic inside one giant prompt.
import { StateGraph } from "@langchain/langgraph";
const workflow = new StateGraph(LoanState)
?
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