DataLatte
AI Appointment Reminder Agent: Python Script That Cuts No-Shows by 40%
AI Automation

AI Appointment Reminder Agent: Python Script That Cuts No-Shows by 40%

June 13, 2026·Nataliia· 14 min read All posts
No-shows cost the average service business 15–20% of potential revenue. A hair salon with 80 appointments per week and a 15% no-show rate is losing 12 appointments weekly — at $80 average, that's nearly $960 per week in empty chair revenue.
The fix is not complicated. Automated reminders sent 48 hours and 2 hours before an appointment reduce no-shows by 35–45% in most service businesses. This article gives you a complete, working Python script to build this system yourself — no expensive software required.

What This Script Does

The script:
  1. Reads upcoming appointments from a CSV (or connects directly to your booking system API)
  2. Generates a personalized message for each client using AI (Groq's LLaMA 3.3)
  3. Sends SMS via Twilio OR email via Resend (or both)
  4. Logs every message sent so you don't double-send
  5. Runs on a schedule via cron or GitHub Actions — no server needed
Total cost to run: ~$5–15/month for Twilio SMS. Email via Resend is free up to 3,000 sends/month.

What You'll Need

  • Python 3.10+
  • A Twilio account (free trial gives $15 credit)
  • A Resend account (free up to 3,000 emails/month)
  • A Groq API key (free tier available)
  • Your appointment data in CSV format (or a booking system with an API)

The Complete Script

#!/usr/bin/env python3
"""
Appointment Reminder Agent
Sends AI-personalized SMS/email reminders 48h and 2h before appointments.
"""

import os
import csv
import json
import urllib.request
import urllib.parse
from datetime import datetime, timedelta
from pathlib import Path

# ── Configuration ─────────────────────────────────────────────────────────────
TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID", "")
TWILIO_AUTH_TOKEN  = os.environ.get("TWILIO_AUTH_TOKEN", "")
TWILIO_FROM_PHONE  = os.environ.get("TWILIO_FROM_PHONE", "")  # e.g. +15551234567

RESEND_API_KEY     = os.environ.get("RESEND_API_KEY", "")
FROM_EMAIL         = os.environ.get("FROM_EMAIL", "hello@yoursalon.com")
BUSINESS_NAME      = os.environ.get("BUSINESS_NAME", "Your Salon")

GROQ_API_KEY       = os.environ.get("GROQ_API_KEY", "")
GROQ_MODEL         = "llama-3.3-70b-versatile"

SENT_LOG_FILE      = "sent_reminders.json"
APPOINTMENTS_CSV   = "appointments.csv"

# ── Load sent log (avoid double-sending) ──────────────────────────────────────
def load_sent_log() -> dict:
    if Path(SENT_LOG_FILE).exists():
        return json.loads(Path(SENT_LOG_FILE).read_text())
    return {}

def save_sent_log(log: dict):
    Path(SENT_LOG_FILE).write_text(json.dumps(log, indent=2))

# ── Read appointments ─────────────────────────────────────────────────────────
def load_appointments() -> list[dict]:
    """
    CSV format expected:
    client_name, phone, email, service, appointment_datetime, stylist
    Example:
    Sarah Johnson, +15551234567, sarah@email.com, Balayage, 2026-06-15 14:00, Emma
    """
    appointments = []
    with open(APPOINTMENTS_CSV) as f:
        reader = csv.DictReader(f)
        for row in reader:
            row["appointment_datetime"] = datetime.strptime(
                row["appointment_datetime"].strip(), "%Y-%m-%d %H:%M"
            )
            appointments.append(row)
    return appointments

# ── AI message generation ─────────────────────────────────────────────────────
def generate_reminder_message(client: dict, hours_until: int) -> str:
    """Uses Groq to write a warm, personalized reminder."""
    
    urgency = "tomorrow" if hours_until >= 24 else "in about 2 hours"
    
    prompt = f"""Write a short, warm appointment reminder SMS for a {client['service']} appointment.

Client: {client['client_name'].split()[0]}  (use first name only)
Service: {client['service']}
Appointment: {urgency} at {client['appointment_datetime'].strftime('%I:%M %p')}
Stylist: {client.get('stylist', 'your stylist')}
Business: {BUSINESS_NAME}

Rules:
- Max 160 characters (one SMS)
- Friendly, not robotic
- Include the appointment time
- End with a simple confirmation ask: "Reply YES to confirm or call us to reschedule."
- No hashtags, no emojis, no exclamation spam
- Start directly with the message, no preamble"""

    payload = json.dumps({
        "model": GROQ_MODEL,
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": 100,
        "temperature": 0.5,
    }).encode()

    req = urllib.request.Request(
        "https://api.groq.com/openai/v1/chat/completions",
        data=payload,
        headers={
            "Authorization": f"Bearer {GROQ_API_KEY}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
            return data["choices"][0]["message"]["content"].strip()
    except Exception as e:
        # Fallback to template if AI call fails
        first_name = client['client_name'].split()[0]
        time_str = client['appointment_datetime'].strftime('%I:%M %p')
        return (
            f"Hi {first_name}, reminder: your {client['service']} is {urgency} "
            f"at {time_str} at {BUSINESS_NAME}. "
            f"Reply YES to confirm or call us to reschedule."
        )

# ── Send SMS via Twilio ───────────────────────────────────────────────────────
def send_sms(to_phone: str, message: str) -> bool:
    if not all([TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_PHONE]):
        print("  ⚠ Twilio credentials not set, skipping SMS")
        return False

    import base64
    auth = base64.b64encode(
        f"{TWILIO_ACCOUNT_SID}:{TWILIO_AUTH_TOKEN}".encode()
    ).decode()

    payload = urllib.parse.urlencode({
        "To": to_phone,
        "From": TWILIO_FROM_PHONE,
        "Body": message,
    }).encode()

    url = f"https://api.twilio.com/2010-04-01/Accounts/{TWILIO_ACCOUNT_SID}/Messages.json"
    req = urllib.request.Request(
        url, data=payload,
        headers={"Authorization": f"Basic {auth}", "Content-Type": "application/x-www-form-urlencoded"},
        method="POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            result = json.loads(resp.read())
            print(f"  ✓ SMS sent to {to_phone} (SID: {result.get('sid', '?')})")
            return True
    except Exception as e:
        print(f"  ✗ SMS failed: {e}")
        return False

# ── Send email via Resend ─────────────────────────────────────────────────────
def send_email(to_email: str, client_name: str, message: str, appointment: dict) -> bool:
    if not RESEND_API_KEY:
        print("  ⚠ Resend API key not set, skipping email")
        return False

    first_name = client_name.split()[0]
    appt_time = appointment["appointment_datetime"].strftime("%A, %B %-d at %I:%M %p")

    html_body = f"""
    <div style="font-family: Georgia, serif; max-width: 500px; margin: 0 auto; color: #333;">
      <h2 style="color: #5c3317;">Appointment Reminder — {BUSINESS_NAME}</h2>
      <p>Hi {first_name},</p>
      <p>{message}</p>
      <div style="background: #f9f5f0; border-left: 3px solid #5c3317; padding: 15px; margin: 20px 0;">
        <strong>Service:</strong> {appointment['service']}<br>
        <strong>Date & Time:</strong> {appt_time}<br>
        <strong>With:</strong> {appointment.get('stylist', 'your stylist')}
      </div>
      <p>Need to reschedule? Reply to this email or call us.</p>
      <p>See you soon,<br><strong>{BUSINESS_NAME}</strong></p>
    </div>
    """

    payload = json.dumps({
        "from": f"{BUSINESS_NAME} <{FROM_EMAIL}>",
        "to": [to_email],
        "subject": f"Reminder: Your {appointment['service']} appointment",
        "html": html_body,
    }).encode()

    req = urllib.request.Request(
        "https://api.resend.com/emails",
        data=payload,
        headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"},
        method="POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            result = json.loads(resp.read())
            print(f"  ✓ Email sent to {to_email} (ID: {result.get('id', '?')})")
            return True
    except Exception as e:
        print(f"  ✗ Email failed: {e}")
        return False

# ── Main loop ─────────────────────────────────────────────────────────────────
def main():
    now = datetime.now()
    sent_log = load_sent_log()
    appointments = load_appointments()

    for appt in appointments:
        appt_dt = appt["appointment_datetime"]
        hours_until = (appt_dt - now).total_seconds() / 3600
        client_id = f"{appt['client_name']}_{appt_dt.isoformat()}"

        # Only send at 48h window and 2h window
        is_48h_window = 46 <= hours_until <= 50
        is_2h_window  = 1 <= hours_until <= 3

        if not (is_48h_window or is_2h_window):
            continue

        window_key = "48h" if is_48h_window else "2h"
        log_key = f"{client_id}_{window_key}"

        if log_key in sent_log:
            print(f"Already sent {window_key} reminder to {appt['client_name']}, skipping")
            continue

        print(f"\nSending {window_key} reminder to {appt['client_name']}...")

        # Generate AI message
        message = generate_reminder_message(appt, hours_until=int(hours_until))
        print(f"  Message: {message[:80]}...")

        # Send via available channels
        sms_sent   = send_sms(appt["phone"], message) if appt.get("phone") else False
        email_sent = send_email(appt["email"], appt["client_name"], message, appt) if appt.get("email") else False

        if sms_sent or email_sent:
            sent_log[log_key] = {
                "sent_at": now.isoformat(),
                "channel": ("sms+email" if sms_sent and email_sent else ("sms" if sms_sent else "email")),
                "appointment": appt_dt.isoformat(),
            }

    save_sent_log(sent_log)
    print(f"\nDone. Processed {len(appointments)} appointments.")

if __name__ == "__main__":
    main()

Sample appointments.csv Format

client_name,phone,email,service,appointment_datetime,stylist
Sarah Johnson,+15551234567,sarah@email.com,Balayage,2026-06-15 14:00,Emma
Mike Torres,+15559876543,mike@email.com,Haircut & Beard,2026-06-15 15:30,James
Lisa Chen,,lisa.chen@gmail.com,Highlights,2026-06-16 11:00,Emma
Clients without a phone number get email only. Clients without email get SMS only. Clients with both get both.

Running It Automatically with GitHub Actions

Create .github/workflows/appointment-reminders.yml:
name: Appointment Reminders

on:
  schedule:
    - cron: '0 * * * *'  # Runs every hour — catches both 48h and 2h windows

jobs:
  reminders:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Write appointments CSV
        run: echo '${{ secrets.APPOINTMENTS_CSV }}' > appointments.csv
      
      - name: Run reminders
        env:
          TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
          TWILIO_AUTH_TOKEN:  ${{ secrets.TWILIO_AUTH_TOKEN }}
          TWILIO_FROM_PHONE:  ${{ secrets.TWILIO_FROM_PHONE }}
          RESEND_API_KEY:     ${{ secrets.RESEND_API_KEY }}
          GROQ_API_KEY:       ${{ secrets.GROQ_API_KEY }}
          BUSINESS_NAME:      "DataLatte Salon"
          FROM_EMAIL:         "hello@yoursalon.com"
        run: python3 reminder_agent.py
Store your API keys as GitHub Secrets. The script runs every hour and sends reminders only when appointments fall in the 46–50h or 1–3h windows.

Connecting to a Real Booking System

If you use Vagaro, Booksy, or Square, replace load_appointments() with an API call to your booking system. Most platforms offer a REST API. Example for Square:
def load_appointments_from_square() -> list[dict]:
    """Pull upcoming appointments from Square Appointments API."""
    tomorrow = (datetime.now() + timedelta(days=3)).strftime("%Y-%m-%dT00:00:00Z")
    
    req = urllib.request.Request(
        f"https://connect.squareup.com/v2/bookings?start_at_min={tomorrow}",
        headers={
            "Authorization": f"Bearer {os.environ.get('SQUARE_ACCESS_TOKEN')}",
            "Square-Version": "2024-01-17",
        }
    )
    
    with urllib.request.urlopen(req) as resp:
        data = json.loads(resp.read())
    
    appointments = []
    for booking in data.get("bookings", []):
        # Map Square booking fields to our format
        appointments.append({
            "client_name": booking.get("customer_note", "Valued Client"),
            "phone": booking.get("seller_note", ""),
            "email": "",  # Pull from customer profile separately
            "service": booking["appointment_segments"][0].get("service_variation_id", "Appointment"),
            "appointment_datetime": datetime.fromisoformat(
                booking["start_at"].replace("Z", "+00:00")
            ),
            "stylist": "your stylist",
        })
    
    return appointments

What Happens After the Reminder

The script handles sending. What it doesn't handle: what happens when someone replies "NO" or "Reschedule."
For a basic setup, replies go to your Twilio phone number. You can set up a Twilio webhook to forward replies as emails or Slack messages. For more advanced handling, you'd build a second script that processes inbound messages and updates the appointment.
For most salons, the simple version works fine: send the reminder, monitor replies in Twilio, and handle reschedules manually. The no-show reduction alone is worth it.

Frequently Asked Questions

Q: Do I need to know Python to use this script?
You need to be comfortable with basic terminal commands — running a Python file, setting environment variables, and editing a CSV. You don't need to understand every line of code. The script is designed to be copy-pasted and configured through environment variables, not edited. If you get stuck, the most common issue is environment variables not being set correctly. Check that echo $TWILIO_ACCOUNT_SID returns your actual SID before running the script.
Q: How much does Twilio SMS actually cost?
Twilio charges $0.0079 per SMS sent in the US (under one cent per message). For a salon with 100 appointments per week, sending two reminders per appointment = 200 messages per week = about $1.60/week, or roughly $7/month. International SMS costs more — UK is about $0.04/message, Australia $0.05/message. Twilio gives you a $15 free trial credit, which covers roughly 1,900 US SMS messages to test with.
Q: What if a client's appointment is in a different timezone?
The script uses your server's local timezone. If you're running it via GitHub Actions (UTC), you need to store appointment times in UTC or add timezone conversion. Add import pytz at the top and convert: appt_dt = appt_dt.replace(tzinfo=pytz.timezone("America/Chicago")). Alternatively, store all datetimes in UTC in your CSV and display them in local time in the message by converting when generating the reminder text.
Q: Can I add a Google review request to the follow-up message?
Yes — add a third window to the script. After is_2h_window, add: is_followup_window = -26 <= hours_until <= -22 (24 hours after the appointment). In the main() loop, handle this window with a different message template that thanks the client and includes your Google review link. Use the same sent log pattern to avoid duplicate messages.
Q: What's the minimum setup to test this without any API keys?
Comment out the send_sms() and send_email() calls and replace them with print(message). Run python3 reminder_agent.py with a test CSV. You'll see the AI-generated messages in the terminal without sending anything. This costs nothing and lets you validate the message quality before connecting real channels.

Want More Local Customers?
Nataliia at DataLatte runs data-driven local marketing campaigns for local businesses — coffee shops, salons, pet groomers, and fitness studios. Book a free 30-minute strategy call or explore Google Ads management.

Free for local businesses

Want this applied to your business?

I'll review your Google presence, local SEO, and ad accounts — and send you a specific action plan within 48 hours. No pitch, no pressure.

Want hands-on help?

See how DataLatte handles AI Agents & Automation for local businesses.

Learn more
Nataliia — local marketing expert
Nataliia

Local marketing strategist with 10+ years at global agencies — OMD, Dentsu, GroupM, and BBDO. Now helping small businesses get the same data-driven edge. Based in Europe, working with clients in the US, UK, Australia, and beyond.

About Nataliia

Want this applied to your business?

Let's review your current marketing setup together — free, no obligations.

Get Your Free Marketing Audit