Building an Attended SMS Confirmation Pattern for Lead Management: Why Asynchronous Handoff Beats Unattended Automation
When you're managing a booking system that touches real customers and real crew schedules, the stakes of automation are high. This post walks through a specific architectural decision we made while building out JADA's lead-response pipeline: why we chose an attended confirmation pattern over fully unattended SMS automation, and how the tooling supports both modes safely.
The Problem Statement
We have warm leads coming in through GetMyBoat's messaging system. Each lead requires:
- An immediate acknowledgment (SMS) to the customer
- A drafted proposal synthesized from historical proposals sent to similar charterers
- A human review step before any confirmation goes to crew
The naive approach—fully automated SMS fire-and-forget—creates friction later: if the text is malformed, the customer details are wrong, or the proposal context is stale, you can't undo an SMS. We needed a pattern that was fast (single tap to send) but kept a human in the loop before the outbound message leaves.
Architecture: The Attended Handoff Pattern
Here's the high-level flow:
Lead arrives via GetMyBoat chat
↓
Monitor polls Messages chat.db for new entries
↓
System fetches charter context, historical proposals (DynamoDB)
↓
Draft text + proposal cached in session memory
↓
Human reviews cached text in terminal/UI
↓
Human one-tap confirms send
↓
send-sms executable fires SMS via AppleScript/iMessage
↓
Confirmation logged to crew roster, proposal filed
The key insight: the expensive work (fetching proposals, synthesizing context, checking quiet hours) happens before the human review. The human's job is to glance at a 50-character text and say yes or no. That's a 2-second decision, not a 2-minute one.
Technical Implementation: Session Memory and Polling
We store session state in a markdown-based memory file at /Users/cb/.claude/projects/-Users-cb/memory/jada-getmyboat-lead-automation.md. This file holds:
- Cached charter data (date, role, customer name, phone)
- Draft SMS text (respecting quiet hours: no texts before 08:00 or after 17:00)
- Synthesized proposal (merged from historical proposals in iCloud's
jada-ops/proposals/) - Confirmation state (pending, approved, sent)
The polling mechanism is designed as a low-frequency check on the GetMyBoat Messages database. Rather than hammering the API, we read the local SQLite copy at ~/Library/Messages/chat.db (on macOS), which GetMyBoat syncs to. The Playwright browser automation handles the settings sync to ensure notifications route to jadasailing@gmail.com.
Example readout after polling detects a new lead:
## Lead: Travis [2024-01-15]
- Phone: +1-619-XXXXXX
- Charter: 4-hour sunset, Jan 20, 2024
- Role: Captain
- Call time: 17:00
Draft SMS (quiet-hours compliant):
"Hi Travis, thanks for the inquiry! We received your message.
A proposal is being prepared—you'll hear back by EOD tomorrow."
Synthesis source:
- Dylan proposal (2024-01-12): tone, terms
- Molly proposal (2024-01-08): pricing structure
- Noelle proposal (2024-01-10): call-to-action closing
Status: READY_FOR_REVIEW
Action: [Send] [Edit] [Reject]
Key Decision: Quiet Hours and Timing Constraints
SMS at odd hours erodes trust and causes support friction. We implemented a hard rule: no SMS before 08:00 or after 17:00. The logic lives in the cache step:
if current_hour < 8 or current_hour >= 17:
cache_draft_text()
status = "QUEUED_FOR_MORNING"
# Next morning after 08:00, human returns and says "send Travis"
else:
cache_draft_text()
status = "READY_FOR_REVIEW" # Show immediately
This removes a whole class of bugs: we can't accidentally send an SMS in the middle of the night, even if a human frantically runs the script at 23:00. The message just waits in the cache until morning.
Integration Points: Where Data Lives
JADA's data is deliberately scattered across multiple systems, and the lead monitor has to stitch it together:
- Messages chat.db (
~/Library/Messages/chat.db): Raw GetMyBoat incoming messages, synced via iMessage bridge. - DynamoDB (AWS): Charter roster, revenue, crew assignments—accessed via Lambda credentials in
creds.txt. - iCloud jada-ops/: Historical proposals for Dylan, Molly, Noelle (synthesis source).
- EC2 repos (~repos/): JADA heritage documentation, pricing logic, brand voice.
- Stripe integration (shipcaptaincrew Lambda): Magic-link generation for proposal sign-off.
The monitor consolidates reads from all of these, but it never writes to them automatically. All writes (proposal filed, charter booked) are human-triggered.
Why Not Full Automation?
The original instinct was to set up a scheduled agent that fires SMS and proposal emails unattended. Here's why we rejected that:
- Irreversibility. Once an SMS leaves, it's gone. An email can be retracted; an SMS cannot.
- Debugging blindness. If something goes wrong (malformed number, stale proposal context), you find out from the customer, not from logs.
- Quiet hours are cultural. Texting someone at 02:00 is worse than texting them late—it signals you don't respect their sleep. This rule needs a human to guard it, not just code.
- Single-tap approval is nearly as fast. If the human already glanced at the draft (2 seconds), they're 1 tap away from send. That's functionally instant.
For a warm lead that can wait until morning, attended automation is the right trade-off.
Tooling: send-sms Executable and Playwright
Two tools power the outbound flow:
- send-sms: A macOS-native executable that triggers AppleScript and sends via iMessage. Verified in place; no dependencies on cloud SMS APIs (Twilio, etc.). Keeps carrier costs down and message source trusted.
- Playwright: Used to configure GetMyBoat notification settings, routing incoming alerts to
jadasailing@gmail.cominstead of scattered inboxes. Also used to fetch proposal