---
title: "AI Patient Recall Agent: Automated Reactivation of Overdue Patients"
description: "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."
canonical: https://callsphere.ai/blog/ai-patient-recall-agent-automated-reactivation-overdue-patients
category: "Learn Agentic AI"
tags: ["Patient Recall", "Healthcare AI", "Reactivation", "Dental Practice", "Python"]
author: "CallSphere Team"
published: 2026-03-17T00:00:00.000Z
updated: 2026-05-06T01:02:44.374Z
---

# 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.

```mermaid
flowchart LR
    CALLER(["Patient or Caregiver"])
    subgraph TEL["Telephony"]
        SIP["Twilio SIP and PSTN"]
    end
    subgraph BRAIN["Healthcare AI Agent"]
        STT["Streaming STT
Deepgram or Whisper"]
        NLU{"Intent and
Entity Extraction"}
        TOOLS["Tool Calls"]
        TTS["Streaming TTS
ElevenLabs or Rime"]
    end
    subgraph DATA["Live Data Plane"]
        CRM[("CRM and Notes")]
        CAL[("Calendar and
Schedule")]
        KB[("Knowledge Base
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
```

```python
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.

```python
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  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.

```python
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

---

Source: https://callsphere.ai/blog/ai-patient-recall-agent-automated-reactivation-overdue-patients
