Last updated: 2026-06-30
Status: Architecture decided. Implementation Phase 8. Verified against live source.
Context
CE is currently 100% reactive — contacts initiate, CE responds.
CI already has a complete outreach system: the scanner detects triggers (churn, inactivity, milestones), generates personalised messages via LLM, and marks triggers fired. But delivery never happens — the message sits in ci_outreach_triggers.message unused (CI bug B3, see contact-intelligence/backlog.md).
Proactive Sessions is mostly a wiring job: CE consumes what CI already produces and delivers it to the contact's channel.
What Triggers a Proactive Session
All triggers originate in CI, not CE. CE is the delivery mechanism only.
| Trigger type | Condition | Who detects |
|---|---|---|
churn_risk |
CI churn score > threshold | CI outreach scanner |
inactivity |
No message in N days | CI outreach scanner |
milestone |
100th message, 1-year anniversary | CI outreach scanner |
re_engagement |
Contact went cold after active period | CI outreach scanner |
form_submitted |
Contact filled a vlozi Form | CE (direct, Phase 8B) |
CI already generates the personalised message using LLM + relationship context before enqueueing. CE uses that message as-is — no re-generation.
Delivery Architecture
CI outreach scanner (runs every 6h)
→ detects trigger, generates message via LLM
→ marks trigger "fired" in ci_outreach_triggers
→ enqueues to OUTGOING_QUEUE (CF Queue)
{ tenantId, contactId, message, triggerType, triggerId }
↓
CE queue consumer (queue export in src/index.ts)
→ selectDeliveryChannel(contactId, tenantId)
→ create outgoing session { initiatedBy: "proactive" }
→ deliver message via channel API
→ save message to Neon (D1 in 6B)
→ POST /ingest to CI: { proactive: true, trigger_id, delivered, session_id }
↓
If contact replies → normal processChat pipeline activates on the same session
If no reply in 48h → SessionScheduler DO alarm auto-closes the sessionChannel Selection
Widget is never used for proactive — the contact must be actively on the site.
Priority order:
1. WhatsApp — if contact last messaged < 24h ago (free-form message allowed)
2. Telegram — if WhatsApp window expired (> 24h) OR WhatsApp not configured
3. Skip — no viable channel → log "delivery_skipped" in CI, do not deliverThe WhatsApp 24h rule: Meta restricts free-form outbound messages to contacts who messaged within the last 24 hours. Outside that window only pre-approved templates are allowed. Rather than manage template approval, CE falls back to Telegram.
Channel data source: ci_contact_channels via GET /contacts/:id/channels — already tracks last_active_at per channel.
Session Model
New column on ce_sessions (the only schema change for Phase 8A):
initiated_by TEXT NOT NULL DEFAULT 'inbound'
-- values: 'inbound' | 'proactive' | 'campaign'Confirmed against schema.ts:90 — ce_sessions has no initiated_by today.
A proactive session starts with an assistant message (bot sends first). If the contact replies, processChat activates on this session identically to inbound — the LLM sees a conversation that started with the bot's message.
Auto-Close on No Reply ⚠️ Correction
Reuses the existing SLA mechanism via scheduleSessionSla (session-scheduler.ts:8) — schedule a 48h alarm instead of the normal 15-min escalation alarm.
Important — the DO self-destructs after firing. The SessionScheduler DO (scheduler.ts) calls deleteAll() after its alarm fires and is one alarm per session id (session-${id}). Consequences for proactive:
- When a proactive session gets a reply, the inbound handler MUST call
cancelSessionSlato tear down the 48h alarm — exactly like agent reply / assign / resolve already do. Otherwise the alarm fires later and wrongly closes an active conversation. - The 48h "no-reply close" and the 15-min "escalation breach" share the same DO slot. A session cannot hold both simultaneously — if a proactive session escalates to a human, the 48h alarm is replaced by the 15-min one (re-scheduling overwrites the stored alarm). This is correct behavior, just be aware they're mutually exclusive.
When the 48h alarm fires with no reply:
- Close the session
- Report
no_responseto CI:POST /ingest { trigger_id, delivered: true, replied: false }
deliverProactiveMessage() Logic
async function deliverProactiveMessage(msg: OutgoingMessage, env: Env) {
// 1. Get contact channels
const channels = await callCI(env, msg.tenantId, `/contacts/${msg.contactId}/channels`, "GET")
// 2. Select best channel (not widget, WhatsApp 24h check, Telegram fallback)
const channel = selectDeliveryChannel(channels)
if (!channel) {
await reportToCI(env, msg.tenantId, msg.triggerId, { delivered: false, reason: "no_viable_channel" })
return
}
// 3. Create outgoing session
const session = await createOutgoingSession(db, msg.tenantId, msg.contactId, channel.id, "proactive")
// 4. Schedule 48h no-reply alarm (cancelled if the contact replies)
await scheduleSessionSla(env.SESSION_SCHEDULER, session.id, msg.tenantId,
new Date(Date.now() + 48 * 60 * 60 * 1000))
// 5. Deliver via channel API
await dispatchToChannel(channel, msg.message, env)
// 6. Save as assistant message
await saveOutgoingMessage(session.id, msg.tenantId, msg.message, env)
// 7. Report delivery back to CI
await reportToCI(env, msg.tenantId, msg.triggerId, { delivered: true, session_id: session.id })
}On queue failure → CF Queue retries with exponential backoff (3 attempts). On final failure → report delivery_failed to CI.
CF Queue Consumer (src/index.ts)
export default {
fetch: app.fetch,
scheduled: scheduledHandler,
queue: async (batch: MessageBatch<OutgoingMessage>, env: Env) => {
for (const msg of batch.messages) {
try {
await deliverProactiveMessage(msg.body, env)
msg.ack()
} catch (err) {
console.warn("[Proactive] Delivery failed, retrying:", err)
msg.retry()
}
}
},
}Changes Required
CI (apps/contact-intelligence)
src/routes/outreach.route.ts— after marking triggerfired, enqueue toOUTGOING_QUEUEwrangler.toml— add[[queues.producers]]forOUTGOING_QUEUE
CE (apps/chat-engine)
src/index.ts— addqueueexport handler- New
src/lib/outgoing.ts—deliverProactiveMessage(),selectDeliveryChannel(),createOutgoingSession(),dispatchToChannel() src/routes/*inbound handler — callcancelSessionSlawhen a proactive session receives a reply (see correction above)wrangler.toml— add[[queues.consumers]]forOUTGOING_QUEUEsrc/db/schema.ts— addinitiatedBytoce_sessions
No new tables. ce_sessions.initiated_by is the only schema change.
What Doesn't Change
- CI trigger scanning — already complete, runs every 6h
- CI message generation — CI already generates personalised messages via LLM
- processChat pipeline — proactive sessions use it unchanged when the contact replies
- Billing accumulator — proactive outbound messages billed as normal assistant messages
- DO SLA scheduler — reused for the 48h no-reply timeout, just a different duration
Phase 8B — Campaign Mode (Future)
Human agent creates bulk outreach (target all churn_risk > 0.7, custom/CI message, staggered delivery, reply-rate reporting). NOT Phase 8A. Requires bulk queue batching, a staggered dispatch rate limiter, a campaign analytics table, and dashboard campaign management. Separate phase when there's a real user requirement.