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

By Cyprian AaronsUpdated 2026-04-21
underwritinglanggraphpythonretail-banking

An underwriting agent in retail banking takes a loan application, gathers the missing facts, checks policy rules, scores risk, and returns a decision path: approve, refer, or decline. It matters because retail banks need faster credit decisions without giving up compliance, auditability, or consistent policy enforcement.

Architecture

  • Application intake

    • Normalizes borrower data from web forms, CRM records, and document extraction.
    • Validates required fields like income, employment status, debt obligations, and consent.
  • Policy and compliance layer

    • Encodes underwriting rules such as minimum income thresholds, DTI limits, KYC checks, and product-specific constraints.
    • Separates hard policy failures from soft exceptions that require manual review.
  • Risk scoring node

    • Calls a model or deterministic scoring function to estimate affordability and default risk.
    • Produces structured outputs the rest of the graph can route on.
  • Decision router

    • Uses LangGraph conditional edges to send the case to approve, refer, or decline paths.
    • Keeps the decision logic explicit and auditable.
  • Audit trail store

    • Persists inputs, intermediate decisions, and final outcome for model risk management and regulator review.
    • Captures timestamps, rule hits, and human override reasons.
  • Human review queue

    • Handles borderline cases, missing data, fraud flags, or policy exceptions.
    • Ensures separation between automated recommendation and final credit decision where required.

Implementation

  1. Define the state and core underwriting nodes

Use a typed state so every step in the graph reads and writes predictable fields. For retail banking, that means keeping raw applicant data separate from derived risk signals and decision metadata.

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

class UnderwritingState(TypedDict):
    applicant_id: str
    income: float
    monthly_debt: float
    requested_amount: float
    employment_years: float
    kyc_passed: bool
    dti: float
    risk_band: Literal["low", "medium", "high"]
    decision: Literal["approve", "refer", "decline"]
    reason: str

def validate_application(state: UnderwritingState):
    if not state["kyc_passed"]:
        return {"decision": "decline", "reason": "KYC failed"}
    return {}

def calculate_dti(state: UnderwritingState):
    dti = state["monthly_debt"] / max(state["income"], 1)
    return {"dti": dti}

def score_risk(state: UnderwritingState):
    if state["dti"] < 0.35 and state["employment_years"] >= 2:
        return {"risk_band": "low"}
    if state["dti"] < 0.5:
        return {"risk_band": "medium"}
    return {"risk_band": "high"}
  1. Add routing logic with add_conditional_edges

This is the part that makes LangGraph useful for underwriting. The graph should not hide decision logic inside one big chain; it should route explicitly based on policy outcomes.

def route_decision(state: UnderwritingState):
    if state.get("decision") == "decline":
        return END
    if state["risk_band"] == "low":
        return "approve"
    if state["risk_band"] == "medium":
        return "refer"
    return "decline"

def approve_case(state: UnderwritingState):
    return {"decision": "approve", "reason": "Meets automated underwriting criteria"}

def refer_case(state: UnderwritingState):
    return {"decision": "refer", "reason": "Borderline risk requires manual review"}

def decline_case(state: UnderwritingState):
    return {"decision": "decline", "reason": "Risk exceeds policy threshold"}

builder = StateGraph(UnderwritingState)
builder.add_node("validate_application", validate_application)
builder.add_node("calculate_dti", calculate_dti)
builder.add_node("score_risk", score_risk)
builder.add_node("approve", approve_case)
builder.add_node("refer", refer_case)
builder.add_node("decline", decline_case)

builder.add_edge(START, "validate_application")
builder.add_edge("validate_application", "calculate_dti")
builder.add_edge("calculate_dti", "score_risk")
builder.add_conditional_edges(
    "score_risk",
    route_decision,
    {
        "approve": "approve",
        "refer": "refer",
        "decline": END,
        END: END,
    },
)
builder.add_edge("approve", END)
builder.add_edge("refer", END)

graph = builder.compile()
  1. Run the graph with a real application payload

Keep execution inputs small and explicit. In production you would hydrate this from your case management system or loan origination platform after masking any unnecessary personal data.

result = graph.invoke({
    "applicant_id": "A-10021",
    "income": 6500.0,
    "monthly_debt": 1800.0,
    "requested_amount": 12000.0,
    "employment_years": 3.5,
    "kyc_passed": True,
})
print(result)
  1. Add audit-friendly persistence around the graph

LangGraph gives you execution structure; your bank still needs traceability. Wrap invoke() with logging that stores input hashes, rule outcomes, timestamps, and final recommendation into an immutable audit store.

A practical pattern is:

  • log before execution with a correlation ID
  • persist each returned state snapshot
  • store the final decision plus reason code
  • keep PII in a restricted vault or region-bound datastore

Production Considerations

  • Deployment

    • Run the agent inside your bank’s approved network boundary or region-specific cloud account.
    • Keep customer data residency aligned with local banking rules; do not ship applications across regions just because your LLM endpoint is elsewhere.
  • Monitoring

    • Track approval rate by product line, referral rate, decline rate, and manual override rate.
    • Alert on drift in DTI distributions or sudden changes in risk band assignment.
  • Guardrails

    • Hard-code non-negotiable policy checks outside the model path: KYC failure, sanctions hits, age thresholds where applicable.
    • Use structured outputs only; never let free-form model text become the final credit decision record.
  • Audit and explainability

    • Persist rule hits such as “DTI above threshold” or “employment history below minimum.”
    • Store versioned prompts, policy versions, model versions, and reviewer IDs for every case.

Common Pitfalls

  • Mixing recommendation with final decision

    • The agent should recommend; your workflow may still require human approval for certain products or jurisdictions.
    • Fix this by separating decision_recommendation from final_decision.
  • Using unstructured LLM output for policy checks

    • A paragraph saying “looks good” is useless in an audit.
    • Fix this by returning typed fields like dti, risk_band, and reason_code, then routing on those fields only.
  • Ignoring regional compliance constraints

    • Retail banking decisions often depend on local laws around adverse action notices, explainability, retention periods, and cross-border processing.
    • Fix this by making compliance requirements first-class nodes in the graph rather than post-processing afterthoughts.

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