How to Build a loan approval Agent Using LangGraph in Python for pension funds

By Cyprian AaronsUpdated 2026-04-21
loan-approvallanggraphpythonpension-funds

A loan approval agent for pension funds takes an application, checks policy and risk rules, gathers missing facts, and returns a decision path: approve, reject, or escalate to a human underwriter. For pension funds, this matters because lending decisions must be explainable, auditable, and aligned with fiduciary duty, compliance rules, and strict data handling requirements.

Architecture

  • Input normalization layer

    • Converts raw application data into a typed state object.
    • Validates required fields like borrower identity, amount, tenor, collateral, and jurisdiction.
  • Policy evaluation node

    • Applies pension-fund-specific lending rules.
    • Checks concentration limits, prohibited sectors, minimum DSCR, LTV thresholds, and delegated authority limits.
  • Risk scoring node

    • Produces a structured risk assessment from financial ratios and borrower history.
    • Keeps the output deterministic and machine-readable for audit.
  • Compliance and KYC/AML gate

    • Flags missing KYC artifacts, sanctions hits, beneficial ownership gaps, or residency conflicts.
    • Forces escalation instead of silent auto-approval.
  • Decision router

    • Uses explicit branching to send the request to approve, reject, or human review.
    • This is where LangGraph fits well: stateful control flow with clear transitions.
  • Audit trail writer

    • Persists every node output, rule hit, and final decision.
    • Required for internal audit and regulator review.

Implementation

1) Define the graph state

Use a typed state so every node reads and writes the same contract. For production systems, keep the state small and structured; do not pass raw documents through the graph.

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

class LoanState(TypedDict):
    applicant_id: str
    amount: float
    annual_income: float
    debt_service_coverage_ratio: float
    loan_to_value: float
    kyc_complete: bool
    sanctions_clear: bool
    jurisdiction_ok: bool
    risk_score: int
    decision: Literal["approve", "reject", "review"]
    reason: str

2) Add deterministic nodes for policy and risk

Keep business rules explicit. Pension funds need predictable outcomes that compliance teams can trace back to rule logic.

def assess_policy(state: LoanState) -> LoanState:
    if not state["kyc_complete"]:
        state["decision"] = "review"
        state["reason"] = "KYC incomplete"
        return state

    if not state["sanctions_clear"]:
        state["decision"] = "reject"
        state["reason"] = "Sanctions screening failed"
        return state

    if not state["jurisdiction_ok"]:
        state["decision"] = "review"
        state["reason"] = "Jurisdiction requires legal review"
        return state

    return state


def score_risk(state: LoanState) -> LoanState:
    score = 0

    if state["debt_service_coverage_ratio"] >= 1.5:
        score += 40
    elif state["debt_service_coverage_ratio"] >= 1.2:
        score += 20

    if state["loan_to_value"] <= 60:
        score += 40
    elif state["loan_to_value"] <= 75:
        score += 20

    if state["annual_income"] >= (state["amount"] * 0.25):
        score += 20

    state["risk_score"] = score
    return state


def decide(state: LoanState) -> LoanState:
    if state.get("decision") in {"reject", "review"}:
        return state

    if state["risk_score"] >= 80:
        state["decision"] = "approve"
        state["reason"] = "Meets policy and risk thresholds"
    elif state["risk_score"] >= 50:
        state["decision"] = "review"
        state["reason"] = "Borderline risk profile"
    else:
        state["decision"] = "reject"
        state["reason"] = "Insufficient risk score"

    return state

3) Wire the workflow with StateGraph

This is the core LangGraph pattern. You define nodes, connect them with edges, compile the graph once, then invoke it per application.

workflow = StateGraph(LoanState)

workflow.add_node("assess_policy", assess_policy)
workflow.add_node("score_risk", score_risk)
workflow.add_node("decide", decide)

workflow.add_edge(START, "assess_policy")
workflow.add_edge("assess_policy", "score_risk")
workflow.add_edge("score_risk", "decide")
workflow.add_edge("decide", END)

app = workflow.compile()

input_state: LoanState = {
    "applicant_id": "APP-10027",
    "amount": 250000.0,
    "annual_income": 180000.0,
    "debt_service_coverage_ratio": 1.35,
    "loan_to_value": 68.0,
    "kyc_complete": True,
    "sanctions_clear": True,
    "jurisdiction_ok": True,
    "risk_score": 0,
    "decision": "review",
    "reason": ""
}

result = app.invoke(input_state)
print(result["decision"], result["reason"], result["risk_score"])

4) Add conditional routing for human escalation

For pension funds you usually need a human-in-the-loop path when policy is unclear or risk is borderline. LangGraph supports this cleanly with add_conditional_edges.

def route_after_policy(state: LoanState):
    if not state["kyc_complete"]:
        return END
    if not state["sanctions_clear"]:
        return END
    return "score_risk"

workflow2 = StateGraph(LoanState)
workflow2.add_node("assess_policy", assess_policy)
workflow2.add_node("score_risk", score_risk)
workflow2.add_node("decide", decide)

workflow2.add_edge(START, "assess_policy")
workflow2.add_conditional_edges("assess_policy", route_after_policy)
workflow2.add_edge("score_risk", "decide")
workflow2.add_edge("decide", END)

app2 = workflow2.compile()

That pattern gives you a clear audit story:

  • policy gate first
  • risk scoring second
  • final decision last

For more complex deployments, replace direct function nodes with tool-backed nodes that fetch credit bureau data or internal portfolio exposure from approved services only.

Production Considerations

  • Enforce data residency

    • Keep applicant PII inside approved regions.
    • If the pension fund operates across jurisdictions, route data only through region-specific deployments and log cross-border transfers explicitly.
  • Store full decision traces

    • Persist input snapshot, node outputs, rule hits, timestamps, and final outcome.
    • Audit teams will want to reconstruct why an application was rejected or escalated months later.
  • Add guardrails around delegated authority

    • Hard-code approval ceilings by role and fund mandate.
    • If an application exceeds threshold exposure or sector concentration limits, force review regardless of model output.
  • Monitor drift in policy outcomes

    • Track approval rates by jurisdiction, asset class, tenor band, and risk bucket.
    • A sudden shift often means upstream data quality issues or policy misconfiguration rather than real portfolio change.

Common Pitfalls

  • Using LLMs to make the final decision

    • Don’t let a model directly approve or reject loans.
    • Use deterministic rules for final disposition; reserve LLMs for document extraction or summarization where needed.
  • Passing unvalidated free-form inputs into the graph

    • Validate with Pydantic or strict schema checks before invoke.
    • Bad inputs create bad decisions fast; pension funds cannot tolerate silent coercion of missing values.
  • Ignoring human review paths

    • Borderline cases need escalation lanes.
    • If your graph only has approve/reject branches, you will end up encoding ambiguous cases as false certainty.
  • Skipping audit metadata

    • Every node should emit reasons in structured fields like reason or rule_hits.

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