AI Patient Recall Agent: Automated Reactivation of Overdue Patients
Build an AI agent that identifies overdue patients, runs multi-step communication sequences to bring them back, and tracks reactivation success rates with real Python implementation code.
The Cost of Lost Patients
A typical dental practice loses 15 to 20 percent of its active patient base each year simply because patients fall off the recall schedule. Each lost patient represents thousands of dollars in lifetime value. Manual recall efforts — calling down a list — are time-consuming and inconsistent.
An AI patient recall agent solves this by continuously scanning for overdue patients, launching personalized outreach sequences, and tracking which messages actually bring patients back.
Identifying Overdue Patients
The first step is defining what "overdue" means. Most practices set recall intervals based on the type of visit: six months for cleanings, twelve months for comprehensive exams. The agent queries the database to find patients who have exceeded their recall window.
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Optional
from enum import Enum
class RecallInterval(Enum):
CLEANING = 180 # 6 months
COMPREHENSIVE = 365 # 12 months
PERIO = 90 # 3 months for periodontal patients
PEDIATRIC = 180 # 6 months
@dataclass
class OverduePatient:
patient_id: str
name: str
phone: str
email: str
last_visit_date: date
last_visit_type: str
days_overdue: int
recall_attempts: int
preferred_contact: str
class OverdueDetector:
def __init__(self, db):
self.db = db
async def find_overdue_patients(
self, practice_id: str, min_days_overdue: int = 0,
) -> list[OverduePatient]:
rows = await self.db.fetch("""
WITH last_visits AS (
SELECT
p.id, p.first_name || ' ' || p.last_name AS name,
p.phone, p.email, p.preferred_contact,
MAX(a.start_time::date) AS last_visit,
a.type AS visit_type,
COALESCE(r.attempt_count, 0) AS attempts
FROM patients p
JOIN appointments a ON a.patient_id = p.id
LEFT JOIN recall_tracking r
ON r.patient_id = p.id
AND r.recall_cycle = DATE_PART(
'year', CURRENT_DATE
)
WHERE a.status = 'completed'
AND p.practice_id = $1
AND p.is_active = true
GROUP BY p.id, p.first_name, p.last_name,
p.phone, p.email, p.preferred_contact,
a.type, r.attempt_count
)
SELECT *, (CURRENT_DATE - last_visit) AS days_since
FROM last_visits
WHERE (CURRENT_DATE - last_visit) > $2
ORDER BY days_since DESC
""", practice_id, min_days_overdue)
overdue = []
for row in rows:
interval = self._get_interval(row["visit_type"])
days_overdue = row["days_since"] - interval
if days_overdue > 0:
overdue.append(OverduePatient(
patient_id=row["id"],
name=row["name"],
phone=row["phone"],
email=row["email"],
last_visit_date=row["last_visit"],
last_visit_type=row["visit_type"],
days_overdue=days_overdue,
recall_attempts=row["attempts"],
preferred_contact=row["preferred_contact"],
))
return overdue
def _get_interval(self, visit_type: str) -> int:
mapping = {
"cleaning": RecallInterval.CLEANING.value,
"comprehensive": RecallInterval.COMPREHENSIVE.value,
"perio_maintenance": RecallInterval.PERIO.value,
}
return mapping.get(visit_type, 180)
Multi-Step Communication Sequences
A single reminder rarely works. The recall agent runs a sequence of escalating outreach steps, starting gentle and increasing urgency. Each step uses the patient's preferred communication channel.
from datetime import datetime
@dataclass
class RecallStep:
step_number: int
channel: str # "sms", "email", "phone"
delay_days: int # days after previous step
template: str
is_final: bool = False
DEFAULT_SEQUENCE = [
RecallStep(1, "sms", 0, "friendly_reminder",),
RecallStep(2, "email", 3, "value_reminder"),
RecallStep(3, "sms", 7, "urgency_reminder"),
RecallStep(4, "phone", 14, "personal_call", is_final=True),
]
class RecallSequencer:
def __init__(self, db, sms_client, email_client):
self.db = db
self.sms = sms_client
self.email = email_client
self.templates = TemplateEngine()
async def run_sequence(
self, patient: OverduePatient,
sequence: list[RecallStep] = None,
):
sequence = sequence or DEFAULT_SEQUENCE
current_step = await self._get_current_step(
patient.patient_id
)
if current_step is None:
next_step = sequence[0]
else:
next_idx = current_step + 1
if next_idx >= len(sequence):
await self._mark_exhausted(patient.patient_id)
return
next_step = sequence[next_idx]
last_contact = await self._get_last_contact_date(
patient.patient_id
)
if last_contact:
days_since = (date.today() - last_contact).days
if days_since < next_step.delay_days:
return # not time yet
message = self.templates.render(
next_step.template,
patient_name=patient.name,
days_overdue=patient.days_overdue,
last_visit=patient.last_visit_date.isoformat(),
)
if next_step.channel == "sms":
await self.sms.send(patient.phone, message)
elif next_step.channel == "email":
await self.email.send(patient.email, message)
elif next_step.channel == "phone":
await self._queue_call_task(patient, message)
await self.db.execute("""
INSERT INTO recall_log
(patient_id, step_number, channel,
sent_at, message_preview)
VALUES ($1, $2, $3, $4, $5)
""", patient.patient_id, next_step.step_number,
next_step.channel, datetime.utcnow(),
message[:200])
Success Tracking and Analytics
The agent tracks which patients actually book after receiving recall messages. This data feeds back into optimizing the sequence timing and messaging.
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
class RecallAnalytics:
def __init__(self, db):
self.db = db
async def get_reactivation_rate(
self, practice_id: str, period_days: int = 90,
) -> dict:
stats = await self.db.fetchrow("""
SELECT
COUNT(DISTINCT rl.patient_id) AS contacted,
COUNT(DISTINCT CASE
WHEN a.id IS NOT NULL
THEN rl.patient_id
END) AS reactivated,
AVG(CASE
WHEN a.id IS NOT NULL
THEN rl.step_number
END) AS avg_steps_to_convert
FROM recall_log rl
JOIN patients p ON p.id = rl.patient_id
LEFT JOIN appointments a
ON a.patient_id = rl.patient_id
AND a.created_at > rl.sent_at
AND a.status IN ('scheduled', 'completed')
WHERE p.practice_id = $1
AND rl.sent_at > CURRENT_DATE - $2
""", practice_id, period_days)
contacted = stats["contacted"] or 0
reactivated = stats["reactivated"] or 0
return {
"contacted": contacted,
"reactivated": reactivated,
"rate": round(
reactivated / contacted * 100, 1
) if contacted > 0 else 0,
"avg_steps": round(
float(stats["avg_steps_to_convert"] or 0), 1
),
}
Running the Recall Agent on a Schedule
The agent runs as a background job, processing the overdue list daily and advancing each patient through their recall sequence.
import asyncio
class RecallAgent:
def __init__(self, db, sms_client, email_client):
self.detector = OverdueDetector(db)
self.sequencer = RecallSequencer(
db, sms_client, email_client
)
self.analytics = RecallAnalytics(db)
async def run_daily_recall(self, practice_id: str):
overdue = await self.detector.find_overdue_patients(
practice_id, min_days_overdue=7
)
for patient in overdue:
try:
await self.sequencer.run_sequence(patient)
except Exception as e:
print(
f"Recall failed for {patient.patient_id}: {e}"
)
stats = await self.analytics.get_reactivation_rate(
practice_id
)
print(
f"Recall stats: {stats['reactivated']}/"
f"{stats['contacted']} reactivated "
f"({stats['rate']}%)"
)
FAQ
How do you prevent the recall agent from contacting patients who have already scheduled an appointment?
The overdue detector query joins against the appointments table and only surfaces patients with no future scheduled appointments. The sequencer also checks for new bookings before each outreach step, so if a patient schedules between steps, the sequence stops automatically.
What is a good reactivation rate to aim for?
Industry benchmarks show that automated recall systems achieve 15 to 25 percent reactivation rates. Practices that combine SMS and email with a personal phone call at the final step tend to hit the higher end. The analytics module lets you compare rates across different sequence configurations to continuously improve.
How do you handle patients who explicitly ask to stop receiving recall messages?
The agent must respect opt-out requests. When a patient replies "STOP" to an SMS or clicks an unsubscribe link in an email, the system sets an opted_out flag on the patient record. The overdue detector filters out opted-out patients, and the sequencer checks this flag before every send.
#PatientRecall #HealthcareAI #Reactivation #DentalPractice #Python #AgenticAI #LearnAI #AIEngineering
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.