Skip to content
Learn Agentic AI13 min read0 views

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

Share this article
C

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.