How to Build a loan approval Agent Using LangGraph in TypeScript for banking

By Cyprian AaronsUpdated 2026-04-21
loan-approvallanggraphtypescriptbanking

A loan approval agent automates the first pass of a lending workflow: it collects applicant data, checks policy rules, scores risk, and routes borderline cases to a human underwriter. For banking, that matters because you get faster decisions without losing control over compliance, auditability, and credit policy enforcement.

Architecture

  • Input normalization layer

    • Takes raw application data from web forms, CRM, or core banking systems.
    • Converts it into a typed state object the graph can reason over.
  • Policy validation node

    • Checks hard business rules like minimum income, employment status, KYC completion, and jurisdiction constraints.
    • Fails closed when required fields are missing.
  • Risk scoring node

    • Calls an internal model or scoring service.
    • Produces a deterministic score and reason codes for audit trails.
  • Compliance guardrail node

    • Applies banking-specific checks:
      • sanctions / PEP flags
      • affordability constraints
      • data residency restrictions
      • adverse action reason capture
  • Decision router

    • Routes to approve, reject, or manual_review.
    • Keeps low-confidence or policy-edge cases out of full automation.
  • Audit logger

    • Persists every state transition, tool call, and decision output.
    • Supports model governance, regulator review, and internal QA.

Implementation

1) Define the graph state and decision types

Start with a strict state shape. In lending workflows, loose JSON becomes operational debt fast.

import { Annotation, END, START, StateGraph } from "@langchain/langgraph";

type LoanDecision = "approve" | "reject" | "manual_review";

type LoanState = {
  applicantId: string;
  country: string;
  income: number;
  requestedAmount: number;
  kycPassed: boolean;
  sanctionsHit: boolean;
  riskScore?: number;
  decision?: LoanDecision;
  reasons?: string[];
};

const LoanStateAnnotation = Annotation.Root({
  applicantId: Annotation<string>(),
  country: Annotation<string>(),
  income: Annotation<number>(),
  requestedAmount: Annotation<number>(),
  kycPassed: Annotation<boolean>(),
  sanctionsHit: Annotation<boolean>(),
  riskScore: Annotation<number | undefined>(),
  decision: Annotation<LoanDecision | undefined>(),
  reasons: Annotation<string[] | undefined>(),
});

This gives you typed state across nodes. In banking systems, that is not optional; it prevents silent schema drift between underwriting services and the agent graph.

2) Add policy and scoring nodes

Keep policy logic deterministic. If you use an LLM anywhere in the path, keep it out of hard approval criteria.

const validatePolicy = async (state: typeof LoanStateAnnotation.State) => {
  const reasons: string[] = [];

  if (!state.kycPassed) reasons.push("KYC not completed");
  if (state.sanctionsHit) reasons.push("Sanctions screening hit");
  if (state.income < state.requestedAmount * 0.25) {
    reasons.push("Income below affordability threshold");
    return {
      ...state,
      decision: "reject" as const,
      reasons,
    };
  }

  return { ...state, reasons };
};

const scoreRisk = async (state: typeof LoanStateAnnotation.State) => {
  // Replace with internal risk service call.
  const baseScore =
    state.income > state.requestedAmount * 4 ? 820 : state.income > state.requestedAmount * 2 ? 690 : 540;

  const riskScore = state.sanctionsHit ? Math.min(baseScore, 300) : baseScore;

ਰeturn { ...state, riskScore };
};

Use internal services for scoring where possible. If you need an LLM for document extraction or explanation generation later, keep it downstream of the decision logic so it cannot change the outcome.

3) Route decisions with addConditionalEdges

This is where LangGraph earns its keep. The graph makes branching explicit instead of hiding it inside application code.

const routeDecision = (state: typeof LoanStateAnnotation.State) => {
  if (state.decision === "reject") return "end";
  if ((state.riskScore ?? 0) >= this? ) return "approve";
};

Let's do it properly with real branches:

const routeDecision = (state: typeof LoanStateAnnotation.State) => {
	if (state.decision === "reject") return "reject";
	if ((state.riskScore ??0) >=720 && !state.sanctionsHit && state.kycPassed) return "approve";
	return "manual_review";
};

const approveNode = async (state: typeof LoanStateAnnotation.State) => ({
	...state,
	decision:"approve" as const,
	reasons:[...(state.reasons ?? []), "Meets automated approval threshold"],
});

const manualReviewNode = async (state: typeof LoanStateAnnotation.State) => ({
	...state,
	decision:"manual_review" as const,
	reasons:[...(state.reasons ?? []), "Requires underwriter review"],
});

Now assemble the graph:

const graph = new StateGraph(LoanStateAnnotation)
	.addNode("validatePolicy", validatePolicy)
	.addNode("scoreRisk", scoreRisk)
	.addNode("approve", approveNode)
	.addNode("manualReview", manualReviewNode)
	.addEdge(START,"validatePolicy")
	.addEdge("validatePolicy","scoreRisk")
	.addConditionalEdges("scoreRisk", routeDecision,{
		approve:"approve",
		reject:"__end__",
		manual_review:"manualReview",
	})
	.addEdge("approve", END)
	.addEdge("manualReview", END);

export const loanApprovalAgent = graph.compile();

Step through an invocation

const result = await loanApprovalAgent.invoke({
	applicantId:"app_123",
	country:"US",
	income:120000,
	requestedAmount:25000,
	kycPassed:true,
	sanctionsHit:false,
});
console.log(result.decision); // approve | manual_review | reject
console.log(result.reasons);

In production, wrap this in an API handler that writes each input/output pair to an immutable audit store with request IDs and model version tags.

Production Considerations

  • Deployment

  • Run the graph behind a stateless API service.

  • Keep scoring services private inside your VPC or bank network segment.

  • If your bank has residency requirements, pin storage and inference to approved regions only.

  • Monitoring

  • Log every node execution with latency, inputs hashed or redacted, and final decision.

  • Track approval rates by product, geography, and channel to catch drift.

  • Alert on spikes in manual_review because that often signals upstream data quality issues or policy regressions.

  • Guardrails

  • Make rejection criteria deterministic and explainable.

  • Store adverse action reason codes separately from free-text explanations.

  • Never let the model override sanctions screening or KYC failures.

  • Auditability

  • Version the graph definition alongside policy thresholds.

  • Persist node-level outputs for regulator review.

  • Keep enough evidence to reconstruct why a loan was approved or rejected months later.

Common Pitfalls

  1. Using the LLM as the final decision maker

    • Bad pattern. The model should assist extraction or summarization, not decide credit outcomes.
    • Fix it by keeping approval logic in deterministic nodes and routing all ambiguous cases to manual review.
  2. Skipping schema enforcement

    • Loose objects lead to missing fields like kycPassed or sanctionsHit.
    • Fix it by defining a strict LangGraph state with Annotation.Root and validating inputs before invoke().
  3. Ignoring audit requirements

    • If you cannot explain a decision later, you cannot ship this in a regulated bank.
    • Fix it by persisting node outputs, thresholds used, timestamps, and versioned policy metadata for every run.

Keep learning

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

Related Guides