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

By Cyprian AaronsUpdated 2026-04-21
underwritinglanggraphpythoninvestment-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

  1. 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()
  1. 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()
  1. 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"])
  1. 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

  1. 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.
  2. Skipping schema validation

    • If leverage ratio comes back as "4.8x" in one run and 4.8 in another, your graph will become brittle.
    • Validate every extracted payload with strict types before routing.
  3. 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.
  4. 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

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