By Sagar Shankaran, Founder of CallSphere
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.
Key takeaways
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.
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.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent for dental practice in your browser — 60 seconds, no signup.
flowchart LR
CALLER(["Patient or Caregiver"])
subgraph TEL["Telephony"]
SIP["Twilio SIP and PSTN"]
end
subgraph BRAIN["Healthcare AI Agent"]
STT["Streaming STT<br/>Deepgram or Whisper"]
NLU{"Intent and<br/>Entity Extraction"}
TOOLS["Tool Calls"]
TTS["Streaming TTS<br/>ElevenLabs or Rime"]
end
subgraph DATA["Live Data Plane"]
CRM[("CRM and Notes")]
CAL[("Calendar and<br/>Schedule")]
KB[("Knowledge Base<br/>and Policies")]
end
subgraph OUT["Outcomes"]
O1(["Appointment booked"])
O2(["Prescription refill request"])
O3(["Triage to clinician"])
end
CALLER --> SIP --> STT --> NLU
NLU -->|Lookup| TOOLS
TOOLS <--> CRM
TOOLS <--> CAL
TOOLS <--> KB
NLU --> TTS --> SIP --> CALLER
NLU -->|Resolved| O1
NLU -->|Schedule| O2
NLU -->|Escalate| O3
style CALLER fill:#f1f5f9,stroke:#64748b,color:#0f172a
style NLU fill:#4f46e5,stroke:#4338ca,color:#fff
style O1 fill:#059669,stroke:#047857,color:#fff
style O2 fill:#0ea5e9,stroke:#0369a1,color:#fff
style O3 fill:#f59e0b,stroke:#d97706,color:#1f2937
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)
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])
The agent tracks which patients actually book after receiving recall messages. This data feeds back into optimizing the sequence timing and messaging.
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
),
}
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']}%)"
)
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.
Still reading? Stop comparing — try CallSphere live.
See the dental practice AI agent handle a real call — complete, industry-specific, and live in your browser. No signup.
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.
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

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
Step-by-step build of a working agent with the OpenAI Agents SDK — Agent class, tools, handoffs, tracing — plus an eval pipeline that catches regressions before merge.
AWS HealthScribe became the open scribe layer EHR vendors built on top of in 2026. Here's the API surface, the per-encounter pricing, the BAA terms.
Apollo, Manipal, and Narayana scaled AI agents across Bangalore in 2026. Here's the deployments across radiology, intake, and follow-up, the costs.
Notable's AI agents now handle scheduling, intake, and revenue cycle for 6,000+ clinics in 2026. Here's the multi-agent architecture, the per-clinic pricing.
Abridge raised $250M in April 2026 at a $2.7B valuation. We break down the deployment numbers, the EHR integrations across Epic and Cerner. The Q2 2026 buyer briefing.
Enterprise CIO Guide perspective on Hippocratic AI's deployment numbers show healthcare voice agents are moving from pilot to production across major US health systems.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.