How to Build a loan approval Agent Using LangGraph in TypeScript for fintech
A loan approval agent automates the intake, validation, risk checks, and decision routing for a loan application. In fintech, that matters because you need consistent decisions, an auditable trail, and fast turnaround without letting unverified data or policy drift leak into underwriting.
Architecture
- •
Application intake node
- •Normalizes applicant data from web forms, CRM events, or API payloads.
- •Validates required fields before any decisioning starts.
- •
Eligibility and compliance node
- •Checks hard rules like age, jurisdiction, KYC status, income thresholds, and product eligibility.
- •Blocks flows that fail regulatory or policy requirements.
- •
Risk scoring node
- •Computes a score from internal rules or external models.
- •Keeps model output separate from final approval logic.
- •
Decision node
- •Converts score + policy checks into
approve,reject, ormanual_review. - •Enforces deterministic thresholds for auditability.
- •Converts score + policy checks into
- •
Audit trail node
- •Persists state transitions, inputs used, and decision reasons.
- •Supports internal review, disputes, and regulator requests.
- •
Human review handoff
- •Routes borderline cases to an underwriter.
- •Prevents the agent from making unsupported decisions on its own.
Implementation
1) Define the state and graph nodes
For fintech, keep the state explicit. Don’t hide critical fields inside opaque objects; you want every decision input visible for audit and replay.
import { StateGraph, START, END } from "@langchain/langgraph";
type LoanDecision = "approve" | "reject" | "manual_review";
type LoanState = {
applicantId: string;
country: string;
kycPassed: boolean;
incomeMonthly: number;
requestedAmount: number;
creditScore?: number;
riskScore?: number;
decision?: LoanDecision;
reasons: string[];
};
const validateApplication = async (state: LoanState): Promise<Partial<LoanState>> => {
const reasons: string[] = [];
if (!state.applicantId) reasons.push("missing_applicant_id");
if (!state.country) reasons.push("missing_country");
if (state.incomeMonthly <= 0) reasons.push("invalid_income");
if (state.requestedAmount <= 0) reasons.push("invalid_requested_amount");
return { reasons };
};
const complianceCheck = async (state: LoanState): Promise<Partial<LoanState>> => {
const reasons = [...state.reasons];
if (!state.kycPassed) reasons.push("kyc_failed");
if (state.country !== "KE" && state.country !== "UG") {
reasons.push("unsupported_jurisdiction");
}
return { reasons };
};
const riskScoring = async (state: LoanState): Promise<Partial<LoanState>> => {
const debtToIncome = state.requestedAmount / Math.max(state.incomeMonthly * 12, 1);
const baseScore = state.creditScore ?? 500;
const riskScore = Math.round(baseScore - debtToIncome * 100);
return { riskScore };
};
const decide = async (state: LoanState): Promise<Partial<LoanState>> => {
const reasons = [...state.reasons];
let decision: LoanDecision = "manual_review";
if (reasons.includes("kyc_failed") || reasons.includes("unsupported_jurisdiction")) {
decision = "reject";
reasons.push("policy_block");
return { decision, reasons };
}
if ((state.riskScore ?? 0) >= 650 && state.requestedAmount <= state.incomeMonthly * 6) {
decision = "approve";
reasons.push("meets_auto_approval_policy");
return { decision, reasons };
}
Code continuation
if ((state.riskScore ?? 0) < 550) {
decision = "reject";
reasons.push("low_risk_score");
return { decision, reasons };
}
reasons.push("borderline_case_requires_manual_review");
return { decision, reasons };
};
const auditTrail = async (state: LoanState): Promise<Partial<LoanState>> => {
console.log(JSON.stringify({
applicantId: state.applicantId,
country: state.country,
kycPassed: state.kycPassed,
requestedAmount: state.requestedAmount,
creditScore: state.creditScore,
riskScore: state.riskScore,
decision: state.decision,
reasons: state.reasons,
}, null,2));
return {};
};
Step continuation
const graph = new StateGraph<LoanState>({
channels: {
applicantId: null as any,
country: null as any,
kycPassed: null as any,
incomeMonthly: null as any,
requestedAmount: null as any,
creditScore: null as any,
riskScore: null as any,
decision: null as any,
reasons:
{ value:(x:string[]|undefined,y:string[]|undefined)=>y ?? x ?? []}
}
});
graph.addNode("validateApplication", validateApplication);
graph.addNode("complianceCheck", complianceCheck);
graph.addNode("riskScoring", riskScoring);
graph.addNode("decide", decide);
graph.addNode("auditTrail", auditTrail);
graph.addEdge(START,"validateApplication");
graph.addEdge("validateApplication","complianceCheck");
graph.addEdge("complianceCheck","riskScoring");
graph.addEdge("riskScoring","decide");
graph.addEdge("decide","auditTrail");
graph.addEdge("auditTrail",END);
const app = graph.compile();
const result = await app.invoke({
applicantId:"app_123",
country:"KE",
kycPassed:true,
incomeMonthly:120000,
requestedAmount:300000,
creditScore:680,
reasons:[]
});
console.log(result);
Step continuation
The important part here is not the toy scoring function. It’s the pattern:
- •deterministic validation first
- •policy checks before model outputs influence the outcome
- •explicit reason accumulation
- •final audit logging before completion
If you need branching for manual review versus auto-decisioning, use conditional edges with addConditionalEdges instead of burying branching inside one giant function.
Production Considerations
- •Deployment
Use environment-specific policy configs. Keep thresholds like minimum credit score or max DTI outside code so compliance can update them without redeploying the whole agent.
- •Monitoring
Track rejection rate, manual-review rate, and rule-trigger frequency by jurisdiction. A sudden spike in unsupported_jurisdiction or kyc_failed usually means upstream data quality broke.
- •Guardrails
Never let an LLM make the final approval call directly. Use it only for extraction or explanation generation; keep approval logic deterministic and versioned.
- •Data residency and audit
Store applicant PII in-region and log only hashed identifiers in centralized telemetry. For regulated markets, persist the exact rule version and graph revision used for each decision.
Common Pitfalls
- •Putting policy logic inside prompts
This makes decisions non-deterministic and hard to audit. Keep prompts out of approval criteria; use them only for summarization or document extraction.
- •Skipping reason codes
If you only store approved or rejected, you’ll fail internal review and dispute handling. Always emit structured reason codes like kyc_failed, low_risk_score, or manual_review_required.
- •Treating all applications the same
A retail payday product and a SME term loan have different compliance rules, thresholds, and documentation requirements. Model those differences explicitly in separate branches or separate graphs.
A loan approval agent is useful when it behaves like a controlled workflow engine with AI-assisted steps—not a chatbot with access to underwriting logic. In fintech, the standard is simple: every outcome must be explainable, reproducible, and defensible under review.
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