How to Build a underwriting Agent Using LangGraph in Python for retail 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
- •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"}
- •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()
- •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)
- •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_recommendationfromfinal_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, andreason_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
- •The complete AI Agents Roadmap — my full 8-step breakdown
- •Free: The AI Agent Starter Kit — PDF checklist + starter code
- •Work with me — I build AI for banks and insurance companies
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