How to Build a underwriting Agent Using LangGraph in Python for investment banking
An underwriting agent in investment banking takes a deal package, extracts the relevant facts, checks them against policy and risk rules, drafts a credit or underwriting memo, and flags anything that needs human review. It matters because bankers spend too much time on repetitive document triage, while the real risk is missing a compliance issue, a covenant breach, or a jurisdiction-specific constraint.
Architecture
- •
Document ingestion layer
- •Pulls PDFs, term sheets, financial statements, KYC files, and internal policy docs.
- •Converts them into structured text before the agent sees anything.
- •
Extraction and normalization node
- •Uses an LLM to extract entities like issuer, facility size, tenor, covenants, ratings, jurisdiction, and use of proceeds.
- •Normalizes values into a strict schema so downstream checks are deterministic.
- •
Policy and risk rules node
- •Applies bank-specific underwriting rules.
- •Checks thresholds like leverage ratios, sector exclusions, sanctioned geographies, and approval limits.
- •
Drafting node
- •Produces an underwriting summary or credit memo section from validated state.
- •Must cite source inputs so the output is auditable.
- •
Human review gate
- •Routes exceptions to a banker or compliance reviewer.
- •Prevents the agent from auto-approving anything outside policy.
- •
Audit log store
- •Persists inputs, intermediate decisions, model outputs, and final recommendations.
- •Required for model governance and regulatory review.
Implementation
- •Define the state and build the graph
Use TypedDict for the shared state. Keep it explicit; underwriting workflows fail when state becomes a loose bag of strings.
from typing import TypedDict, List, Optional
from langgraph.graph import StateGraph, START, END
class UnderwritingState(TypedDict):
deal_text: str
extracted: dict
risk_flags: List[str]
memo_draft: str
decision: str
reviewer_notes: Optional[str]
def extract_deal(state: UnderwritingState) -> UnderwritingState:
text = state["deal_text"]
extracted = {
"issuer": "Acme Corp",
"facility_size_usd": 250000000,
"tenor_years": 5,
"jurisdiction": "US",
"sector": "Industrial",
"leverage_ratio": 4.8,
}
return {**state, "extracted": extracted}
def risk_check(state: UnderwritingState) -> UnderwritingState:
flags = []
e = state["extracted"]
if e["leverage_ratio"] > 4.5:
flags.append("Leverage above policy threshold")
if e["jurisdiction"] not in {"US", "UK", "EU"}:
flags.append("Unsupported jurisdiction")
return {**state, "risk_flags": flags}
def draft_memo(state: UnderwritingState) -> UnderwritingState:
e = state["extracted"]
memo = (
f"Issuer: {e['issuer']}\n"
f"Facility: ${e['facility_size_usd']:,}\n"
f"Tenor: {e['tenor_years']} years\n"
f"Sector: {e['sector']}\n"
f"Risk Flags: {', '.join(state['risk_flags']) or 'None'}"
)
decision = "REVIEW" if state["risk_flags"] else "APPROVE_FOR_HUMAN_SIGNOFF"
return {**state, "memo_draft": memo, "decision": decision}
graph = StateGraph(UnderwritingState)
graph.add_node("extract_deal", extract_deal)
graph.add_node("risk_check", risk_check)
graph.add_node("draft_memo", draft_memo)
graph.add_edge(START, "extract_deal")
graph.add_edge("extract_deal", "risk_check")
graph.add_edge("risk_check", "draft_memo")
graph.add_edge("draft_memo", END)
app = graph.compile()
- •Add routing for exceptions
A real underwriting agent should not always follow one path. Use add_conditional_edges to send risky deals to human review.
from typing import Literal
def route_decision(state: UnderwritingState) -> Literal["human_review", "__end__"]:
return "human_review" if state["decision"] == "REVIEW" else "__end__"
def human_review(state: UnderwritingState) -> UnderwritingState:
notes = f"Escalate to credit officer. Flags: {state['risk_flags']}"
return {**state, "reviewer_notes": notes}
graph = StateGraph(UnderwritingState)
graph.add_node("extract_deal", extract_deal)
graph.add_node("risk_check", risk_check)
graph.add_node("draft_memo", draft_memo)
graph.add_node("human_review", human_review)
graph.add_edge(START, "extract_deal")
graph.add_edge("extract_deal", "risk_check")
graph.add_edge("risk_check", "draft_memo")
graph.add_conditional_edges("draft_memo", route_decision)
graph.add_edge("human_review", END)
app = graph.compile()
- •Run it with real deal input
This is the pattern you want in production services: load documents upstream, pass a clean text payload into LangGraph, then persist the result after execution.
deal_input = {
"deal_text": """
Proposed $250m senior secured facility for Acme Corp.
Five-year tenor. US jurisdiction. Industrial sector.
Preliminary leverage ratio at closing expected to be 4.8x.
""",
"extracted": {},
"risk_flags": [],
"memo_draft": "",
"decision": "",
"reviewer_notes": None,
}
result = app.invoke(deal_input)
print(result["decision"])
print(result["memo_draft"])
if result.get("reviewer_notes"):
print(result["reviewer_notes"])
- •Swap in LLM extraction without losing control
The extraction step should call a model through structured output or function calling, then validate against your schema before any policy logic runs. That keeps hallucinations from contaminating approvals.
A practical pattern is:
- •LLM extracts fields from the deal package
- •Pydantic validates types and required fields
- •deterministic rule nodes decide whether to proceed
If you’re using langchain-openai, keep the model call inside extract_deal, but never let it write directly to final approval state without validation.
Production Considerations
- •
Compliance logging
- •Store every input document hash, extracted field set, rule trigger, and final recommendation.
- •Regulators will care about why a deal was escalated or approved.
- •
Data residency
- •Keep EU client data in EU-hosted infrastructure and separate region-specific vector stores if you use retrieval.
- •Don’t route confidential offering materials across regions by default.
- •
Guardrails
- •Block outputs that recommend approval when mandatory fields are missing.
- •Enforce hard stops for sanctions hits, restricted sectors, or unapproved jurisdictions.
- •
Monitoring
- •Track extraction accuracy, escalation rate, false positives on policy rules, and latency per node.
- •A sudden drop in escalation rate can mean your rules broke silently.
Common Pitfalls
- •
Letting the LLM decide policy
- •Don’t ask the model whether a deal passes underwriting rules.
- •Use the model for extraction and drafting; use code for thresholds and compliance checks.
- •
Skipping schema validation
- •If leverage ratio comes back as
"4.8x"in one run and4.8in another, your graph will become brittle. - •Validate every extracted payload with strict types before routing.
- •If leverage ratio comes back as
- •
No human-in-the-loop path
- •Investment banking cannot be fully autonomous on first pass.
- •Add an explicit review branch for exceptions so bankers can override with traceable notes.
- •
Weak auditability
- •If you cannot reconstruct why the agent escalated a deal six months later, it is not production-ready.
- •Persist graph inputs/outputs per run ID and keep immutable logs tied to document versions.
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