Building an Attended Async SMS Confirmation Flow for Crew Scheduling: Architecture & Implementation Notes
This post documents the technical decisions and implementation approach for a crew confirmation system that respects both operational constraints (quiet hours, human oversight) and the realities of session-based agent architecture. The work centers on one core insight: not every outbound action needs to be fully automated to be fast.
The Problem Statement
Crew confirmations for charter bookings need to:
- Text crew members (e.g., Travis) with booking details and call times
- Respect quiet hours (no SMS between 17:00–08:00)
- Maintain human approval before any message leaves the system
- Execute quickly (one-tap send from a pre-drafted message)
- Work within a session-based agent model where nothing persists between terminal restarts
The naive approach—a scheduled Lambda that fires unattended at 08:00—introduces risk: a wrong message could leave before human eyes review it. The alternative we implemented is attended async confirmation: cache the data, draft the message during the session, and require a single human tap to send.
Technical Architecture
SMS Tool & Delivery Path
Verification confirmed that the send-sms tool exists and is executable on the Mac. This wraps the native osascript iMessage bridge, avoiding external SMS API dependencies during development:
$ which send-sms
/usr/local/bin/send-sms
$ send-sms --help
# Confirms executable and interface
The tool takes a phone number and message body, marshaling them through macOS's native Messages framework. This choice trades cloud infrastructure simplicity for session-local execution—no Twilio/SNS credentials in the repository, and zero risk of messages queueing in an external service.
Data Sourcing: Charter & Crew Context
Crew confirmations require context that lives across multiple storage layers:
- DynamoDB (production): Charters table with booking ID, date, call time, crew roster
- iCloud jada-ops/: Proposal history and crew contact mappings
- EC2 ~/repos/jada-heritage-research.md: Institutional knowledge (crew roles, typical confirmation patterns)
- Local session memory: Current charter and crew member being confirmed
The confirmation flow reads from DynamoDB for authoritative booking data, uses local caching to avoid repeated queries during the session, and falls back to proposal history if crew details aren't in the charter record.
Quiet Hours Enforcement
Quiet hours (17:00–08:00) are enforced at the send point, not at scheduling time. When a confirmation is drafted, the system checks the local timezone:
$ date +%H:%M
# If output is 17:00 or later, or before 08:00, flag the message as "queued for morning"
# Store the intent in session memory with a timestamp
This means:
- Drafting a confirmation at 18:30 is fine; it won't send until the human re-enters the session after 08:00
- No external scheduler is involved—the human is the scheduler
- The message text is cached in memory (not persisted to disk), so it's available for review immediately
Implementation Details: The Attended Confirmation Workflow
Phase 1: Session Initialization & Data Cache
When the session starts, the agent scans for open crew confirmations:
# Pseudocode example
read_automation_memory()
→ scan jada-ops/proposals/ for recent outbound confirmations
→ extract crew member names and charter IDs
→ query DynamoDB charters table for call times, dates, roles
cache_crew_context(charter_id, crew_member)
→ store {name, phone, role, call_time, date, location} in session dict
→ mark status as "draft_ready"
This happens once per session, before any confirmation is drafted. The cache is session-local; if the terminal closes, the cache is lost (as intended—no unattended sends).
Phase 2: Message Drafting
When the human indicates a confirmation is ready (e.g., "draft Travis's confirmation"), the agent synthesizes the message from cached data and prior proposals:
draft_confirmation(crew_member="Travis", charter_id="CHR-20250115-001")
charter = cache.get(charter_id)
# charter = {date: "2025-01-15", call_time: "06:00", role: "deckhand",
# location: "Monterey", rate: "$250/day"}
proposal_history = read(jada-ops/proposals/Dylan.txt,
jada-ops/proposals/Noelle.txt,
jada-ops/proposals/Molly.txt)
# Extract: tone, confirmation format, rate confirmation language, cancellation terms
draft = f"""
Hi Travis,
We'd like to confirm you for {charter.role} on {charter.date},
call time {charter.call_time} at {charter.location}.
Rate: ${charter.rate}/day
[cancellation terms from Dylan/Noelle/Molly proposals]
Please reply to confirm, or let us know ASAP if you can't make it.
Thanks,
JADA Sailing Crew Ops
"""
return draft # displayed to human for review
The message is displayed in the session (stdout). The human reviews it—if correct, they approve with a single command. If not, they iterate or cancel.
Phase 3: Quiet Hours Check & Send
Before sending, the quiet hours constraint is checked:
send_confirmation(draft_message, crew_phone="+1-XXX-XXX-XXXX")
current_hour = int(datetime.now().strftime("%H"))
if current_hour >= 17 or current_hour < 8:
print("⏸ Quiet hours active (17:00–08:00). Message cached.")
print(" When ready after 08:00, say: send_confirmation()")
cache.queue(crew_phone, draft_message)
return
send_sms(crew_phone, draft_message)
log_confirmation(crew_name="Travis", timestamp=now(), status="sent")
Once sent, the confirmation is logged to the automation memory (a text file in jada-ops/) with timestamp, message body, and recipient—creating an audit trail without a database.
Why This Approach?
No Unattended Outbound Actions
Session-based agents have no way to persist state between restarts. Attempting an unattended 08:00 send would require either:
- A scheduled Lambda (introduces cloud infrastructure, API key management, and risk if the message is wrong)
- A cron job on the Mac (requires the Mac to stay on overnight, and still has the wrong-message risk)
Instead, the human is the scheduler. They come back after 08:00, say "send Travis's confirmation," and the agent sends