How to Build a underwriting Agent Using LangChain in Python for fintech

By Cyprian AaronsUpdated 2026-04-21
underwritinglangchainpythonfintech

An underwriting agent takes a loan or credit application, pulls the relevant customer and transaction data, checks it against policy rules, and produces a decision recommendation with reasons. For fintech, that matters because speed is only useful if you can also prove consistency, auditability, and compliance across every decision.

Architecture

  • Application intake layer

    • Accepts structured inputs from your API or workflow engine.
    • Normalizes fields like income, debt, business type, geography, and requested limit.
  • Policy retrieval layer

    • Pulls underwriting policy snippets, risk rules, and product constraints.
    • Usually backed by a vector store or a simple rules service.
  • Decision engine

    • Uses an LLM to synthesize facts into a recommendation.
    • Produces approve, review, or decline with rationale.
  • Guardrails layer

    • Enforces hard constraints outside the model.
    • Example: KYC missing, sanctions hit, unsupported jurisdiction, or data residency violation.
  • Audit logging layer

    • Stores inputs, retrieved policy context, model output, and final decision.
    • Needed for compliance reviews and model governance.
  • Human review handoff

    • Routes borderline cases to an analyst queue.
    • Keeps the agent from making irreversible decisions on ambiguous files.

Implementation

1. Define the underwriting schema

Keep the input structured. Underwriting agents fail when they start with free-form text and try to infer basic facts from noise.

from pydantic import BaseModel, Field
from typing import Literal, Optional

class UnderwritingApplication(BaseModel):
    applicant_id: str
    country: str
    annual_income: float = Field(gt=0)
    existing_debt: float = Field(ge=0)
    requested_limit: float = Field(gt=0)
    kyc_passed: bool
    sanctions_hit: bool
    business_type: Optional[str] = None

class UnderwritingDecision(BaseModel):
    decision: Literal["approve", "review", "decline"]
    risk_score: int = Field(ge=0, le=100)
    rationale: str

2. Build the LangChain chain with policy context

Use ChatPromptTemplate, StrOutputParser, and RunnablePassthrough. The pattern below keeps the model focused on facts plus policy text instead of raw prompt soup.

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

policy_text = """
Underwriting policy:
- Decline if sanctions_hit is true.
- Decline if kyc_passed is false.
- Review if requested_limit > annual_income * 0.5.
- Review if debt-to-income ratio exceeds 0.4.
- Approve only when no hard declines apply and risk is low.
"""

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are an underwriting assistant for a fintech lender. Follow policy exactly."),
    ("user", """
Applicant:
{application}

Policy:
{policy}

Return JSON with keys: decision, risk_score, rationale.
""")
])

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

chain = (
    {
        "application": RunnablePassthrough(),
        "policy": lambda _: policy_text,
    }
    | prompt
    | llm
    | StrOutputParser()
)

3. Add deterministic pre-checks before the LLM

Do not ask the model to enforce obvious compliance rules. Hard-stop anything that should never reach a discretionary decision.

import json

def hard_rules(app: UnderwritingApplication):
    if app.sanctions_hit:
        return {"decision": "decline", "risk_score": 100, "rationale": "Sanctions hit detected."}
    if not app.kyc_passed:
        return {"decision": "decline", "risk_score": 95, "rationale": "KYC not completed."}
    return None

def underwrite(app: UnderwritingApplication):
    blocked = hard_rules(app)
    if blocked:
        return blocked

    response = chain.invoke(app.model_dump_json())
    return response

app = UnderwritingApplication(
    applicant_id="A123",
    country="KE",
    annual_income=120000,
    existing_debt=30000,
    requested_limit=40000,
    kyc_passed=True,
    sanctions_hit=False,
)

print(underwrite(app))

4. Parse structured output before writing to your ledger

In production you want structured decisions, not prose. Use Pydantic parsing so downstream systems can store stable fields for audit and analytics.

from langchain_core.output_parsers import PydanticOutputParser

parser = PydanticOutputParser(pydantic_object=UnderwritingDecision)

structured_prompt = ChatPromptTemplate.from_messages([
    ("system", "You are an underwriting assistant. Return only valid structured output."),
    ("user", "{input}\n\n{format_instructions}")
]).partial(format_instructions=parser.get_format_instructions())

structured_chain = structured_prompt | llm | parser

result = structured_chain.invoke({
    "input": f"Applicant JSON: {app.model_dump_json()}\nPolicy: {policy_text}"
})

print(result.decision, result.risk_score)

Production Considerations

  • Enforce data residency

    • Keep applicant PII in-region if your regulatory regime requires it.
    • If you use hosted LLMs, verify where prompts and logs are processed and stored.
  • Separate hard controls from model judgment

    • Sanctions screening, KYC status, age checks, and jurisdiction blocks should be code.
    • The model should explain decisions, not invent compliance logic.
  • Log every decision path

    • Store input payload hash, retrieved policy version, model version, prompt template version, and final outcome.
    • That gives you traceability during audits and dispute resolution.
  • Monitor drift by segment

    • Track approval rates by country, product line, channel, and income band.
    • A sudden shift often means upstream data quality issues or policy regression.

Common Pitfalls

  1. Letting the LLM make binary compliance calls

    • Mistake: asking the model to decide whether KYC passed or sanctions matched.
    • Fix: run those as deterministic checks before any chain invocation.
  2. Using unstructured prompts for regulated decisions

    • Mistake: free-form text output with no schema.
    • Fix: use PydanticOutputParser or another strict parser so your ledger gets consistent fields.
  3. Ignoring policy versioning

    • Mistake: changing underwriting rules without recording which version produced each decision.
    • Fix: attach a policy ID or hash to every request and persist it with the result.
  4. Sending sensitive data everywhere

    • Mistake: passing full application payloads into logs, traces, and external tools.
    • Fix: redact unnecessary PII before tracing and keep only what the model needs for the decision.

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