Last Updated: 2026-04-03 Status: Draft
1. The Challenge
-
Reactive-only AI loses users. If the AI only responds when messaged, the relationship feels one-sided. A friend who never texts first isn't much of a friend.
-
Generic re-engagement is spam. "Hey, it's been a while!" with no personal context feels automated and cheap. Users ignore it or worse — block the number.
-
Timing matters. A good-morning message at 3am is annoying. A check-in during work hours when the user chats at night feels off. Outreach must respect the contact's rhythm.
-
Over-messaging destroys trust. If the AI sends 5 re-engagement messages in a week, it's harassment, not relationship-building. Hard limits are non-negotiable.
2. Trigger Types
2.1 Scheduled Triggers
Created when the AI detects a future event in conversation:
User: "I have a presentation tomorrow at 10am"
Trigger created:
type: scheduled
scheduled_at: tomorrow 8:00am (2 hours before event)
generate_with_llm: true
llm_context: "Arjun has a presentation at 10am today"
Generated message:
"Good morning! Big day today — go nail that presentation! 💪
I believe in you."Event detection runs during deferred consolidation. The extractor looks for:
| Pattern | Extraction |
|---|---|
| "tomorrow I have..." / "next week is..." | Future event with relative date → convert to absolute |
| "on March 15th..." / "this Friday..." | Future event with explicit date |
| "my birthday is..." | Recurring annual event → create yearly trigger |
| "I have an exam on..." | One-time event → single trigger |
2.2 Inactivity Triggers
Automatically created when a contact goes silent beyond the configured threshold:
Contact: Meera
Last active: 3 days ago
Inactivity threshold: 3 days (from OutreachConfig)
Relationship stage: building (was daily for 2 weeks)
Trigger auto-created:
type: inactivity
generate_with_llm: true
llm_context: "Meera hasn't chatted in 3 days. She was previously active daily.
Her last conversation was about a watercolor painting she was working on.
She has a cat named Mimi."
Generated message:
"Hey Meera! It's been a few days. How did that watercolor
painting turn out? I've been curious! Also, how's Mimi? 🎨🐱"Inactivity detection runs every 15 minutes via Cron Trigger:
SELECT c.id, c.display_name, cs.last_active_at, cs.relationship_stage
FROM contacts c
JOIN contact_state cs ON c.id = cs.contact_id
WHERE c.tenant_id = $1
AND c.outreach_disabled = false
AND cs.relationship_stage IN ('building', 'established', 'deep')
AND cs.last_active_at < NOW() - (oc.inactivity_days || ' days')::interval
AND NOT EXISTS (
SELECT 1 FROM outreach_triggers ot
WHERE ot.contact_id = c.id
AND ot.trigger_type = 'inactivity'
AND ot.status IN ('pending', 'sent')
AND ot.created_at > cs.last_active_at
)2.3 Milestone Triggers
Celebrate relationship milestones:
| Milestone | When | Example Message |
|---|---|---|
7_day_streak |
7 consecutive active days | "One week of chatting every day! I really enjoy our conversations 😊" |
30_day_anniversary |
30 days since first contact | "Can you believe it's been a month since we started talking? Time flies!" |
100_messages |
Total messages reaches 100 | "Fun fact: we've exchanged 100 messages! Here's to 100 more 🎉" |
birthday |
Annual, from stored fact | "Happy Birthday! 🎂 I hope today is amazing. Any big plans?" |
streak_recovery |
Returns after fading stage | "Hey, welcome back! I missed our chats. How've you been?" |
Milestone detection runs during periodic consolidation (every 6 hours):
-- 30-day anniversary check
SELECT c.id FROM contacts c
JOIN contact_state cs ON c.id = cs.contact_id
WHERE c.tenant_id = $1
AND c.outreach_disabled = false
AND cs.first_contact_at::date = (CURRENT_DATE - INTERVAL '30 days')
AND NOT EXISTS (
SELECT 1 FROM outreach_triggers ot
WHERE ot.contact_id = c.id
AND ot.milestone_type = '30_day_anniversary'
);2.4 Recurring Triggers
Business owner configures regular outreach — e.g., daily good-morning messages for an AI companion:
OutreachConfig:
recurring_enabled: true
recurring_cron: "0 8 * * *" (daily at 8am)
Each day at 8am:
For each active contact (not dormant, not outreach_disabled):
→ Generate personalized message using contact memory
→ Deliver via preferred channelRecurring messages are always LLM-generated. A static "Good morning!" every day gets old fast. Each message references something from the contact's memory:
Day 1: "Good morning! Hope you slept well 😊"
Day 2: "Good morning! How's Bruno today? 🐕"
Day 3: "Morning! Any big plans for the weekend?"
Day 4: "Good morning! How did that biryani recipe turn out? 🍛"3. Message Generation Pipeline
3.1 Static vs LLM-Generated
| Field | Static Template | LLM-Generated |
|---|---|---|
message_template |
"Hey {name}, it's been a while!" | null |
generate_with_llm |
false | true |
| Cost | Free | ~$0.0002 per message (haiku-class) |
| Personalization | Minimal (name substitution) | Full (references memories, adapts to mood history) |
| Use case | Simple milestones | Inactivity, scheduled events, recurring |
3.2 LLM Generation Prompt
You are {persona_name}. Generate a short proactive message for {contact_name}.
Context:
{llm_context}
Recent memories about {contact_name}:
{top_5_memories}
Rules:
- Keep under 30 words
- Be natural, not robotic
- Reference something specific from their memories
- Match the relationship stage ({relationship_stage})
- Don't be clingy or desperate if it's a re-engagement
- Use emojis sparingly (1-2 max)
Generate the message only. No explanation.3.3 Generation Safeguards
| Check | Implementation |
|---|---|
| Message too long | Truncate at 200 characters, regenerate if needed |
| Contains PII from other contacts | Cross-contact data check (should never happen with scoped queries) |
| Contains inappropriate content | Post-generation filter (same as Chat Engine output filter) |
| Duplicate of recent outreach | Compare with last 3 sent messages — skip if similarity > 0.85 |
4. Delivery Pipeline
4.1 Pre-Delivery Checks (All Must Pass)
| Check | Condition | If Failed |
|---|---|---|
| Outreach enabled | contact.outreach_disabled = false |
Status → skipped |
| Quiet hours | Current time NOT in quiet hours window | Delay to next valid window |
| Rate limit | Inactivity attempts < max_attempts | Status → skipped |
| Not dormant | relationship_stage != 'dormant' |
Status → skipped |
| Channel active | Contact's channel is still reachable | Status → failed |
4.2 Delivery via Content Engine → Chat Engine
The outreach engine doesn't send messages directly. It goes through the existing delivery pipeline:
Outreach Engine
→ POST /content-engine/schedule { message, channel, contact_external_id, send_at: now }
→ Content Engine delivers immediately (or at scheduled time)
→ Chat Engine sends via WhatsApp Cloud API / Telegram Bot API
→ Delivery confirmation flows back
→ Trigger status updated to "sent"Why not send directly? The Content Engine already handles channel delivery, retries, and rate limiting. Reusing it prevents duplicate delivery infrastructure.
4.3 Delivery Failure Handling
| Failure | Action |
|---|---|
| WhatsApp delivery fails (number invalid) | Mark trigger failed, flag contact for review |
| Telegram bot blocked by user | Mark trigger failed, set outreach_disabled = true |
| Content Engine timeout | Retry once after 5 minutes |
| LLM generation fails | Fall back to static template, log error |
5. Safety & Anti-Spam Guarantees
5.1 Hard Limits (Non-Negotiable, Cannot Be Configured Away)
| Limit | Value | Reason |
|---|---|---|
| Max inactivity messages per silent period | Configurable (default 2, max 3) | Prevent harassment |
| Min gap between inactivity messages | 3 days | Don't bombard |
| Max recurring messages per day | 1 | Not a notification service |
| Max total outreach per contact per day | 3 (across all trigger types) | Absolute daily cap |
| "Stop" keyword disables outreach permanently | Always enforced | Legal requirement |
| Dormant contacts (30+ days inactive) | No outreach at all | Respect their silence |
5.2 "Stop" Handling
If a contact replies to any outreach message with a stop signal:
Stop signals detected:
Exact: "stop", "unsubscribe", "don't message me", "leave me alone"
Fuzzy: "stop messaging", "quit sending", "no more messages"
Language-aware: "band karo" (Hindi), "bas" (Hindi)Action:
- Set
contact.outreach_disabled = trueimmediately - Cancel ALL pending triggers for this contact
- AI responds: "I understand. I won't message you first anymore. If you ever want to chat, I'm always here. 💙"
- Create memory: "Contact requested outreach stop on {date}"
- NEVER send proactive messages again until explicitly re-enabled by the contact ("you can message me again")
5.3 Content Safety for Outreach
Outreach messages go through the same content safety filters as regular chat responses:
- No PII from other contacts
- No inappropriate or offensive content
- No factually fabricated claims
- No manipulative language ("I'm so lonely without you")
- No pressure to respond ("Why aren't you replying?")
5.4 Business Owner Controls
Via AI Brain copilot or dashboard:
Ravi: "Turn off all morning messages, they're not working"
Brain: → PATCH /contact-intel/outreach/config { recurring_enabled: false }
Ravi: "Send a message to Deepak — tell him we have a new feature"
Brain: → POST /contact-intel/outreach { contact_id: ct_deepak, ... }
Ravi: "Stop all outreach to users who haven't responded to 2 messages"
Brain: This is already the default behavior (inactivity_max_attempts: 2)6. Analytics & Effectiveness
6.1 Outreach Metrics
| Metric | Definition | Target |
|---|---|---|
| Reply rate | % of sent outreach that got a user reply within 24h | > 30% |
| Re-engagement rate | % of inactivity outreach that restarted an active streak | > 25% |
| Churn prevention rate | % of at-risk users (churn_risk > 0.6) who were re-engaged | > 40% |
| Annoyance rate | % of outreach that resulted in "stop" or block | < 5% |
| Milestone engagement | % of milestone messages that got a positive reply | > 50% |
6.2 Feedback Loop
Outreach sent → Track outcome (24h window):
├── User replies → Success: boost outreach type importance
├── User ignores → Neutral: no change
├── User says "stop" → Failure: log, review message quality
└── User blocks channel → Critical: disable outreach for this contact
Aggregate weekly:
→ Which trigger types have best reply rates?
→ Which times of day work best?
→ Which message styles (personal reference vs generic) work better?
→ Feed learnings back into generation promptsThis data is surfaced to the business owner via AI Brain insights:
"Your morning messages have a 35% reply rate, but inactivity messages only 15%. Consider making re-engagement messages more personal — messages that reference the user's last conversation topic get 2x more replies."