How to Build a compliance checking Agent Using LangGraph in Python for lending
A compliance checking agent for lending reviews an application, pulls the relevant policy rules, checks the applicant data against those rules, and returns a decision with an audit trail. It matters because lending teams need consistent decisions, defensible explanations, and a clean record for regulators when a loan is approved, referred, or rejected.
Architecture
- •
Input normalization layer
- •Converts raw application payloads into a strict schema.
- •Validates fields like income, debt-to-income ratio, employment status, and jurisdiction.
- •
Policy retrieval node
- •Fetches the current lending policy version.
- •Pulls product-specific rules such as minimum credit score, max LTV, or prohibited geographies.
- •
Compliance evaluation node
- •Applies deterministic checks first.
- •Uses an LLM only for explanation drafting, not for deciding whether a rule passed.
- •
Decision router
- •Routes to
approve,refer, orrejectbased on rule outcomes. - •Keeps decisions deterministic and easy to audit.
- •Routes to
- •
Audit logging node
- •Persists the full state: input, rules used, checks run, timestamps, and final decision.
- •Stores immutable records for model risk and regulatory review.
- •
Human review handoff
- •Escalates edge cases like missing documents, sanctions hits, or conflicting data.
- •Lets compliance officers override with reason codes.
Implementation
1) Define the state and rule checks
Use a typed state so every node reads and writes predictable fields. In lending, that matters because auditability breaks fast when your graph state is loose and inconsistent.
from typing import TypedDict, Literal, Optional
from langgraph.graph import StateGraph, START, END
Decision = Literal["approve", "refer", "reject"]
class LendingState(TypedDict):
applicant_id: str
jurisdiction: str
income: float
debt: float
credit_score: int
ltv: float
policy_version: str
findings: list[str]
decision: Optional[Decision]
rationale: Optional[str]
def normalize_input(state: LendingState) -> LendingState:
findings = []
if state["income"] <= 0:
findings.append("Invalid income")
if state["debt"] < 0:
findings.append("Invalid debt")
return {**state, "findings": findings}
2) Add deterministic compliance checks
Do not let the LLM decide whether a borrower passes policy. Use code for the actual rule evaluation so the outcome is stable across runs.
def evaluate_policy(state: LendingState) -> LendingState:
findings = list(state["findings"])
if state["credit_score"] < 620:
findings.append("Credit score below minimum threshold")
if state["ltv"] > 0.80:
findings.append("LTV above maximum threshold")
dti = state["debt"] / state["income"]
if dti > 0.43:
findings.append(f"DTI too high: {dti:.2f}")
return {**state, "findings": findings}
def route_decision(state: LendingState) -> Decision:
if any("below minimum" in f or "above maximum" in f or "too high" in f for f in state["findings"]):
return "reject"
if state["findings"]:
return "refer"
return "approve"
3) Build the LangGraph workflow
This is the core pattern. StateGraph gives you explicit nodes and edges; add_conditional_edges makes routing readable; compile() produces the runnable graph you deploy behind an API.
from langgraph.graph import StateGraph, START, END
def draft_rationale(state: LendingState) -> LendingState:
if state["decision"] == "approve":
rationale = "Application passed all automated lending compliance checks."
elif state["decision"] == "refer":
rationale = f"Manual review required due to: {', '.join(state['findings'])}"
else:
rationale = f"Rejected due to policy failures: {', '.join(state['findings'])}"
return {**state, "rationale": rationale}
def build_graph():
graph = StateGraph(LendingState)
graph.add_node("normalize_input", normalize_input)
graph.add_node("evaluate_policy", evaluate_policy)
graph.add_node("draft_rationale", draft_rationale)
graph.add_edge(START, "normalize_input")
graph.add_edge("normalize_input", "evaluate_policy")
graph.add_conditional_edges(
"evaluate_policy",
route_decision,
{
"approve": "draft_rationale",
"refer": "draft_rationale",
"reject": "draft_rationale",
},
)
graph.add_edge("draft_rationale", END)
return graph.compile()
app = build_graph()
result = app.invoke({
"applicant_id": "LN-10021",
"jurisdiction": "US-NY",
"income": 120000.0,
"debt": 30000.0,
"credit_score": 640,
"ltv": 0.72,
"policy_version": "2026.01",
"findings": [],
"decision": None,
"rationale": None,
})
print(result["decision"])
print(result["rationale"])
4) Add human review and audit capture
For lending workflows, you usually want a refer path that creates an exception case instead of forcing a binary outcome. Keep the audit payload separate from the decision logic so your logs reflect what happened without changing behavior.
def attach_decision(state: LendingState) -> LendingState:
decision = route_decision(state)
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