How to Build a underwriting Agent Using LangGraph in Python for lending

By Cyprian AaronsUpdated 2026-04-21
underwritinglanggraphpythonlending

An underwriting agent for lending takes a loan application, gathers the needed facts, checks policy and risk rules, and returns a decision path: approve, decline, or route to manual review. It matters because lending decisions need consistency, auditability, and speed, and a graph-based agent gives you deterministic control over each step instead of letting an LLM freestyle through regulated workflows.

Architecture

  • Application intake node
    • Normalizes borrower data, loan amount, income, liabilities, collateral, and requested product.
  • Document extraction node
    • Pulls structured fields from pay stubs, bank statements, tax forms, or business financials.
  • Policy/rules node
    • Applies hard constraints like minimum DSCR, max DTI, residency rules, and prohibited-use checks.
  • Risk scoring node
    • Produces a score or band using internal models and external bureau signals.
  • Decision node
    • Converts policy outputs and risk score into approve, decline, or manual_review.
  • Audit/logging node
    • Persists every input, intermediate output, and final rationale for compliance review.

Implementation

1) Define the state and graph nodes

Use StateGraph with a typed state object. Keep the state explicit so every field that affects credit decisions is visible in the graph.

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

class UnderwritingState(TypedDict):
    applicant_id: str
    loan_amount: float
    annual_income: float
    monthly_debt: float
    country: str
    product_type: str
    docs_verified: bool
    dti: float
    risk_score: int
    decision: Literal["approve", "decline", "manual_review"]
    reasons: Annotated[list[str], operator.add]

def intake_node(state: UnderwritingState):
    dti = state["monthly_debt"] * 12 / state["annual_income"]
    return {"dti": dti}

def policy_node(state: UnderwritingState):
    reasons = []
    if state["country"] != "US":
        reasons.append("Non-US residency requires manual compliance review")
    if state["product_type"] == "secured" and not state["docs_verified"]:
        reasons.append("Collateral docs not verified")
    if state["dti"] > 0.45:
        reasons.append(f"DTI too high: {state['dti']:.2f}")
    return {"reasons": reasons}

def risk_node(state: UnderwritingState):
    score = 720 if state["annual_income"] > 100000 else 640
    return {"risk_score": score}

def decision_node(state: UnderwritingState):
    if any("manual review" in r.lower() for r in state["reasons"]):
        return {"decision": "manual_review"}
    if state["risk_score"] >= 700 and state["dti"] <= 0.4:
        return {"decision": "approve"}
    return {"decision": "decline"}

2) Wire the workflow with add_node, add_edge, and add_conditional_edges

This is where LangGraph helps most. You model underwriting as a controlled process instead of one big prompt.

def build_graph():
    graph = StateGraph(UnderwritingState)

    graph.add_node("intake", intake_node)
    graph.add_node("policy", policy_node)
    graph.add_node("risk", risk_node)
    graph.add_node("decision", decision_node)

    graph.add_edge(START, "intake")
    graph.add_edge("intake", "policy")
    graph.add_edge("policy", "risk")
    graph.add_edge("risk", "decision")

    def route_after_decision(state: UnderwritingState):
        return state["decision"]

    graph.add_conditional_edges(
        "decision",
        route_after_decision,
        {
            "approve": END,
            "decline": END,
            "manual_review": END,
        },
    )

    return graph.compile()

app = build_graph()

3) Run an application through the compiled app

The compiled graph exposes .invoke(). In production you’d wrap this in an API layer and persist the full state transition log.

result = app.invoke(
    {
        "applicant_id": "A-10021",
        "loan_amount": 25000.0,
        "annual_income": 98000.0,
        "monthly_debt": 1200.0,
        "country": "US",
        "product_type": "secured",
        "docs_verified": True,
        "dti": 0.0,
        "risk_score": 0,
        "decision": "manual_review",
        "reasons": [],
    }
)

print(result["decision"])
print(result["reasons"])
print(result["dti"], result["risk_score"])

4) Add an LLM only where it belongs

For lending, do not let the model make the final credit call. Use it for document summarization or explanation drafting after rules have already decided the path.

A common pattern is to insert an LLM-backed node between extraction and policy review:

  • summarize income evidence from documents
  • extract missing fields into structured JSON
  • draft adverse action explanation text for compliance review

Keep that node isolated so you can test it independently and disable it without breaking core underwriting logic.

Production Considerations

  • Deployment
    • Run the graph behind a service boundary with strict schema validation on inputs.
    • Store raw application payloads and node outputs in immutable audit storage.
  • Monitoring
    • Track approval rate, manual review rate, decline reasons, model drift, and policy override frequency.
    • Alert on sudden shifts by product type, geography, or channel.
  • Guardrails
    • Hard-block unsupported jurisdictions before any model call.
    • Separate PII handling from reasoning steps; redact sensitive fields before sending text to an LLM.
  • Compliance
    • Persist rationale for every adverse decision to support fair lending reviews.
    • Version your policies so you can reproduce decisions from a specific date.

Common Pitfalls

  1. Letting the LLM decide credit outcomes

    • Avoid this by keeping approval logic in deterministic Python nodes.
    • Use the model only for extraction or explanation generation.
  2. Mixing compliance checks with scoring logic

    • Keep policy enforcement separate from risk scoring.
    • That makes audits easier and prevents “high score but illegal to book” failures.
  3. Ignoring data residency and retention rules

    • Don’t send borrower PII to unmanaged endpoints.
    • Route EU/UK data to approved regions and log where every field was processed.
  4. Building graphs without traceable outputs

    • Every node should return explicit fields like reasons, risk_score, or decision.
    • If you can’t explain why a loan was declined in one pass through the logs, your workflow is not ready for lending.

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