July 7, 2026
Patient Flow Automation for a Dental Clinic
A mid-sized dental clinic was managing its entire patient flow manually: WhatsApp inquiries, schedule availability checked by eye in Dentalink, appointment coordination handled over phone calls, and confirmations that relied on someone remembering to send a message. The outcome was predictable: duplicate bookings, unutilized slots, and patients lost simply because no one responded to them in time.
The objective of the project was to automate this workflow end-to-end without replacing the practice management software they already used—Dentalink. Everything runs on a Hetzner Cloud VPS, orchestrated with Docker Compose, and internal communication between services is secured via WireGuard tunnels.
The Stack and the Rationale for Each Component
The backend is a FastAPI application acting as the central orchestrator. It receives events, queries external APIs, decides how to respond, and maintains conversational state. I chose FastAPI because I needed native async support to handle multiple concurrent webhooks without blocking the event loop, and because its integration with Pydantic makes data validation from Chatwoot, WhatsApp, and Dentalink declarative and clean.
PostgreSQL stores persistent state: patient profiles with their WhatsApp numbers, appointment history, and interaction logs. The decision to use a relational database rather than something like MongoDB was pragmatic: the data has clear relational structure (a patient has many appointments, an appointment belongs to a practitioner and a clinic), and I needed referential integrity to prevent discrepancies between what our system records and what Dentalink holds.
Redis serves two distinct purposes. First, as an availability cache: when a patient is choosing between multiple appointment times, the system queries the Dentalink API once and caches available slots for 30 seconds. If the patient requests the same slots again, no redundant API call is made. Second, as a deferred job queue: 24-hour appointment reminders are scheduled using SETEX and TTL-based keys without needing an external job scheduler like Celery. Since Redis was already part of the stack for caching, leveraging it for reminders avoided introducing an additional dependency.
The most critical component of the system is the integration with Claude (Anthropic) through a hybrid router. The challenge of deploying an LLM in a production workflow like this is its tendency to hallucinate when given incomplete context—and in a healthcare setting, even an administrative error in scheduling or a false confirmation is unacceptable. The solution was to never let Claude decide everything unconditionally.
The Hybrid Router: Claude Doesn't Touch Deterministic Logic
The concept is inspired by this article. The core premise is that not every message needs to pass through an LLM. You classify the intent first, and route the message accordingly.
I implemented an endpoint in FastAPI that receives the patient message (already normalized from Chatwoot) and routes it through a lightweight classifier. No secondary model was needed for this: a concise Claude prompt with temperature=0 and a strict JSON output schema is sufficient to determine the route. The critical part is that the schema forces Claude to pick a category without room for creative improvisation:
from pydantic import BaseModel
from enum import Enum
class IntentType(str, Enum):
DETERMINISTIC = "deterministic"
SEMI_STRUCTURED = "semi_structured"
CONVERSATIONAL = "conversational"
class RouterDecision(BaseModel):
intent: IntentType
confidence: float
reasoning: str
async def classify_message(text: str) -> RouterDecision:
response = await claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=256,
temperature=0,
system=(
"Classify the patient's message into one of three categories. "
"DETERMINISTIC: schedule queries, existing appointment confirmation, "
"cancellations. SEMI_STRUCTURED: rescheduling, finding a specific doctor, "
"checking insurance coverage. CONVERSATIONAL: general questions, symptoms, "
"service information. Respond strictly with the JSON object."
),
messages=[{"role": "user", "content": text}],
)
return RouterDecision.model_validate_json(response.content[0].text)
Once classified, each route follows its own dedicated logic. If the intent is deterministic, Claude is not called again. We simply extract the date using regular expressions and query the Dentalink API directly. For "what times do you have open tomorrow?", you don't need a language model; you need a GET request to /citas filtered by date:
async def get_available_slots(date_str: str, professional_id: int | None = None):
params = {"fecha": date_str}
if professional_id:
params["id_profesional"] = professional_id
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.dentalink.healthatom.com/api/v1/citas/disponibles",
params=params,
headers={"Authorization": f"Token {DENTALINK_API_KEY}"},
)
response.raise_for_status()
return response.json()
If the intent is semi-structured, Claude participates using strictly defined tool calls rather than freeform text. We supply a list of available functions (query slots, book appointment, update appointment, cancel appointment), and the model decides which one to call and with what parameters. However, the execution itself is performed by FastAPI, not Claude. This prevents hallucinations: Claude cannot invent a slot that does not exist because it has no mechanism to fabricate API responses.
tools = [
{
"name": "get_available_slots",
"description": "Retrieves available appointment slots for a given date and practitioner.",
"input_schema": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "Date in YYYY-MM-DD format"},
"professional_id": {"type": "integer"},
},
"required": ["date"],
},
},
{
"name": "create_appointment",
"description": "Creates a new appointment in Dentalink.",
"input_schema": {
"type": "object",
"properties": {
"patient_id": {"type": "integer"},
"professional_id": {"type": "integer"},
"datetime": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["patient_id", "professional_id", "datetime"],
},
},
]
async def handle_semi_structured(patient_id: int, message: str, history: list):
response = await claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system=(
"You are an administrative assistant at a dental clinic. "
"Use the available tools to help the patient schedule, "
"reschedule, or cancel appointments. Never fabricate availability. "
"If no slots are available, state so clearly and offer alternatives."
),
messages=history + [{"role": "user", "content": message}],
tools=tools,
)
# Process tool_use blocks without emitting hallucinated text to the patient
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = await execute_tool(block.name, block.input)
tool_results.append({
"tool_use_id": block.id,
"content": json.dumps(result),
"type": "tool_result",
})
# Second pass: Claude generates the patient-facing response
# grounded strictly in verified data, not assumptions
final_response = await claude_client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=512,
messages=history + [
{"role": "user", "content": message},
{"role": "assistant", "content": response.content},
{"role": "user", "content": tool_results},
],
)
return final_response.content[0].text
If the intent is conversational, Claude responds in natural language addressing FAQs, general clinic recommendations, or service overviews—always appending an explicit disclaimer stating that automated responses do not replace professional medical evaluation.
This three-way architecture ensures the system is fast when possible (deterministic queries bypass the LLM and return in milliseconds), safe when necessary (appointment operations pass through Claude with bounded tool schemas), and conversational when it adds genuine value (open-ended inquiries leverage Claude's contextual comprehension).
WhatsApp and Chatwoot: Ingestion and Control
The patient never leaves WhatsApp. The clinic's phone number is connected via the Meta WhatsApp Business API, using a webhook that routes incoming messages directly to Chatwoot. Webhook configuration in Meta is straightforward: you register an endpoint on your server subscribed to messages events and verify the webhook using the verification token Meta provides.
Why place Chatwoot in the middle instead of connecting FastAPI directly to the WhatsApp webhook? For three practical reasons: First, Chatwoot out-of-the-box handles session lifecycle management: reconnections, rate limits, message templates, and read receipts. Implementing that from scratch would take weeks of engineering for a solved problem. Second, Chatwoot allows seamless human handoff: if the automated system cannot resolve an issue, a staff member can claim the conversation directly from the Chatwoot dashboard without the patient noticing any friction. Third, Chatwoot provides clean, bidirectional webhooks: when a new message arrives, Chatwoot dispatches a POST request to FastAPI with the message content and contact metadata; once FastAPI produces a reply, it sends it back to Chatwoot, which handles delivery to WhatsApp.
The FastAPI webhook receiving messages from Chatwoot looks like this:
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhook/chatwoot")
async def chatwoot_webhook(request: Request):
body = await request.json()
# Chatwoot sends events as message_created or message_updated
event = body.get("event")
if event != "message_created":
return {"status": "ignored"}
message_type = body.get("message_type")
if message_type != "incoming":
return {"status": "ignored"}
conversation_id = body.get("conversation", {}).get("id")
contact_phone = body.get("sender", {}).get("phone_number")
content = body.get("content")
if not all([conversation_id, contact_phone, content]):
raise HTTPException(status_code=400, detail="Missing fields")
# Fetch or register the patient record in PostgreSQL
patient = await get_or_create_patient(contact_phone)
# Retrieve recent conversation history
history = await get_conversation_history(conversation_id)
# Classify intent and generate response
response_text = await process_message(
patient_id=patient.id,
message=content,
history=history,
)
# Dispatch reply back to Chatwoot
await send_chatwoot_message(conversation_id, response_text)
return {"status": "ok"}
Sending the response back to Chatwoot is a simple POST request to its API, authenticated via an agent token configured in the dashboard:
async def send_chatwoot_message(conversation_id: int, text: str):
async with httpx.AsyncClient() as client:
await client.post(
f"https://{CHATWOOT_HOST}/api/v1/accounts/{ACCOUNT_ID}/"
f"conversations/{conversation_id}/messages",
headers={"api_access_token": CHATWOOT_AGENT_TOKEN},
json={"content": text, "message_type": "outgoing"},
)
On WhatsApp, we also utilize WhatsApp Flows, enabling interactive embedded forms directly within the chat for date and time selection and patient detail confirmation. The patient taps structured options rather than typing free text, eliminating ambiguity and ensuring the hybrid router almost always selects the deterministic path—the fastest and most cost-effective route.
Dentalink as the Single Source of Truth
A foundational design principle was that Dentalink remains the canonical system of record. The project does not replace Dentalink; it integrates with it. Appointments are booked, modified, and cancelled exclusively through its REST API. PostgreSQL maintains a local copy of appointment history for fast lookups and to avoid hard dependencies on Dentalink's availability for every read request, but all writes hit Dentalink first, persisting locally only upon confirmed success.
This guarantees that if the automated system goes down, clinic staff can continue operating manually in Dentalink without data loss. The automation is an orchestration layer, not a replacement. Integration with Dentalink uses token authentication, and all calls implement exponential backoff to handle temporary upstream blips:
import asyncio
async def create_dentalink_appointment(
patient_id: int,
professional_id: int,
start_time: str,
reason: str,
max_retries: int = 3,
):
payload = {
"id_paciente": patient_id,
"id_profesional": professional_id,
"fecha_hora_inicio": start_time,
"motivo": reason,
}
for attempt in range(max_retries):
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.dentalink.healthatom.com/api/v1/citas",
json=payload,
headers={"Authorization": f"Token {DENTALINK_API_KEY}"},
timeout=15.0,
)
if response.status_code == 201:
return response.json()
if response.status_code >= 500:
# Upstream server error; retry with exponential backoff
await asyncio.sleep(2 ** attempt)
continue
# Client errors (400, 409, etc.); do not retry
response.raise_for_status()
raise Exception(f"Failed to create appointment after {max_retries} attempts")
Automated Reminders with Redis
Automated appointment reminders 24 hours in advance are implemented using Redis, taking advantage of the instance already running for slot caching. When an appointment is confirmed in Dentalink, the reminder timestamp is calculated and stored in a Redis sorted set where the score represents the Unix timestamp:
import time
from datetime import datetime, timedelta
async def schedule_reminder(appointment_id: int, appointment_time: str):
appointment_dt = datetime.fromisoformat(appointment_time)
reminder_dt = appointment_dt - timedelta(hours=24)
reminder_ts = int(reminder_dt.timestamp())
redis_client.zadd(
"reminders:pending",
{str(appointment_id): reminder_ts},
)
A lightweight background worker, running as an async loop inside the FastAPI container, polls every 30 seconds for reminders whose scheduled time has elapsed. When matching entries are found, they are processed and removed from the sorted set. This architecture is deliberately simple: for the daily volume of a private clinic (dozens of appointments per day, not millions), this approach is resilient and avoids the overhead of a distributed job queue:
async def process_reminders():
while True:
now = int(time.time())
# ZRANGEBYSCORE with limit 10 to process in small batches
due = redis_client.zrangebyscore(
"reminders:pending", 0, now, start=0, num=10
)
for appointment_id in due:
appointment = await get_appointment(int(appointment_id))
patient = await get_patient(appointment["patient_id"])
text = (
f"Hello {patient['first_name']}, this is a reminder that you "
f"have an appointment tomorrow at {appointment['time']}. "
f"Reply CONFIRM to confirm your attendance."
)
await send_chatwoot_message(
patient["conversation_id"], text
)
redis_client.zrem("reminders:pending", appointment_id)
await asyncio.sleep(30)
Deployment on Hetzner with Docker and WireGuard
The complete architecture is deployed via Docker Compose onto a CX22 VPS on Hetzner Cloud (2 vCPUs, 4 GB RAM). The docker-compose.yml orchestrates five containers: FastAPI, Chatwoot, PostgreSQL, Redis, and a WireGuard container creating the private virtual overlay network.
WireGuard serves two key functions. First, it connects the local development environment with the production VPS without exposing PostgreSQL or Redis ports to the public internet: the tunnel maps 10.0.0.1:5432 on the local machine directly to postgres:5432 on the server. Second, if additional clinics join the infrastructure in the future, each can maintain its own isolated tunnel without the complexity of traditional VPN overhead. WireGuard configuration remains lean: a single config file specifying the server's private key, peer public keys, and tunnel IP ranges.
The administrative frontend is built with TypeScript and React (Next.js). While not the primary focus of the system, it allows clinic personnel to monitor active conversations in real time, inspect conversion metrics, and take over chats via Chatwoot whenever human escalation is required.
What the System Does Not Do (and Shouldn't Do)
There is a strict boundary the system never crosses: medical diagnosis. If a patient writes "my tooth hurts", Claude provides general scheduling assistance and advises booking a clinical consultation, but never suggests a medical diagnosis or treatment plan. Disclaimers are explicitly attached to every conversational response, and the hybrid router ensures that deterministic and semi-structured paths—which interact with clinic data—never emit unvetted free text.
Nor does the system replace administrative staff. Its purpose is to absorb repetitive booking workflows so clinical coordinators can focus on tasks that demand human presence: welcoming patients, navigating nuanced edge cases, and elevating in-clinic care. The Chatwoot dashboard remains immediately accessible for staff intervention whenever edge cases arise, and handoffs remain completely seamless to the patient.