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

By Cyprian AaronsUpdated 2026-04-21
underwritinglanggraphpythonbanking

An underwriting agent for banking takes a loan or credit application, gathers the relevant facts, checks policy and compliance rules, scores risk, and produces a decision package that a human underwriter can review or approve. It matters because banks need faster turnaround without losing control: every decision must be explainable, auditable, and consistent with credit policy.

Architecture

  • Input intake node

    • Normalizes applicant data from CRM, LOS, PDFs, or API payloads.
    • Validates required fields like income, liabilities, collateral, and jurisdiction.
  • Document extraction node

    • Pulls structured facts from bank statements, payslips, tax returns, and KYC documents.
    • Should return citations or source references for audit trails.
  • Policy and compliance rules node

    • Checks hard constraints such as minimum DSCR, LTV caps, sanctions flags, and residency requirements.
    • Separates policy failures from soft risk concerns.
  • Risk scoring node

    • Produces a recommendation using deterministic rules plus optional model outputs.
    • Should output a structured decision object, not free text.
  • Human review gate

    • Routes borderline or high-risk cases to an underwriter.
    • Captures override reasons for governance.
  • Audit logger

    • Persists state transitions, inputs used, outputs generated, and final decision.
    • Required for model risk management and regulator review.

Implementation

1. Define the graph state and decision schema

Use typed state so every node knows what it can read and write. In banking workflows, this keeps the agent from drifting into unstructured output.

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

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

class UnderwritingState(TypedDict):
    applicant_id: str
    income: float
    monthly_debt: float
    loan_amount: float
    collateral_value: float
    jurisdiction: str
    sanctions_hit: bool
    dscr: Optional[float]
    ltv: Optional[float]
    decision: Optional[Decision]
    rationale: Optional[str]

2. Add deterministic underwriting nodes

Keep the core policy logic explicit. For banking use cases, deterministic checks are easier to audit than opaque agent behavior.

def compute_ratios(state: UnderwritingState):
    income = state["income"]
    debt = state["monthly_debt"]
    loan_amount = state["loan_amount"]
    collateral = state["collateral_value"]

    dscr = round(income / debt if debt else 0.0, 2)
    ltv = round(loan_amount / collateral if collateral else 1.0, 2)

    return {"dscr": dscr, "ltv": ltv}

def apply_policy(state: UnderwritingState):
    if state["sanctions_hit"]:
        return {
            "decision": "decline",
            "rationale": "Sanctions screening hit; automatic decline per policy."
        }

    if state["jurisdiction"] not in {"US", "UK", "EU"}:
        return {
            "decision": "manual_review",
            "rationale": "Unsupported jurisdiction; requires compliance review."
        }

    if state["dscr"] < 1.25 or state["ltv"] > 0.80:
        return {
            "decision": "manual_review",
            "rationale": f"Policy threshold not met (DSCR={state['dscr']}, LTV={state['ltv']})."
        }

    return {
        "decision": "approve",
        "rationale": f"Meets policy thresholds (DSCR={state['dscr']}, LTV={state['ltv']})."
    }

3. Build routing with StateGraph and conditional edges

This is the LangGraph pattern you want in production: compute facts first, then route based on those facts. Use add_conditional_edges to separate automatic approval from manual review.

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

graph = StateGraph(UnderwritingState)

graph.add_node("ratios", compute_ratios)
graph.add_node("policy", apply_policy)

graph.add_edge(START, "ratios")
graph.add_edge("ratios", "policy")

graph.add_conditional_edges(
    "policy",
    route_decision,
    {
        "approve": END,
        "decline": END,
        "manual_review": END,
    },
)

underwriting_app = graph.compile()

4. Run the workflow and persist the outcome

In a real bank you would attach persistence through a checkpointer or external audit store. Even without that here, the compiled app returns a full result that can be logged downstream.

input_state: UnderwritingState = {
    "applicant_id": "APP-10021",
    "income": 12000.0,
    "monthly_debt": 3000.0,
    "loan_amount": 180000.0,
    "collateral_value": 250000.0,
    "jurisdiction": "US",
    "sanctions_hit": False,
    "dscr": None,
    "ltv": None,
    "decision": None,
    "rationale": None,
}

result = underwriting_app.invoke(input_state)

print(result["decision"])
print(result["rationale"])
print(result["dscr"], result["ltv"])

If you want an LLM in the loop for narrative explanations only, keep it outside the decision path. The model should summarize why the rule engine decided what it decided; it should not be the source of truth for approve/decline logic.

Production Considerations

  • Use durable checkpoints

    • Persist graph state with LangGraph checkpointing or your own audit store.
    • Banking workflows need replayability for disputes, QA sampling, and regulator requests.
  • Separate PII from prompt context

    • Minimize what enters any model call.
    • Mask account numbers, SSNs/NINs, and addresses unless they are required for the specific step.
  • Enforce data residency

    • Keep application data in-region if your bank operates under residency constraints.
    • Make sure any external model endpoint is approved for that jurisdiction.
  • Instrument every decision path

    • Log node inputs/outputs plus reason codes.
    • Track approval rate drift by product type, geography, channel, and underwriter override rate.

Common Pitfalls

  • Letting the LLM make final credit decisions

    • Avoid this by making policy checks deterministic and using the model only for summarization or document extraction.
    • Final disposition should come from explicit rules or approved scoring models.
  • Returning unstructured text instead of typed outputs

    • This breaks auditability and makes downstream systems brittle.
    • Use typed state fields like decision, dscr, ltv, and rationale.
  • Ignoring compliance branches

    • A sanctions hit or unsupported jurisdiction must short-circuit into decline or manual review immediately.
    • Put these checks early in the graph so they cannot be overridden by later nodes.

A good underwriting agent is not “smart” in the vague sense. It is controlled: deterministic where regulation demands it, traceable where auditors care about evidence, and flexible only in places where automation does not change credit policy.


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