Part 3 of 7 · Appointment reminder series ~5 min read

How the reminder knows when to send

Once an hour, an EventBridge Scheduler rule fires the reminder Lambda. The Lambda reads the list, looks at one appointment at a time, computes the hours remaining, and decides whether to do nothing or to send a reminder — and if so, which kind. The whole decision is plain Python. No model. No vector retrieval. Every threshold lives in the rules doc, where the front desk can edit it without a deploy.

Key takeaways

  • The reminder runs once an hour via EventBridge Scheduler so it can catch tight 2-hour and 1-hour windows.
  • Per-service schedules live in the rules doc — a dental visit gets 48h/2h, a salon gets 24h/1h, a long consult gets 72h/24h/3h.
  • Four moves per appointment, every tick: scheduled, first reminder, second reminder, gap alert.
  • DynamoDB tracks last-send and confirm per appointment so reminders aren’t duplicate spam.
  • The reminder itself never calls a model. The decision is entirely deterministic.

The decision flow, per appointment

Decision flow per appointment on every hourly tick A vertical decision flow diagram. At the top, an input box "Appointment from list" with the row's customer, service, date and time, staff member, and DynamoDB state. Below that, a step "Compute hours_to_appt" — the appointment time in the configured timezone minus the current time. Below that, a check "Already confirmed or cancelled?" — if yes, route to "Scheduled" (do nothing this tick). If no, continue. The next step "Look up reminder schedule for service" — pulls the schedule from the rules doc; for example a dental cleaning returns [48, 2] hours. The next step "Find smallest window threshold not yet crossed" — iterates the schedule and picks the first window where hours_to_appt equals the threshold or fell below it since the last tick. If none and the appointment is still far out, route to "Scheduled." If the appointment is very close and still unconfirmed, route to "Gap alert." Otherwise look at the DynamoDB state to see if a reminder has already gone out for this window. If no reminder yet for this window, route to "First reminder" (or "Second reminder" if an earlier window already sent). Each terminal box — Scheduled, First reminder, Second reminder, Gap alert — emits an event to EventBridge with the move and the appointment context. A note at the bottom: the rules doc holds every threshold; the reminder's code only enforces them — change a window in the doc and the next hourly tick uses the new value. Appointment from list customer · service · time · staff Step 1 Compute hours_to_appt appt_time (TZ) − now Step 2 Confirmed or cancelled? read DDB ar-replies table Step 3 Look up reminder schedule e.g. dental → [48, 2] hours Step 4 Smallest window crossed? none → scheduled close + unconfirmed → gap Step 5 Already sent this window? read DDB ar-sends table Scheduled do nothing First reminder first window crossed Second reminder later window Gap alert close, no confirm if yes none gap first later The rules doc holds every threshold — change a window and the next hourly tick uses it.
Fig 3. The reminder’s decision tree, per appointment, per hourly tick. Five steps decide which of four moves applies. The rules doc holds every threshold; the reminder only enforces them.

Reminder schedules: 48/2 isn’t magic, it’s in the doc

The rules doc has one short section per service. Each section names the schedule in plain prose: “Dental cleaning: remind at 48 and 2 hours. Salon haircut: 24 and 1 hour. New-patient consult: 72, 24, and 3 hours. Quick follow-up: 24 hours only.” The numbers are hours remaining when the reminder fires. The first number is the early heads-up. The last number is the same-day nudge — the “see you soon” text that catches the customer who forgot.

The schedules exist for a reason. A 48-hour dental reminder gives the customer time to reschedule if the day no longer works, which turns a no-show into a filled slot. A 1-hour salon nudge is the last-chance reminder for a walk-in-style appointment people book on impulse and forget. A 72-hour consult reminder respects that a longer, more valuable appointment deserves more notice. Different services have different rhythms; the schedules reflect that.

Per-appointment overrides exist too. The list sheet has an optional column called schedule_override. Type a comma-separated list of hours there and the reminder uses your numbers instead of the service default for that one row. This is the right escape hatch for the VIP client who asked to be reminded the night before, or the first-timer you want to nudge twice.

Four moves, always

Every appointment, every tick, lands in exactly one of four buckets. The names are simple on purpose.

  • Scheduled. The appointment is more than the first window away, or it’s already confirmed or cancelled. Do nothing. Most appointments, most hours, are scheduled.
  • First reminder. The appointment just crossed the first window threshold and there’s no reply yet. Send a fresh reminder with full context. Write a row to the ar-sends DynamoDB table marking that the first window has fired.
  • Second reminder. A later window crossed without a confirm. Send a closer-to-the-time nudge that’s shorter and friendlier — “See you at 9 this morning.” Write the new send to ar-sends.
  • Gap alert. The customer cancelled, or the appointment is only an hour or two away and still completely unconfirmed. Tell the front desk, in their Slack, so they can call the customer or offer the slot to someone on the waitlist. A gap alert goes to staff, not the customer — it’s the system’s way of saying “this chair might be empty; do something about it now.”

State that makes the decision deterministic

The reminder reads two DynamoDB tables every tick. ar-sends records every reminder that’s gone out: (appt_id, window_index, sent_at, channel). ar-replies records every reply: (appt_id, replied_at, action). With those two tables, the move-decision logic is a few dozen lines of Python and zero magic. A given appointment with a given time, a given reminder schedule, and a given send/reply history always produces the same move. Re-running the tick produces no extra texts (because the state in DDB shows what already fired).

Rescheduling an appointment is an explicit reset of both tables for that booking: rows for the old time are kept for audit, but a new chain starts fresh against the new appointment time. Part 5 covers the reschedule flow in detail.

Why the hourly tick uses no model

The reminder could call a model on the tick to write a smarter message, or to decide whether to send at all. It doesn’t. Two reasons. First, the tick should be the one part of the system that is utterly predictable — if the rules doc says remind at 48 hours and there’s no confirm, the text fires. A model in that loop introduces variance the front desk can’t reason about. Second, model calls cost money, and most hours most appointments are scheduled, so the call would be wasted again and again.

Bedrock fires elsewhere — on the inbound parsing lane in Part 2, and on the weekly staff summary mentioned in Part 6. Not on the hourly tick. The reminder itself is plain Python that reads a doc and writes events.

Next post: how a reminder reaches the customer, how quiet hours and holidays are honored, and what a confirm actually does to the chain.

All posts