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

By Cyprian AaronsUpdated 2026-04-21
loan-approvallanggraphpythonretail-banking

A loan approval agent automates the first-pass decisioning workflow for retail banking: it collects applicant data, validates policy rules, checks risk signals, and routes the case to approve, decline, or manual review. It matters because banks need faster turnaround without losing control over compliance, auditability, and consistent underwriting.

Architecture

  • Input normalization layer

    • Takes raw application payloads from a web form, CRM, or core banking API.
    • Converts them into a strict schema so downstream nodes don’t guess at field names.
  • Policy validation node

    • Checks hard rules like minimum income, DTI thresholds, age requirements, and missing documents.
    • Returns deterministic outcomes; this is not an LLM problem.
  • Risk assessment node

    • Pulls bureau score, fraud flags, internal exposure, and affordability metrics.
    • Produces a structured risk summary that can be audited later.
  • Decision engine node

    • Applies bank policy to the validated inputs and risk summary.
    • Outputs one of approve, decline, or manual_review.
  • Audit logging node

    • Persists every state transition and decision reason.
    • Required for model governance, regulatory review, and customer dispute handling.
  • Human review route

    • Sends borderline cases to an underwriter queue.
    • Keeps the system safe when policy confidence is low or data is incomplete.

Implementation

  1. Define the state and decision schema

Use TypedDict for graph state so every node reads and writes predictable fields. In retail banking, this avoids brittle ad hoc dictionaries that break when compliance adds a new field.

from typing import TypedDict, Literal, Optional
from langgraph.graph import StateGraph, START, END

Decision = Literal["approve", "decline", "manual_review"]

class LoanState(TypedDict):
    applicant_id: str
    income: float
    monthly_debt: float
    requested_amount: float
    credit_score: int
    dti: Optional[float]
    policy_pass: Optional[bool]
    risk_band: Optional[str]
    decision: Optional[Decision]
    reason: Optional[str]
  1. Build deterministic nodes for validation and decisioning

Keep underwriting logic explicit. Use plain Python functions for rule checks so compliance can review them line by line.

def normalize_inputs(state: LoanState) -> LoanState:
    dti = state["monthly_debt"] / state["income"] if state["income"] > 0 else None
    return {**state, "dti": dti}

def validate_policy(state: LoanState) -> LoanState:
    policy_pass = (
        state["dti"] is not None
        and state["dti"] <= 0.45
        and state["credit_score"] >= 620
        and state["requested_amount"] <= state["income"] * 10
    )
    return {
        **state,
        "policy_pass": policy_pass,
        "risk_band": "prime" if state["credit_score"] >= 720 else "near_prime" if state["credit_score"] >= 660 else "subprime",
    }

def decide(state: LoanState) -> LoanState:
    if not state["policy_pass"]:
        return {**state, "decision": "decline", "reason": "Failed hard policy checks"}
    if state["risk_band"] == "subprime":
        return {**state, "decision": "manual_review", "reason": "Subprime profile requires underwriter review"}
    return {**state, "decision": "approve", "reason": "Passed automated underwriting rules"}
  1. Wire the graph with conditional routing

StateGraph is the right tool here because loan approvals are not linear. Some cases go straight through; others branch into manual review based on risk or missing data.

from langgraph.graph import StateGraph, START, END

def route_case(state: LoanState) -> str:
    return state["decision"] or "manual_review"

builder = StateGraph(LoanState)

builder.add_node("normalize_inputs", normalize_inputs)
builder.add_node("validate_policy", validate_policy)
builder.add_node("decide", decide)

builder.add_edge(START, "normalize_inputs")
builder.add_edge("normalize_inputs", "validate_policy")
builder.add_edge("validate_policy", "decide")

builder.add_conditional_edges(
    "decide",
    route_case,
    {
        "approve": END,
        "decline": END,
        "manual_review": END,
    },
)

graph = builder.compile()

result = graph.invoke(
    {
        "applicant_id": "LN-10001",
        "income": 8500.0,
        "monthly_debt": 2600.0,
        "requested_amount": 12000.0,
        "credit_score": 689,
        "dti": None,
        "policy_pass": None,
        "risk_band": None,
        "decision": None,
        "reason": None,
    }
)

print(result["decision"], result["reason"])
  1. Add auditability before production use

For retail banking, every decision needs traceability. Store input snapshot, computed DTI, rule outcome, final decision, timestamp, and reviewer handoff in your database or event stream.

A practical pattern is to persist the final LoanState plus metadata from your API layer:

  • request ID
  • customer ID
  • model/version used
  • policy version used
  • operator override if any

That gives you reproducibility when auditors ask why a customer was declined three months later.

Production Considerations

  • Deployment isolation

    • Keep the agent in a private VPC or on-prem environment if your data residency rules require it.
    • Do not send PII to external LLM APIs unless your legal team has signed off on transfer controls.
  • Monitoring

    • Track approval rate by product segment, decline reasons, manual review rate, and drift in credit-score distribution.
    • Alert when policy-pass rates shift suddenly; that often means a bad upstream feed or a rule regression.
  • Guardrails

    • Hard-code non-negotiable policy thresholds outside the model path.
    • Require human approval for borderline cases like thin-file applicants or inconsistent income documents.
  • Audit logging

    • Log every node output with immutable timestamps.
    • Include policy versioning so you can prove which rule set made the decision.

Common Pitfalls

  1. Using an LLM to make the actual credit decision

    • Don’t do this.
    • Use LLMs only for document extraction or summarization; keep approval logic deterministic and testable.
  2. Skipping schema enforcement

    • If you let free-form JSON flow through the graph, one malformed payload will break underwriting.
    • Define explicit types with TypedDict or Pydantic models at the edge of the system.
  3. Ignoring explainability

    • “Model said no” is not acceptable in retail banking.
    • Always return a reason code mapped to policy language like DTI_TOO_HIGH, LOW_CREDIT_SCORE, or MANUAL_REVIEW_REQUIRED.
  4. Not versioning policies

    • A loan approved under one rule set may be declined under another.
    • Version both code and underwriting rules together so audit teams can reproduce outcomes exactly.

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