Engineering reference: the appointment reminder architecture
Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the SES inbound rule set, EventBridge Scheduler config, the DynamoDB schemas, and the SMS reply flow. Read alongside the previous six posts; this one’s the build sheet.
Region and account shape
Default region: ap-southeast-1 (Singapore). SES inbound, Bedrock Global cross-Region inference, EventBridge Scheduler, and SNS SMS are all in good shape there. A second region for multi-region resilience isn’t worth the extra setup work at SMB volume — the failure mode for an SMB is a reminder that goes out an hour late, not a regional outage. One AWS account dedicated to the reminder (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system. SMS origination identity (a sender ID or a long/short code, depending on the destination country’s rules) is registered in the SNS console and referenced by the dispatch Lambda.
Topology
Lambda functions
All Lambdas use the arm64 architecture, the smallest memory size that meets latency targets (typically 256 MB), Python 3.14 runtime, and CloudWatch Logs at 7-day retention. Each function has its own least-privilege IAM role. None run inside a VPC.
drive-sync— EventBridge Scheduler target, fires every few minutes (defaultrate(5 minutes); tighten only if your booking tool writes the sheet faster than the reminder needs). Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager underar/drive/sa) to export the appointment sheet as CSV and write tos3://ar-appointments-source/appointments.csvonly if the sheet has changed since the last sync. Same pattern syncs the rules and voice docs tos3://ar-rules-source/. Memory: 256 MB. Timeout: 30 s.calendar-sync— EventBridge Scheduler target, hourly. Uses the Google Calendar APIevents.listto scan configured calendars for events with#apptin the description; for any new events, creates a Slack interactive proposal message. For lower-latency setups you can switch toevents.watchand have Calendar push notifications to a Function URL instead of polling, at the cost of renewing the channel before it expires (Calendar push channels have a finite TTL and need a small refresh job). Memory: 256 MB. Timeout: 30 s.intake-ses-parser— S3 PUT trigger ons3://ar-raw-mime/. Parses MIME; if the booking confirmation is plain text it goes straight to the model, and if it carries a PDF or image attachment it runs Textract viaStartDocumentTextDetection(asynchronously to handle multi-page confirmations). On Textract completion (via SNS notification), reads the structured text and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0) to propose an appointment row. Posts the proposal to Slack via the bot token with Approve/Edit/Discard buttons. For DOCX attachments (Textract doesn’t accept them), falls back topython-docx; XLSX usesopenpyxl. Both packages are stable and widely used in 2026, though their maintenance velocity is light — for a parsing path that only runs a few times a month, that’s acceptable. Memory: 512 MB. Timeout: 60 s.reminder— EventBridge Scheduler target, hourly (the schedule expression runs inTZ_NAMEset to the SMB’s timezone, e.g.Asia/Singapore). Readss3://ar-appointments-source/appointments.csvand the rules and voice docs. For each row, computeshours_to_appt, reads chain state fromar-sendsandar-replies, decides on a move. Emits one event per row that needs action:ar.first_reminder,ar.second_reminder, orar.gap_alert, with the appointment context as the event payload. Scheduled (no-action) appointments emit nothing. Memory: 512 MB. Timeout: 60 s. No Bedrock calls.dispatch— EventBridge rule on the three move events. Picks the channel, checks quiet hours and holiday calendar, formats the message from the voice template, and ships via SNSPublish(SMS, with the registered origination identity), SESSendRawEmail(email fallback), or the Slack bot token (gap alert to staff). On quiet-hours or holiday defer, creates a one-off EventBridge Scheduler rule that re-invokesdispatchat the next allowed minute. Writes a row toar-sendsafter a successful send. Memory: 256 MB. Timeout: 30 s.reply-handler— Lambda Function URL, public withAuthType: NONE; each reply link carries a signed, single-use token (HMAC overappt_id+ action + nonce, secret in Secrets Manager) so a guessed URL can’t confirm or cancel someone else’s appointment. Triggered by customer taps on the Confirm/Reschedule/Cancel links. Writes toar-repliesandar-audit; on reschedule, updates the Drive sheet via the Sheets API and archives the old chain inar-sends-archive; on cancel, frees the slot and posts a gap alert to Slack. Renders a tiny HTML confirmation page. Memory: 256 MB. Timeout: 15 s.summary— EventBridge Scheduler target, weekly Monday 8am. Reads the past week’sar-sends,ar-replies, andar-audit; calls Bedrock Haiku 4.5 to write a short staff narrative (confirms, no-shows prevented, slots freed, customers who went quiet); posts it to a configured Slack channel and emails it via SES to the owner. Memory: 512 MB.
Storage
- DynamoDB ·
ar-sends— one row per dispatch. PK(appt_id, window_index); attributes:sent_at,channel(sms/email/slack),recipient,move(first_reminder/second_reminder/gap_alert). On-demand. No TTL. - DynamoDB ·
ar-replies— one row per reply. PKappt_id; sort keyreplied_at; attributes:action(confirm/reschedule/cancel),channel,old_time,new_time(if action = reschedule). On-demand. The reminder reads this table for the “already confirmed or cancelled?” short-circuit. - DynamoDB ·
ar-audit— one row per write action of any kind. PK(appt_id, ts); attributes:action,channel,before,after. On-demand. No TTL — this is the long-term audit trail. - DynamoDB ·
ar-sends-archive— archived chains after a reschedule. Same shape asar-sends; PK(appt_id, chain_id, window_index). On-demand. - S3 ·
ar-appointments-source— mirrored CSV from the Drive appointment sheet. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 2 years. - S3 ·
ar-rules-source— mirrored rules and voice docs as plain text. Versioning enabled. - S3 ·
ar-raw-mime— raw inbound MIME from forwarded booking emails. Lifecycle to Glacier at 30 days; expiry at 1 year.
Bedrock
- Foundation model.
anthropic.claude-haiku-4-5-20251001-v1:0via the Global cross-Region inference profileglobal.anthropic.claude-haiku-4-5-20251001-v1:0. Two callsites:intake-ses-parserfor the inbound booking parsing, andsummaryfor the weekly staff narrative. The hourly tick never touches Bedrock; if you ever wanted a model to draft a warmer same-day message, Haiku 4.5 is the right tier and Sonnet 4.6 would be overkill. - Embeddings. Not used. The list is structured rows; deterministic lookup beats vector retrieval here. No Knowledge Base, no S3 Vectors.
- Quotas. Default account quotas are more than enough at SMB volume. The reminder itself doesn’t call Bedrock; the parsing lane fires a few times a month at most.
EventBridge Scheduler config
ar-hourly-tick—rate(1 hour)in the SMB’s timezone. Target:reminderLambda.ar-drive-sync—rate(5 minutes). Target:drive-syncLambda.ar-calendar-sync—rate(1 hour). Target:calendar-syncLambda.ar-weekly-summary—cron(0 8 ? * MON *)in TZ. Target:summaryLambda.- One-off rules — created on the fly by
dispatchwhen a quiet-hours or holiday defer is needed. Useat(YYYY-MM-DDTHH:MM:SS)expressions with--action-after-completion DELETEso the rule self-cleans.
SNS SMS, SES inbound and outbound
- SNS SMS. Register an origination identity for each destination country in the SNS console (sender ID where allowed, otherwise a long or short code; some countries require pre-registration). Set the SMS type to
Transactionalfor delivery priority, and set a monthly SMS spend limit in SNS as a hard backstop under the Budgets alarm. The dispatch Lambda callssns:Publishwith the customer’s E.164 phone number. - Set the MX record on a dedicated subdomain (e.g.
bookings.your-company.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
ar-inbound-rules: one rule with recipientbookings@your-company.com→ spam scan → S3 PUT tos3://ar-raw-mime/<message-id>→ stop. The S3 PUT triggersintake-ses-parser. - SES outbound for the email-fallback reminders: verify a sender identity at
appointments@your-company.comwith DKIM and SPF on the parent domain. Out of sandbox by request.
IAM (least privilege per Lambda)
Each Lambda has its own role with policies scoped to exact ARNs. Sketch:
- reminder role:
s3:GetObjecton the appointments, rules, and voice keys;dynamodb:Query+GetItemonar-sends,ar-replies;events:PutEventson the default bus. Nobedrock:*. - dispatch role:
events:ListSchedules+CreateSchedulefor the deferred-send one-offs;sns:Publishfor SMS with the registered origination identity;secretsmanager:GetSecretValueon the Slack bot-token secret;ses:SendRawEmailfrom the verified sender identity;dynamodb:PutItemonar-sends; outbound network access tohooks.slack.com. - reply-handler role:
dynamodb:PutItemonar-repliesandar-audit;secretsmanager:GetSecretValueon the Sheets-API service-account secret and the reply-token HMAC secret; outbound network access tosheets.googleapis.com;dynamodb:Queryfor chain-state lookup; on reschedule,dynamodb:BatchWriteItemfor archiving the old chain toar-sends-archive. - intake-ses-parser role:
s3:GetObjectonar-raw-mime;textract:StartDocumentTextDetection;bedrock:InvokeModelon the Haiku ARN;secretsmanager:GetSecretValueon the Slack bot token. - drive-sync and calendar-sync roles:
secretsmanager:GetSecretValueon the Google service-account secret;s3:PutObjecton the appointments and rules buckets; outbound network towww.googleapis.com.
Reply-link token flow
The one-tap links are public URLs, so each one carries a signed, single-use token rather than a raw appointment id. The token is an HMAC (key in Secrets Manager under ar/reply/hmac) over appt_id, the action (confirm/reschedule/cancel), and a per-send nonce, with a short expiry tied to the appointment time. reply-handler verifies the signature and the expiry, checks the nonce hasn’t already been spent (a single-use marker in ar-replies), then processes the action. A confirm or cancel is one tap and resolves immediately; a reschedule renders a slot-picker page that posts back to the same handler with a second token. This keeps the surface stateless and tamper-evident without standing up an auth system for customers who’ll never log in.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on
"error"+"throttle"+"timeout"to a CloudWatch metric for alerting. - Alarms: reminder Lambda failures > 0 in an hour (the tick is the one piece that has to run); SNS SMS delivery-failure rate > 5% in 24h (might mean an origination-identity or registration issue); reply-handler token-verification failures > 10/hour (might mean a leaked or rotated HMAC secret).
- X-Ray: off by default. Not worth the cost at SMB volume.
- AWS Budgets: $30/month threshold, alarm at 80% and 100%, posts to SNS topic
ar-cost-alarmsubscribed to the owner’s email and Slack. Back it with the SNS monthly SMS spend limit so a runaway loop can’t send unbounded texts.
Config and secrets
Service-account credentials for Drive, Sheets, and Calendar APIs all live in Secrets Manager under ar/drive/sa (one service account with scopes for all three APIs). The Slack bot token lives under ar/slack/bot-token. The reply-link HMAC key lives under ar/reply/hmac. SES sender identity lives in IAM and the verified-domain config; the SNS origination identity lives in the SNS config. The configured timezone, holiday list reference, quiet-hours window, per-service reminder schedules, and gap-alert Slack channel all live in Parameter Store under /ar/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.
Deploy
Whichever IaC you prefer. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for both ar-appointments-source and ar-rules-source so a bad Drive edit can be rolled back in one click, set the SNS monthly SMS spend limit explicitly so a bug can’t run up a text bill, and version the EventBridge Scheduler timezone setting so you don’t accidentally start running the hourly tick in UTC after a CI rotation. CDK with a Python stack file works well; SAM also fits. Total deployable surface: around seven Lambdas, four DDB tables, three S3 buckets, one EventBridge rule on the default bus (plus the Scheduler rules), one SES rule set, one SNS origination identity, and one Budgets alarm.
That’s the full system. Six narrative posts and this engineering reference. If you want to talk about adapting it for your business, see Work with me.
All posts