Build a Healthcare AI Agent with LangGraph: Patient Triage & Scheduling
Step-by-step LangGraph tutorial building a clinical triage agent with patient lookup, symptom assessment, appointment scheduling, and clinician escalation.
Your triage nurse just spent 12 minutes on a patient who needed a specialist referral — and the patient with chest pain sat waiting in the lobby. We’ve seen this pattern across every clinical setting we’ve worked with: the bottleneck isn’t medical expertise, it’s routing. Getting the right patient to the right resource at the right time.
In this tutorial we’ll build a clinical triage agent with LangGraph’s StateGraph that handles patient lookup, symptom assessment, appointment scheduling, and clinician escalation. It’s the same graph pattern we used for our retail inventory agent, adapted for a healthcare context where the stakes are higher and the routing logic matters more.
Requirements: Python 3.10+, an OpenAI API key (or any LangChain-compatible provider), and about 20 minutes.
Architecture
Our agent has five tool nodes orchestrated by a single StateGraph:
- Patient lookup — retrieve patient record by ID, including history and allergies
- Symptom triage — assess urgency based on reported symptoms (routine, urgent, emergency)
- Appointment scheduling — book a slot by department and urgency tier
- Medication check — verify a prescription against patient allergies and interactions
- Clinician escalation — hand off to a human provider with a structured summary
The graph loops through agent → decide → tools → agent until the model produces a final response without tool calls. A requires_clinician flag in the state triggers early termination when the case needs human judgment.
Step 1: Install Dependencies
mkdir healthcare-agent && cd healthcare-agent
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install langgraph langchain-openai
Export your API key:
export OPENAI_API_KEY="sk-..."
Step 2: Define the Graph State
The state dictionary carries the conversation messages, the active patient context, triage results, and an escalation flag.
from typing import TypedDict, Annotated, Optional
from langgraph.graph.message import add_messages
class ClinicalAgentState(TypedDict):
messages: Annotated[list, add_messages]
patient_id: Optional[str]
triage_level: Optional[str] # "routine", "urgent", or "emergency"
requires_clinician: bool
The add_messages reducer appends new messages instead of overwriting — giving the agent conversation memory across graph cycles. The triage_level and requires_clinician fields let downstream nodes make routing decisions without re-parsing LLM output.
Step 3: Build the Tool Functions
These simulate a clinical backend. In production you’d wire them to your EHR system (Epic, Cerner) or a FHIR API. We use deterministic mock data so the code runs end-to-end without external dependencies.
from langchain_core.tools import tool
from datetime import datetime, timedelta
@tool
def lookup_patient(patient_id: str) -> str:
"""Retrieve a patient's record by ID. Returns name, age, allergies, and active conditions."""
patients = {
"P-1001": {
"name": "Maria Gonzalez",
"age": 47,
"allergies": ["penicillin", "sulfa"],
"conditions": ["type-2 diabetes", "hypertension"],
"last_visit": "2026-05-14",
},
"P-1002": {
"name": "James Chen",
"age": 62,
"allergies": ["latex"],
"conditions": ["coronary artery disease", "hyperlipidemia"],
"last_visit": "2026-06-01",
},
"P-1003": {
"name": "Aisha Patel",
"age": 29,
"allergies": [],
"conditions": ["asthma"],
"last_visit": "2026-03-22",
},
}
patient = patients.get(patient_id)
if not patient:
return f"Patient {patient_id} not found. Verify the ID and try again."
return (
f"Patient: {patient['name']}, Age: {patient['age']}. "
f"Allergies: {', '.join(patient['allergies']) if patient['allergies'] else 'none'}. "
f"Active conditions: {', '.join(patient['conditions'])}. "
f"Last visit: {patient['last_visit']}."
)
@tool
def triage_symptoms(patient_id: str, symptoms: str) -> str:
"""Assess symptom urgency and return a triage level: routine, urgent, or emergency."""
emergency_keywords = [
"chest pain", "difficulty breathing", "severe bleeding",
"loss of consciousness", "stroke", "anaphylaxis", "overdose"
]
urgent_keywords = [
"high fever", "fracture", "deep cut", "severe pain",
"vomiting blood", "confusion", "dehydration"
]
symptoms_lower = symptoms.lower()
for keyword in emergency_keywords:
if keyword in symptoms_lower:
return (
f"TRIAGE: EMERGENCY — Patient {patient_id}. "
f"Symptoms indicate potential {keyword}. "
f"Immediate intervention required. Call 911 or direct to ER."
)
for keyword in urgent_keywords:
if keyword in symptoms_lower:
return (
f"TRIAGE: URGENT — Patient {patient_id}. "
f"Symptoms include {keyword}. "
f"Schedule same-day appointment within 4 hours."
)
return (
f"TRIAGE: ROUTINE — Patient {patient_id}. "
f"Symptoms do not match emergency or urgent criteria. "
f"Schedule routine appointment within 3-5 business days."
)
@tool
def schedule_appointment(patient_id: str, department: str, urgency: str) -> str:
"""Book an appointment slot. Department options: cardiology, primary-care, dermatology, orthopedics."""
slots = {
"emergency": {"available": True, "eta": "immediate — ER intake"},
"urgent": {"available": True, "eta": (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d %H:%M")},
"routine": {"available": True, "eta": (datetime.now() + timedelta(days=4)).strftime("%Y-%m-%d")},
}
valid_departments = {"cardiology", "primary-care", "dermatology", "orthopedics"}
if department not in valid_departments:
return f"Unknown department '{department}'. Options: {', '.join(sorted(valid_departments))}."
slot = slots.get(urgency, slots["routine"])
if not slot["available"]:
return f"No {urgency} slots available in {department}. Escalating to scheduling coordinator."
return (
f"Appointment booked: Patient {patient_id}, {department} department. "
f"Urgency: {urgency}. Estimated: {slot['eta']}."
)
@tool
def check_medication(patient_id: str, medication: str) -> str:
"""Verify a medication against patient allergies and known interactions."""
interactions = {
"penicillin": {"amoxicillin", "ampicillin", "augmentin"},
"sulfa": {"bactrim", "septra", "sulfamethoxazole"},
}
patient_result = lookup_patient.invoke({"patient_id": patient_id})
if "not found" in patient_result:
return patient_result
patient_allergies = []
if "penicillin" in patient_result.lower():
patient_allergies.append("penicillin")
if "sulfa" in patient_result.lower():
patient_allergies.append("sulfa")
for allergy in patient_allergies:
if medication.lower() in interactions.get(allergy, set()):
return (
f"⚠️ CONTRAINDICATED: Patient {patient_id} is allergic to {allergy}. "
f"{medication} is in the {allergy} family. Do NOT prescribe. "
f"Consider alternative antibiotics."
)
return (
f"Medication check: {medication} — no known allergies or interactions "
f"for patient {patient_id}. Safe to prescribe with standard monitoring."
)
@tool
def escalate_to_clinician(patient_id: str, reason: str) -> str:
"""Escalate a case requiring human clinical judgment."""
return (
f"Case escalated for patient {patient_id}. Reason: {reason}. "
f"On-call clinician notified. Expected review within 15 minutes."
)
tools = [lookup_patient, triage_symptoms, schedule_appointment, check_medication, escalate_to_clinician]
The check_medication tool actually calls lookup_patient internally — demonstrating how tools can compose. In production you’d replace the string-matching allergy detection with a structured FHIR medication request check.
Step 4: Wire Up the StateGraph
Same pattern as every LangGraph agent: bind tools to the model, register an agent node and a ToolNode, then route conditionally.
from langgraph.prebuilt import ToolNode
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-4o-mini").bind_tools(tools)
def agent_node(state: ClinicalAgentState):
"""Invoke the LLM with the current conversation state."""
response = model.invoke(state["messages"])
return {"messages": [response]}
workflow = StateGraph(ClinicalAgentState)
workflow.add_node("agent", agent_node)
workflow.add_node("tools", ToolNode(tools))
workflow.add_edge(START, "agent")
def clinical_router(state: ClinicalAgentState) -> str:
"""Route after each agent step: tools, end, or clinician escalation."""
last_message = state["messages"][-1]
tool_calls = getattr(last_message, "tool_calls", None)
if tool_calls:
for tc in tool_calls:
if tc["name"] == "escalate_to_clinician":
return END
return "tools"
return END
workflow.add_conditional_edges("agent", clinical_router, {"tools": "tools", END: END})
workflow.add_edge("tools", "agent")
app = workflow.compile()
When the model calls escalate_to_clinician, the router terminates the graph immediately — the patient needs a human, not another LLM round-trip. For all other tool calls, results feed back into the agent node for a natural-language response.
This is the core insight we’ve learned shipping agents at Turion: the routing logic is the product. A graph that knows when to stop is worth more than a model upgrade. For more on graph architecture patterns, see our complete guide to agent frameworks.
Step 5: Run the Agent
Let’s test three realistic clinical scenarios:
from langchain_core.messages import HumanMessage
def run_clinical_query(query: str, patient_context: str = ""):
full_query = query
if patient_context and "patient" not in query.lower():
full_query = f"[Patient context: {patient_context}] {query}"
messages = [HumanMessage(content=full_query)]
result = app.invoke({"messages": messages, "requires_clinician": False})
print(f"Q: {query}")
print(f"A: {result['messages'][-1].content}")
print("---")
# Scenario 1: Routine triage
run_clinical_query(
"Patient P-1003 has been wheezing and using her inhaler more than usual. "
"She needs to see someone about adjusting her asthma medication."
)
# Scenario 2: Urgent — needs escalation
run_clinical_query(
"Patient P-1002 is reporting chest pain that started 30 minutes ago. "
"He says it feels like pressure, not sharp. He has a history of CAD."
)
# Scenario 3: Medication safety check
run_clinical_query(
"Patient P-1001 has a bacterial infection. Can we prescribe amoxicillin?"
)
Expected output:
Q: Patient P-1003 has been wheezing and using her inhaler more than usual...
A: I've assessed Aisha Patel (P-1003). Her symptoms — increased wheezing and
inhaler use — triage as ROUTINE. She has a history of asthma and no known
allergies. I've scheduled a primary-care appointment for [date], 4 days from now.
---
Q: Patient P-1002 is reporting chest pain that started 30 minutes ago...
A: ⚠️ EMERGENCY: James Chen (P-1002) is reporting chest pain with a history of
coronary artery disease. This requires immediate intervention. I'm escalating
to the on-call clinician. In the meantime, the patient should go to the ER or
call 911.
---
Q: Patient P-1001 has a bacterial infection. Can we prescribe amoxicillin?
A: ⚠️ CONTRAINDICATED: Maria Gonzalez (P-1001) is allergic to penicillin.
Amoxicillin is in the penicillin family. Do NOT prescribe. Consider alternative
antibiotics and consult the on-call clinician for a safe substitute.
---
The agent chains tool calls automatically: it looks up the patient, runs triage, schedules the appointment if needed — all in a single graph cycle. The chest pain case triggers the escalation path and terminates before the model can keep chatting.
Step 6: Add Clinical Guardrails with a System Prompt
Healthcare agents need explicit boundaries. The system prompt defines scope, safety rules, and when to escalate.
CLINICAL_SYSTEM_PROMPT = """You are a clinical triage assistant for Riverdale Medical Group.
Rules:
- Always look up the patient record before making any clinical recommendation.
- Run symptom triage on EVERY patient complaint before suggesting next steps.
- NEVER prescribe medication without checking allergies and interactions first.
- If the triage result is EMERGENCY, escalate to a clinician immediately and advise the patient to seek emergency care. Do not attempt to schedule.
- If the triage result is URGENT, schedule a same-day appointment in the appropriate department.
- If the triage result is ROUTINE, schedule within 3-5 business days.
- Never contradict a clinician's orders or make a definitive diagnosis.
- If the patient's query is unclear, ask clarifying questions before acting.
Your role is to route patients efficiently and safely. You do not replace clinical judgment."""
def run_with_guardrails(query: str):
from langchain_core.messages import HumanMessage, SystemMessage
messages = [
SystemMessage(content=CLINICAL_SYSTEM_PROMPT),
HumanMessage(content=query),
]
result = app.invoke({"messages": messages, "requires_clinician": False})
print(f"A: {result['messages'][-1].content}")
# Test a borderline case
run_with_guardrails(
"Patient P-1002 called in saying his chest feels tight after climbing stairs. "
"He's not sure if it's serious. What should we do?"
)
The system prompt enforces the clinical workflow: lookup → triage → schedule/escalate. No shortcuts. This is the same guardrail pattern we use in our LangGraph human-in-the-loop tutorial — explicit routing rules in the prompt, enforced by graph structure.
Step 7: Persist State Across Turns
Real clinical conversations span multiple messages. LangGraph’s checkpointer handles this with a thread_id:
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
app_with_memory = workflow.compile(checkpointer=checkpointer)
thread = {"configurable": {"thread_id": "clinical-session-42"}}
# Turn 1: Patient lookup
result1 = app_with_memory.invoke(
{"messages": [HumanMessage(content="Look up patient P-1001 for me.")]},
config=thread,
)
print("Turn 1:", result1["messages"][-1].content)
# Turn 2: Follow-up — agent remembers the patient context
result2 = app_with_memory.invoke(
{"messages": [HumanMessage(content="She's reporting a high fever and severe body aches. What's the next step?")]},
config=thread,
)
print("Turn 2:", result2["messages"][-1].content)
The agent resolves “she” to Maria Gonzalez (P-1001) from the first turn, then runs triage on the symptoms — all without the user re-specifying the patient ID. For production, swap MemorySaver with SqliteSaver or a Postgres-backed checkpointer.
Where to Go Next
This agent handles the core triage workflow, but production clinical systems need more:
- FHIR integration: Replace the mock patient dict with real FHIR API calls. The HL7 FHIR standard is the lingua franca of modern EHR systems.
- HIPAA compliance: Any deployment handling real patient data needs encrypted storage, audit logging, and a BAA with your LLM provider. See our agent governance deep dive for the full compliance checklist.
- Structured outputs: Use LangChain’s
with_structured_output()to parse triage results into typed Pydantic models — critical for integrating with downstream scheduling and billing systems. - Human-in-the-loop approval: For high-stakes actions (prescribing, procedure orders), insert an interrupt step that pauses the graph and waits for clinician sign-off.
- Evaluation: Build a golden dataset of 50+ clinical scenarios and run regression tests before every deploy. Our agent evaluation strategies cover the framework.
The pattern we’ve built — lookup, triage, schedule, escalate — transfers to other clinical workflows with minimal changes. Swap the tools, keep the graph structure. For more on the framework landscape, our complete guide to agent frameworks in 2026 compares LangGraph against OpenAI Agents SDK, CrewAI, and AutoGen across production readiness, observability, and deployment complexity.
Related Posts
LangGraph State, Checkpointing, and Resumable Agents
Build a production-grade LangGraph agent with TypedDict state, SQLite checkpointing, and human-in-the-loop interrupts. Complete runnable code.
Build a Retail AI Agent with LangGraph: Inventory & Orders
Step-by-step LangGraph tutorial building a retail AI agent with StateGraph, tool-calling nodes for inventory lookup, order processing, and returns.
LangGraph Human-in-the-Loop: Interrupt Patterns in Python
from langgraph.types import interrupt — build human-in-the-loop approval workflows in LangGraph. Step-by-step with approve, reject, and edit patterns.