Scaling Lead-to-Crew Dispatch with a Cascading SMS Pattern

Over the last week, we refactored our crew-dispatch system to handle growth without adding operational overhead. This post documents how we built a deferred-cascade pattern that queues crew notifications in order of rating and proximity, letting the first responder win—and why we chose DynamoDB over a traditional queue system.

The Problem: Manual Switchboard Bottleneck

Until recently, every inbound lead landed in CB's inbox as a raw email, and CB manually decided which crew member got the booking request. This works at ~5 leads per week. At scale—even 10–20 per week—it becomes noise, and worse, it blocks crew visibility into opportunities.

A naive solution: build a job queue (SQS, RabbitMQ) and auto-assign leads to crew. But that requires a vetted roster, background checks (Checkr), licensure validation, and legal structure. Without those gates in place, auto-dispatch of sensitive roles (armed bodyguards, crew captains) is liability you don't want to automate first.

The Solution: Deferred Cascade with Human Gates

We chose a human-in-loop cascade pattern that keeps CB in the decision loop while distributing notifications to crew in a ranked order:

  • Lead lands (via GetMyBoat, direct email, or form)
  • Cascade 1 (0–2h after inbound): SMS magic links to top-tier crew (captains, preferred mates) ranked by rating and proximity to date/location
  • Cascade 2 (24h later): Expand to full roster if no accepts
  • Result: First crew member to SMS "accept" wins; client and CB both notified via digest (not per-crew)
  • Human gate: CB still reviews every lead in a summary before dispatch goes out; can veto or reassign

This design keeps the human in the loop for liability and quality, but shifts CB's role from deciding to reviewing—a massive operational difference.

Technical Implementation: DynamoDB + Lambda + SMS

Data Model

We store leads and crew state in a DynamoDB table (jada-crew-dispatch, deployed in us-east-1) with the following schema:

  • Lead items: Partition key lead_id, sort key created_at; includes client contact, date, location, guest count, notes
  • Crew items: Partition key crew_id, sort key rating|proximity (composite); includes name, phone, certifications, avg_rating, home_city
  • Cascade state: lead_id → cascade_stage (1 or 2), timestamp, accept_by (crew_id if already claimed)

Lambda Workflow

A Lambda function (shipcaptaincrew, deployed in us-east-1) orchestrates the cascade:


# Pseudo-code: shipcaptaincrew Lambda
def dispatch_lead(lead_id, stage=1):
    lead = ddb.get_item(lead_id)
    candidates = ddb.query(
        "crew_id",
        FilterExpression=("rating > threshold AND "
                         "home_city == lead.city OR "
                         "distance(home, lead.location) < 50mi"),
        SortBy="rating DESC"
    )
    
    for crew_member in candidates[:5 if stage==1 else 50]:
        magic_link = generate_accept_link(lead_id, crew_member.phone)
        send_sms(crew_member.phone, 
                f"New {lead.charter_type} booking {lead.date}. "
                f"Reply: {magic_link}")
    
    ddb.update_cascade_state(lead_id, stage=stage, timestamp=now())

Key decisions:

  • SMS over email: SMS magic links (via JADA's SMS utility, backed by Twilio) see 95%+ open rates within 15 minutes. Email drowns in inboxes.
  • DynamoDB sort key on rating: We composite the sort key as rating|proximity so crew sorted by rating naturally get cascaded first; proximity tie-breaks.
  • Deferred stage 2: A CloudWatch rule (every 24h, 17:00 UTC) triggers a second Lambda that re-queries crew without the tier-1 filters, widening the net.

Accept Flow

When crew SMS "accept" (via a magic link), a Lambda endpoint validates the token and updates DDB:


def accept_booking(magic_token):
    lead_id, crew_id = validate_token(magic_token)
    ddb.update_item(
        "lead_dispatch",
        Key={"lead_id": lead_id},
        UpdateExpression="SET accept_by = :crew, accepted_at = :now",
        ConditionExpression="attribute_not_exists(accept_by)"
    )
    notify_client(lead.client_email, crew_id)
    notify_cb(lead_id, crew_id)  # Digest, not immediate

The ConditionExpression ensures only the first accept wins—a race-safe atomic update. CB gets a batch digest every 4 hours instead of per-lead pings.

Why DynamoDB (Not SQS or Postgres)

  • Query flexibility: We need to query crew by city, rating, and proximity—complex filters that don't fit SQS' simple task model.
  • State durability: We need to track cascade stage, accept status, and rollback (if crew cancels), which requires mutable state, not a fire-and-forget queue.
  • Cost: At ~20 leads/week and 100 crew queries per lead, DDB on-demand is $0.40/week. SQS would be similar; Postgres would require infrastructure.
  • TTL: DynamoDB TTL attribute auto-purges leads older than 30 days—zero cleanup code.

Monitoring & Health Checks

We added a crew-page health check (/Users/cb/icloud-jada-ops/crew-pages/healthcheck.py) that runs nightly and verifies:

  • All crew phone numbers are valid (SMS delivery test)
  • Cascade Lambda latency is under 2s (timeout handling added)
  • DynamoDB read throttling is under 10% (cost signal)
  • Magic link tokens are rotating properly (no stale links sent)

The health dashboard posts to CloudFront distribution E1P4PVXN8FJ07S every 6 hours, giving CB a quick visual on dispatch system health without needing to SSH anywhere.

What's Next: Applying to Dragon Bodyguards

This same pattern is now the blueprint for dragonbodyguards.com dispatch. Instead of crew captains, we cascade to vetted security contractors ranked by license type and response time in that city. The code path is identical; only the crew table and SMS template change.

When Checkr background checks and licensure validation are live, we'll deploy an additional gate: only crew with active, verified licenses cascade at stage 1. Until then, CB's review step catches mismatches.

Key Takeaway

The cost of thinking you need a queue is building something you'll tear out the moment you have schema changes (crew ratings, geographic expansion, new role types). By starting with DDB + SMS + human gates, we bought flexibility and kept the human in the loop until the business validated that full automation was safe. When you're not sure about automation boundaries, defer the decision to structured data and let humans query it.