Building a Customer Reactivation Engine: Templates, State, and Safe Defaults
Over the past week, I built three interconnected systems for customer engagement at Queen of San Diego: a reactivation skill that queues personalized follow-ups with strict templates and approval gates, a morning digest that surfaces urgent customer state from local data sources, and a content governance framework that pre-authorizes social posting lanes while blocking high-risk categories. Here's how they fit together and why the architecture matters.
The Reactivation Skill: Templates Meet State
The core system lives in /Users/cb/.claude/skills/jada-followup/ and follows the same pattern as the existing PDF proposal skill: a SKILL.md descriptor, a Python builder script, and a reference data layer.
File structure:
scripts/build_followups.py— the main builder, invoked with--dry-runor--mark-sentflagsreference/followup_templates.json— four template bodies: standard thank-you (1 day post-visit), memorial thank-you (gentle, no review ask), review request (4 days), and referral prompt (21 days)build/followups.jsonl— output queue of draft emails, one JSON object per line, awaiting approval
The script reads three data sources:
ledger.json— outstanding guest balances and payment statepassengers/contacts.csv— guest email addresses and event metadata (event name, date, crew/passenger flag)suppression/jada.csv— opt-out and previously-contacted records
Cadence logic is simple and explicit: thank-you triggers 1 day after an event, review request at 4 days, referral prompt at 21 days. Each template is applied only once per guest per cadence, enforced by timestamp columns in the ledger. The script runs in dry-run mode by default, printing a summary without modifying state; only --mark-sent stamps the thank_you_sent column and updates internal timestamps.
Hard Rules: Memorials and Financial Safety
Two critical rules are enforced structurally, not in comments:
Memorial handling: Guests marked as memorial event attendees receive a single, gentle thank-you email with no review request, no referral ask, no exclamation marks, and no unsubscribe footer. This is enforced by template selection logic — the builder checks an event flag and routes to a distinct template body, making it impossible to accidentally send a cheerful "tell your friends" message to someone grieving.
Financial safety: Any guest with an open balance in the ledger is skipped entirely. This prevents a follow-up asking for a referral to arrive at the same time an invoice does — a jarring UX mistake that's easy to make at scale.
Approval Gates: Nothing Sends Unreviewed
The skill cannot send mail. Every batch of follow-ups is written to build/followups.jsonl and waits for manual approval. If a batch exceeds 10 recipients, it must route through the existing blast approval flow (currently run_scheduled_blast.py, not jada_blast.py — ground truth was corrected during this project). The approval gate is the only place where send decisions are made.
The Morning Digest: Local State Without External Polling
/Users/cb/icloud-jada-ops/digest/build_digest.py assembles a read-only card stack from local data sources only, run every morning by a LaunchAgent:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.jada.morning-digest.plist
The plist is installed at user scope (not system) and runs the builder without network calls. Data sources are strictly local:
- Payments from the last 24 hours (parsed from local exports)
- Guest balances flagged as at-risk (open invoices older than 30 days)
- Any guest with a
next_actionfield set (custom action tracking) - Queued follow-ups from
build/followups.jsonlpending approval - Any file in
drafts/(ad-hoc notes or manual interventions)
Output is written to two formats:
build/digest.txt— human-readable plaintext, capped at 15 items, sorted by urgency (needs-you items first, then FYI)- A cards JSON file matching the schema used by the progress dashboard (so digests can be ingested when the dashboard launches)
The --email flag sends a summary to a single hardcoded address. This is intentional: the digest surfaces state, not decisions, and it goes only to the person who acts on it.
Content Governance: Pre-Approved Posting Lanes
/Users/cb/.claude/agents/content-writer.md now embeds a posting framework with six approved lanes and an explicit blocklist. Approved lanes (tested and safe to automate):
- Scheduled event promos (e.g., "sunset sails this Friday at 6pm")
- Next-open-date announcements
- Evergreen content from a whitelist of pre-approved photos only
- Verbatim reshares of guest reviews
- Countdown reposts (e.g., "5 days until…")
- No-claim holiday posts (generic "happy summer" messaging with no business claims)
Blocked lanes: replies to review comments, pricing announcements, history claims beyond the basic fact ("built 1938, Stockton"), and any guest-specific narratives not explicitly approved by date. This prevents a content system from accidentally making a promise it can't keep or responding to feedback in a way that opens new obligations.
Key Architectural Decisions
Why local data sources only? The morning digest doesn't poll external APIs. This keeps it fast, avoids rate-limit surprises, and makes it safe to run frequently. State lives in ledger.json, exported from accounting software on a regular schedule, and contacts.csv, which is the source of truth for guest contact info. No polling, no caching confusion.
Why templates in JSON, not strings? Follow-up bodies are versioned in reference/followup_templates.json, not hardcoded in Python. This decouples template updates from code deploys and makes it easy to A/B test body copy without touching the builder logic. Each template is a single object with fields for subject, body, and metadata (e.g., skip_if_balance, apply_once_per_days).
Why approval gates? Customer communication scales quickly. A system that sends 100 emails on a Saturday night with a bug is much worse than a system that sends 50 on Monday morning after you've reviewed them. The approval gate is the cheapest insurance policy available.
Why strict rules for memorials? Tone-deaf marketing is a common failure mode in customer systems. By making memorial handling a first-class concern in template selection — not a checkbox in conditional logic — we make it impossible to skip. Same principle as code: push policy into the type system and make the wrong thing unrepresentable.
What's Next
Three immediate items emerged from the first digest run:
- A high-priority guest sails tomorrow with an open balance and unconfirmed headcount — manual intervention needed today
- Three follow-up drafts (one memorial, two general thank-yous) are queued and awaiting approval to send
- The review request template is structurally complete but blocked until the Google review link is provided — it's a required field in the template JSON, so review requests cannot be sent until that single URL is filled in
The skill and digest are now running. Once the approval gates and the review link are in place, follow-ups will queue daily, the digest will surface state every morning, and the content lanes are ready to post safely.
```