How to Build a underwriting Agent Using LangGraph in Python for healthcare
An underwriting agent for healthcare takes member or provider data, checks eligibility and policy rules, scores risk against plan constraints, and produces a decision with an audit trail. That matters because healthcare underwriting is not just classification; it affects compliance, pricing, coverage eligibility, and downstream claims risk.
Architecture
- •
Input normalization node
- •Cleans up raw application data from EHR exports, PDFs, CRM records, or API payloads.
- •Converts everything into a strict schema before any reasoning happens.
- •
Policy retrieval node
- •Pulls the current underwriting rules, plan exclusions, state-specific regulations, and internal SOPs.
- •Keeps the agent grounded in approved policy instead of free-form generation.
- •
Risk assessment node
- •Scores the case using deterministic logic plus model-assisted reasoning where allowed.
- •Produces structured outputs like
approve,review, ordeny.
- •
Compliance guardrail node
- •Checks HIPAA-sensitive fields, minimum necessary access, and prohibited decisions.
- •Forces escalation when the input is incomplete or outside policy scope.
- •
Decision synthesis node
- •Converts the assessment into a final underwriting recommendation.
- •Generates a concise explanation that can be audited by compliance teams.
- •
Audit logging node
- •Stores inputs, retrieved policy references, intermediate decisions, and final output.
- •Supports traceability for regulators and internal review.
Implementation
1. Define the state and decision schema
Use a typed state object so every node reads and writes predictable fields. In healthcare underwriting, this is non-negotiable because you need reproducibility and auditability.
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, START, END
import operator
class UnderwritingState(TypedDict):
applicant: dict
policy_context: str
risk_factors: list[str]
decision: Literal["approve", "manual_review", "deny"]
rationale: str
audit_log: Annotated[list[str], operator.add]
def normalize_input(state: UnderwritingState) -> UnderwritingState:
applicant = state["applicant"]
normalized = {
"age": int(applicant["age"]),
"state": applicant["state"].upper(),
"diagnosis_codes": [c.strip().upper() for c in applicant.get("diagnosis_codes", [])],
"coverage_requested": applicant["coverage_requested"],
}
return {
**state,
"applicant": normalized,
"audit_log": ["normalized input"],
}
2. Add policy retrieval and risk scoring nodes
Keep policy text outside the prompt. In production you would replace the static string with a vector store or document service that returns only approved plan language.
def retrieve_policy(state: UnderwritingState) -> UnderwritingState:
state["policy_context"] = (
"Plan A excludes experimental treatments. "
"Applicants with uncontrolled chronic conditions require manual review. "
"State-specific rules apply for CA and NY."
)
return {**state, "audit_log": ["retrieved policy context"]}
def assess_risk(state: UnderwritingState) -> UnderwritingState:
applicant = state["applicant"]
factors = []
if applicant["age"] > 65:
factors.append("age_over_65")
if any(code.startswith("E11") for code in applicant["diagnosis_codes"]):
factors.append("diabetes_related_condition")
if applicant["state"] in {"CA", "NY"}:
factors.append("state_regulated_market")
return {
**state,
"risk_factors": factors,
"audit_log": [f"risk factors identified: {', '.join(factors) or 'none'}"],
}
3. Add compliance checks and decision logic
This is where LangGraph helps. You can branch deterministically based on state instead of forcing everything through an LLM.
def compliance_check(state: UnderwritingState) -> UnderwritingState:
applicant = state["applicant"]
if not applicant.get("coverage_requested"):
return {
**state,
"decision": "manual_review",
"rationale": "Missing coverage request; cannot underwrite.",
"audit_log": ["compliance failed: missing coverage_requested"],
}
if len(applicant.get("diagnosis_codes", [])) == 0:
return {
**state,
"decision": "manual_review",
"rationale": "No diagnosis codes provided; manual review required.",
"audit_log": ["compliance failed: missing diagnosis codes"],
}
return {**state, "audit_log": ["compliance passed"]}
4. Build the LangGraph workflow
Use StateGraph, add nodes with add_node, connect them with add_edge, then compile. This pattern gives you a real graph with explicit control flow.
def decide(state: UnderwritingState) -> UnderwritingState:
if state.get("decision") in {"manual_review", "deny"}:
return state
factors = set(state.get("risk_factors", []))
if {"age_over_65", "diabetes_related_condition"} <= factors:
decision = "manual_review"
rationale = (
f"Multiple elevated risk indicators found: {', '.join(sorted(factors))}. "
f"Policy context: {state['policy_context']}"
)
else:
decision = "approve"
rationale = (
f"Case within acceptable risk thresholds. "
f"Policy context: {state['policy_context']}"
)
return {
**state,
"decision": decision,
"rationale": rationale,
"audit_log": [f"final decision: {decision}"],
}
graph = StateGraph(UnderwritingState)
graph.add_node("normalize_input", normalize_input)
graph.add_node("retrieve_policy", retrieve_policy)
graph.add_node("assess_risk", assess_risk)
graph.add_node("compliance_check", compliance_check)
graph.add_node("decide", decide)
graph.add_edge(START, "normalize_input")
graph.add_edge("normalize_input", "retrieve_policy")
graph.add_edge("retrieve_policy", "assess_risk")
graph.add_edge("assess_risk", "compliance_check")
graph.add_edge("compliance_check", "decide")
graph.add_edge("decide", END)
app = graph.compile()
result = app.invoke({
"applicant": {
"age": 71,
"state": "ca",
"diagnosis_codes": ["E11.9"],
"coverage_requested": True,
},
"policy_context": "",
"risk_factors": [],
"decision": None,
"rationale": "",
# Use operator.add reducer semantics by providing an initial list
# so every node can append audit entries.
# In production this should go to immutable storage.
# The graph will merge updates across nodes.
})
print(result["decision"])
print(result["rationale"])
print(result["audit_log"])
Production Considerations
- •
HIPAA controls
- •Do not send PHI to external model endpoints unless you have a signed BAA and approved data handling terms.
- •Minimize payloads before they enter the graph; strip identifiers that are not needed for underwriting.
- •
Auditability
- •Persist every state transition with timestamps, node names, retrieved policy versions, and final decisions.
- •Store immutable logs in WORM-capable storage or an equivalent tamper-evident system.
- •
Data residency
- •Keep processing inside the required jurisdiction when dealing with regional health data restrictions.
- •If your deployment spans multiple regions, pin workloads to approved clusters and avoid cross-region PHI replication.
- •
Guardrails
- •Hard-fail to manual review when inputs are incomplete or conflict with policy.
- •Add deterministic rules before any model call so the agent cannot override mandatory compliance checks.
Common Pitfalls
- •
Letting the LLM make the final underwriting call
- •Don’t do that for regulated healthcare workflows.
- •Use the model for summarization or extraction only; keep approval/denial logic deterministic and reviewable.
- •
Skipping schema validation
- •Free-form JSON from upstream systems will break your workflow sooner or later.
- •Validate at the boundary with typed state or Pydantic models before any graph execution.
- •
Ignoring versioned policy context
- •If you don’t store which policy document was used, you cannot explain why a case was approved six months later.
- •Always attach policy version IDs to the graph run and persist them with the audit record.
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