logicspike/docs

Chat Engine

Proactive Sessions — Architecture Reference

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 session

Channel 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 deliver

The 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:90ce_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:

  1. When a proactive session gets a reply, the inbound handler MUST call cancelSessionSla to tear down the 48h alarm — exactly like agent reply / assign / resolve already do. Otherwise the alarm fires later and wrongly closes an active conversation.
  2. 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_response to 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 trigger fired, enqueue to OUTGOING_QUEUE
  • wrangler.toml — add [[queues.producers]] for OUTGOING_QUEUE

CE (apps/chat-engine)

  • src/index.ts — add queue export handler
  • New src/lib/outgoing.tsdeliverProactiveMessage(), selectDeliveryChannel(), createOutgoingSession(), dispatchToChannel()
  • src/routes/* inbound handler — call cancelSessionSla when a proactive session receives a reply (see correction above)
  • wrangler.toml — add [[queues.consumers]] for OUTGOING_QUEUE
  • src/db/schema.ts — add initiatedBy to ce_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.

Chat Engine