TL;DR
We deployed three specialized agents for a dental clinic: an appointments agent (takes calls/WhatsApp and books), a reminders agent (sends confirmations and pre-visit messages) and a reviews agent (post-visit, asks for a review). All three orchestrated with LangGraph and a Postgres checkpointer, patient memory in Mem0 over Qdrant, long flows (24h reminder, 7-day follow-up) with Temporal, healthcare guardrails with NeMo Guardrails and complete observability in Langfuse. The system reduces no-shows 38%, captures 87% of after-hours leads and frees ~60 hours/month of reception. All code, MCP tools and Temporal workflows are published.
Why three agents and not one giant one
A monolithic agent can answer everything but loses focus. Talking to a patient who wants to book is different from reminding them about tomorrow's visit or asking for a review 24h after treatment. Three benefits of multi-agent design:
- Shorter, focused prompts — each agent knows its job exactly.
- Independent evaluation — you can improve one without touching the others.
- Privilege separation — the reviews agent can never book; the appointments agent never sends bulk emails.
Overall diagram
┌──────────────────────────────────────────────────────────┐
│ LangGraph orchestrator │
│ │
│ ┌──────────────┐ │
│ │ Router │ ◄─── Channel (voice / WhatsApp / web) │
│ └──────┬───────┘ │
│ │ │
│ ┌───────┼──────────┬───────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ Booking Reminder Review Human fallback │
│ ▲ ▲ │
│ │ │ │
│ ┌────┴─────┐ ┌────┴───────┐ │
│ │ Temporal │ │ Temporal │ │
│ │ 24h/1h │ │ 24h post │ │
│ └──────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
Calendar WhatsApp Google Reviews
(MCP) (MCP) (MCP)
│ │ │
└─────────────────┴──────────────────┘
│
▼
Postgres + Qdrant + Redis + Mem0 + Langfuse
Concrete stack
| Piece | Technology | Role |
|---|---|---|
| Orchestrator | LangGraph (Python) | State graph between agents |
| Checkpointer | Postgres | Conversational state persistence |
| Semantic memory | Mem0 over Qdrant | Per-patient facts |
| Working memory | Redis | Per-turn context |
| Long flows | Temporal | 24h reminders, 7d follow-ups |
| LLM | Claude Sonnet via LiteLLM | Reasoning |
| Voice | Pipecat + Deepgram + Cartesia | Phone channel |
MCP mcp-whatsapp | Messaging channel | |
| Calendar | MCP mcp-calendar | Available slots |
| Reviews | Custom MCP mcp-reviews | Google Business Profile |
| Guardrails | NeMo Guardrails | No diagnosis, no fabricated prices |
| Observability | Langfuse self-host | Traces, evals, sessions |
The LangGraph graph
File clinic_graph.py:
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.postgres import PostgresSaver
from typing import TypedDict, Literal
class ClinicState(TypedDict):
session_id: str
patient_id: str | None
channel: Literal["voice", "whatsapp", "web"]
user_message: str
intent: Literal["book", "reschedule", "cancel", "ask_info", "confirm", "review", "unknown"]
working_memory: dict
response: str
tool_calls: list
done: bool
def router_node(state: ClinicState) -> ClinicState:
state["intent"] = classify_intent(state["user_message"])
return state
def booking_node(state: ClinicState) -> ClinicState:
# specialized agent with its own prompt + calendar tools
result = booking_agent.run(state)
return {**state, **result}
def reminder_handler(state: ClinicState) -> ClinicState:
# handles reminder confirmations
result = reminder_agent.run(state)
return {**state, **result}
def review_handler(state: ClinicState) -> ClinicState:
result = review_agent.run(state)
return {**state, **result}
def human_fallback(state: ClinicState) -> ClinicState:
trigger_human_handoff(state)
return {**state, "done": True, "response": "I'll connect you with a receptionist."}
def route(state: ClinicState) -> str:
intent = state["intent"]
if intent in ("book", "reschedule", "cancel", "ask_info"):
return "booking"
if intent == "confirm":
return "reminder"
if intent == "review":
return "review"
return "fallback"
graph = StateGraph(ClinicState)
graph.add_node("router", router_node)
graph.add_node("booking", booking_node)
graph.add_node("reminder", reminder_handler)
graph.add_node("review", review_handler)
graph.add_node("fallback", human_fallback)
graph.set_entry_point("router")
graph.add_conditional_edges("router", route, {
"booking": "booking",
"reminder": "reminder",
"review": "review",
"fallback": "fallback",
})
graph.add_edge("booking", END)
graph.add_edge("reminder", END)
graph.add_edge("review", END)
graph.add_edge("fallback", END)
checkpointer = PostgresSaver.from_conn_string(os.getenv("POSTGRES_URL"))
app = graph.compile(checkpointer=checkpointer)
Booking agent
Short prompt, specific tools, guardrails:
BOOKING_SYSTEM = """
You are the booking agent for Numoru Dental Clinic.
Your single task is helping book, reschedule or cancel appointments.
Rules:
- Confirm full name and phone before booking.
- Honor hours: Mon-Sat 9:00-19:00.
- If the patient asks for price or diagnosis, use search_clinic_info;
if it's not in the RAG, offer the doctor — never make it up.
- If you detect a real medical urgency, transfer immediately with transfer_to_emergency.
"""
booking_agent = Agent(
system_prompt=BOOKING_SYSTEM,
model="claude-sonnet",
tools=[
tool.find_available_slot,
tool.book_appointment,
tool.reschedule,
tool.cancel,
tool.search_clinic_info,
tool.transfer_to_emergency,
],
guardrails=NeMoGuardrails(config="guardrails/clinic.colang"),
)
Reminders agent (with Temporal)
The reminders agent is reactive (replies) but Temporal triggers the send:
# Temporal workflow
@workflow.defn
class AppointmentReminderWorkflow:
@workflow.run
async def run(self, appt: Appointment):
# 24h before
await workflow.sleep(appt.start - timedelta(hours=24))
await workflow.execute_activity(
send_whatsapp_template,
ReminderInput(patient_id=appt.patient_id, slot=appt.start, kind="24h"),
start_to_close_timeout=timedelta(minutes=2),
)
# 1h before
await workflow.sleep(appt.start - timedelta(hours=1))
await workflow.execute_activity(
send_whatsapp_template,
ReminderInput(patient_id=appt.patient_id, slot=appt.start, kind="1h"),
start_to_close_timeout=timedelta(minutes=2),
)
Temporal survives restarts, upgrades and worker crashes. If the patient replies via WhatsApp, the inbound message triggers the LangGraph graph with intent="confirm".
Reviews agent (post-visit)
@workflow.defn
class PostVisitReviewWorkflow:
@workflow.run
async def run(self, appt: Appointment):
# 4h post-visit
await workflow.sleep(appt.end + timedelta(hours=4))
satisfied = await workflow.execute_activity(
nps_one_question,
NpsInput(patient_id=appt.patient_id),
start_to_close_timeout=timedelta(minutes=10),
)
if satisfied >= 8:
# 24h later, ask for a public review
await workflow.sleep(timedelta(hours=20))
await workflow.execute_activity(
request_review,
ReviewInput(patient_id=appt.patient_id, channel="google"),
)
The agent doesn't ask unsatisfied patients for a review — instead it triggers an internal workflow so a human reaches out first.
Patient memory with Mem0
Facts extracted from past conversations (with consent):
from mem0 import Memory
memory = Memory.from_config({
"vector_store": {
"provider": "qdrant",
"config": {"host": "qdrant", "port": 6333, "collection_name": "clinic_patients"},
},
"llm": {"provider": "litellm", "config": {"model": "claude-sonnet"}},
})
def enrich_prompt_with_memory(patient_id: str, query: str) -> str:
facts = memory.search(query=query, user_id=patient_id, limit=5)
if not facts:
return ""
bullets = "\n".join(f"- {f['memory']}" for f in facts)
return f"\n\nPatient prior context:\n{bullets}\n"
Useful fact examples:
- "Prefers afternoon appointments"
- "Allergic to penicillin"
- "Last 2 visits: cleaning and root canal on molar 36"
- "Has GNP insurance"
Healthcare guardrails
File guardrails/clinic.colang:
define user ask medical_diagnosis
"do I have a cavity?"
"what disease do I have?"
"is it serious?"
"do I need braces?"
define bot refuse medical_diagnosis
"We need to see you to diagnose. Shall I book you with the doctor?"
define user ask price
"how much does it cost?"
"what's the price?"
define flow
user ask medical_diagnosis
bot refuse medical_diagnosis
define flow
user ask price
execute search_clinic_info
# if no price found in RAG, the tool returns an error and the default flow says "ask reception"
Observability in Langfuse
Each graph execution emits a session with:
patient_id(hash).channel.- detected
intent. - Tool calls with input/output.
- "Goal completion" score (automatic eval: did it close the user's intent?).
Dashboards we look at:
- Goal completion rate per intent (target >85% for
book). - Human transfer rate (target <12%).
- p95 latency (voice: <2.5s per turn).
- No-show rate post-reminder (baseline vs actual).
Real metrics from the pilot client
Dental clinic with 3 doctors, 850 visits/month.
| Metric | Pre | 90 days post |
|---|---|---|
| No-show rate | 22% | 14% |
| After-hours leads captured | 12% | 87% |
| Reception hours/month | 170 | 108 |
| Average NPS | 7.8 | 8.4 |
| New Google reviews/month | 4 | 18 |
| Operational agent cost (USD/month) | — | 420 |
ROI: the additional receptionist they didn't hire costs ~$600 USD / month. The agent costs less and works 24/7.
Same practice, 3 doctors, 850 visits per month. Agents deployed in sequence (booking → reminders → reviews) over 6 weeks; stabilization period observed at day 60.
- Pre (baseline = 100)
- 90 days post
Numoru client telemetry, measured against previous-year baseline.
Business & commercial impact
Where the value concentrates
Multi-agent setups unlock revenue the voice agent alone cannot. Reminders is the single biggest dollar line — every point of no-show recovered is a $120-180 slot that gets backfilled. Reviews is the long-tail growth engine — organic new patients arrive through Google Maps rank, which moves with fresh 5-star reviews. The booking agent is table stakes; the other two are the moat.
Where this pattern sells beyond dental
Multi-agent orchestration ticket by vertical (Numoru, USD)
Public benchmarks anchoring the value
Dental Intelligence — schedule & reminder analytics
Google — local search + reviews impact
LangChain / LangGraph — multi-agent production case studies
Illustrative case — aesthetic chain
Aesthetic / MedSpa chain (6 locations) adopting the 3-agent stack
ROI calculator — 3-doctor dental practice
Single dental practice, 3 doctors, 850 visits / mo (12 months)
| Platform retainer (12 mo × $420) | −$5,040 |
| Implementation (one-time) | −$6,500 |
| No-show recovery (+68 visits / mo × $180 × 12) | +$146,880 |
| New patients from after-hours capture (+19 / mo × $720 LTV × 12) | +$164,160 |
| Reception hours saved (62 × $12 × 12) | +$8,928 |
| Review-driven new visits (+6 / mo × $720 × 12) | +$51,840 |
| Net year-1 gross contribution | +$360,268 |
Pricing tiers Numoru sells
- 1 location / 1 phone / 1 WhatsApp
- LangGraph orchestration + Temporal
- Mem0 patient memory
- NeMo healthcare guardrails
- Langfuse monthly dashboard
- Google Calendar + Google Reviews
- Setup: $6,500 one-time
- Shared Qdrant knowledge base
- Per-clinic branding + voice
- Cross-site analytics dashboard
- Insurance eligibility add-on available
- Dentrix / OpenDental integration
- Quarterly eval review
- Setup: $14,000 one-time
- Self-hosted option (HIPAA scope)
- White-label agent branding
- Master contract / PO friendly
- Dedicated CSM
- Custom guardrails + policies
- Annual compliance audit
- Setup: $28,000+ one-time
Monthly includes a usage bucket (2,000 calls + 5,000 WhatsApp + 8,000 LLM inferences per clinic). Overages bill at rate card.
Deploy on the OSS stack
On top of the self-hosted droplet stack, we add:
services:
clinic-orchestrator:
image: numoru/clinic-orchestrator:latest
environment:
POSTGRES_URL: postgres://lf:${LF_DB_PASSWORD}@langfuse-db:5432/clinic
QDRANT_URL: http://qdrant:6333
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/3
LITELLM_URL: http://litellm:4000
LANGFUSE_PUBLIC_KEY: ${LF_PUBLIC_KEY}
TEMPORAL_HOST: temporal:7233
depends_on: [langfuse-db, qdrant, redis, litellm, temporal]
temporal:
image: temporalio/auto-setup:1.25
environment:
DB: postgres12
DB_PORT: 5432
POSTGRES_USER: lf
POSTGRES_PWD: ${LF_DB_PASSWORD}
POSTGRES_SEEDS: langfuse-db
networks: [core]
clinic-temporal-worker:
image: numoru/clinic-temporal-worker:latest
environment:
TEMPORAL_HOST: temporal:7233
LITELLM_URL: http://litellm:4000
depends_on: [temporal]
Compliance and regulation
- LFPDPPP (Mexico): explicit consent for memory;
/patient/{id}/dataendpoint for access/deletion. - HIPAA (if US patient): all on-prem, no data leaves the client's droplet.
- AI Act (if EU patient): transparency in the voice channel ("this assistant is AI"), auditable Langfuse logs, clear human supervision plan (clear fallback).
Tests and evals
- Unit tests for each MCP tool.
- Integration tests for the graph with embedded Postgres.
- Eval suite with 80 golden scripts per agent.
- Manual weekly E2E: reception team reviews 10 random conversations.
Common anti-patterns
- One agent that does everything. 5k-token prompts, impossible evals, risky changes.
- Reminders via cron. No durability, server restart loses them. Temporal is the answer.
- Global memory without
user_id. Data leak between patients. Mandatoryuser_idon every Mem0 operation. - No human in the loop. Guardrails fail sometimes; need a clear transfer path and edge-case alerts.
- Deploy without backup. An accidental
drop table appointmentsis fatal. Daily Restic at minimum.
FAQ
Can it adapt to other verticals (law firm, vet, spa)?Yes. Same skeleton; tools (services catalog), guardrails and tone change. Generally 1-2 weeks of adaptation per new vertical.
How many agents does the $40 droplet handle?Comfortably 3-5 small clients on the same droplet. Beyond 10, separate Temporal to its own instance.
Does it work without Temporal?You can use a simple cron + Postgres as queue. It works until a restart fails and you understand why everyone uses Temporal/Inngest.
How do you handle patients who don't accept AI?At the start of the voice/WhatsApp channel they are informed and can say "I want to talk to a human" — the graph immediately transfers.
Is it more expensive than hiring a receptionist? By month 2 in mid-volume cases. With very small clinics (<200 visits/month) a part-time receptionist is still cheaper.
Next steps
Repo: github.com/numoru-ia/clinic-agents-langgraph. Includes prompt seeds, synthetic test data, vertical adaptation playbooks and operations guides (incident runbook, escalation, compliance). The next article in the series covers CI/CD evals on this kind of agent.