Skip to content

LLM-Driven Lead Nurturing Pipeline: Design

Status: Design / proposed Date: 2026-06-09 Scope (Phase 1): B2C leads, email channel only, human-approval queue before every send. Schema designed so a B2B/Zoho track and more channels drop in later.

This describes an automated, LLM-personalized, multi-step nurturing pipeline built on the infrastructure that already exists in this worker. The guiding principle: reuse the send path, the cron heartbeat, and the lead data we already have, and build only the one thing that's missing, a per-lead journey state engine.

What already exists (reused, not rebuilt)

PrimitiveWhereReused for
Email send path (suppression, dedup, rate limit, tracking, RFC 8058 unsubscribe)src/email-send.ts, src/email-tracking.tsAll Phase 1 sends go through sendCampaignEmail() unchanged
Open/click eventsemail_events tableStep conditions ("if they clicked, branch") read from here
Cron heartbeat (every 2 min)scheduled() in src/index.ts, wrangler.toml [triggers]Two new tasks added to the existing loop
Lead signalusers, generations, games, analytics_sessionsbuildLeadProfile() joins these for the LLM
Segment queriesSEGMENT_QUERIES in src/email-send.tsJourney entry conditions
LLM clientOpenAI responses API, pattern in src/game-creator.tsgenerateNurtureMessage()
Rate limitsKV resend:daily / resend:monthly (90/day, 2800/mo)Unchanged, still the ceiling

What's missing (built here)

A journey state engine: per-lead position in a multi-step sequence, with waits, conditional branching, and re-entry. Today email_campaigns.sequence_num is only a label, there is no per-lead state. Plus an LLM personalization function and a human-approval outbox.

Data model (new D1 tables)

sql
-- Journey definitions. Editable without a deploy.
CREATE TABLE nurture_journeys (
  id              TEXT PRIMARY KEY,
  name            TEXT NOT NULL,
  audience        TEXT NOT NULL DEFAULT 'b2c',   -- 'b2c' | 'b2b' (b2b reserved for later)
  entry_segment   TEXT,                          -- a SEGMENT_QUERIES key, or NULL for rule-based
  entry_rule_json TEXT,                          -- optional extra entry condition
  status          TEXT NOT NULL DEFAULT 'paused',-- 'active' | 'paused'  (paused = kill switch)
  max_per_7d      INTEGER NOT NULL DEFAULT 2,    -- frequency cap per lead across this journey
  created_at      TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Ordered steps within a journey.
CREATE TABLE nurture_journey_steps (
  id               TEXT PRIMARY KEY,
  journey_id       TEXT NOT NULL,
  step_order       INTEGER NOT NULL,
  channel          TEXT NOT NULL DEFAULT 'email', -- email only in Phase 1
  delay_seconds    INTEGER NOT NULL DEFAULT 0,    -- wait after entering this step before acting
  condition_json   TEXT,                          -- e.g. {"requires":"clicked_prev"} | {"if_no_open_prev":true}
  intent           TEXT NOT NULL,                 -- LLM instruction: what this message should accomplish
  subject_hint     TEXT,
  approval_required INTEGER NOT NULL DEFAULT 1,
  created_at       TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(journey_id, step_order)
);

-- Per-lead position in a journey.
CREATE TABLE nurture_lead_state (
  id              TEXT PRIMARY KEY,
  user_id         TEXT,                           -- B2C
  crm_contact_id  TEXT,                           -- reserved for B2B/Zoho
  email           TEXT NOT NULL,
  journey_id      TEXT NOT NULL,
  current_step    INTEGER NOT NULL DEFAULT 0,
  status          TEXT NOT NULL DEFAULT 'active', -- active|waiting|awaiting_approval|completed|exited|suppressed
  entered_at      TEXT NOT NULL DEFAULT (datetime('now')),
  next_action_at  TEXT,                           -- when the engine should next act on this lead
  last_action_at  TEXT,
  last_send_id    INTEGER,                        -- FK into email_sends, for condition checks
  context_json    TEXT,                           -- scratch (engagement flags, etc.)
  updated_at      TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(user_id, journey_id)                     -- no double-enrollment
);

-- Human approval queue. Every generated message lands here first.
CREATE TABLE nurture_outbox (
  id              TEXT PRIMARY KEY,
  lead_state_id   TEXT NOT NULL,
  user_id         TEXT,
  email           TEXT NOT NULL,
  journey_id      TEXT NOT NULL,
  step_order      INTEGER NOT NULL,
  channel         TEXT NOT NULL DEFAULT 'email',
  subject         TEXT,
  body_html       TEXT,
  llm_model       TEXT,
  llm_cost_json   TEXT,
  validation_json TEXT,                           -- {pass:bool, flags:[...]}
  status          TEXT NOT NULL DEFAULT 'pending_approval', -- pending_approval|approved|rejected|sent|failed
  decided_by      TEXT,
  decided_at      TEXT,
  sent_send_id    INTEGER,
  created_at      TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_lead_state_due ON nurture_lead_state(status, next_action_at);
CREATE INDEX idx_outbox_status ON nurture_outbox(status, created_at);

New module: src/nurture.ts

typescript
// Join existing tables into a compact profile for the LLM.
async function buildLeadProfile(env, userId): Promise<LeadProfile>
//   { email, name, signup_page, timezone, created_at,
//     generations: {music, 3d, visual, sfx, counts, last_at},
//     games: {count, last_at},
//     analytics: {intent_segment, traffic_source, engagement_level, landing_page},
//     derived: {days_since_signup, primary_interest, is_activated} }

// Draft one message. Reuses the OpenAI responses pattern from game-creator.ts.
async function generateNurtureMessage(env, profile, step): Promise<DraftMessage>
//   System prompt enforces the brand voice rules from CLAUDE.md
//   (no em dashes, no semicolons, banned-word list, conversational, concrete).
//   Returns {subject, bodyHtml, model, cost, validation}.

// Validation pass before anything is queued.
function validateNurtureMessage(draft, profile): Validation
//   Checks: length bounds, banned words, no hallucinated specifics not in profile,
//   subject present, no claim that contradicts opt-out, tone. Sets pass=false to force review.

// Cron task A: enroll new leads + generate due-step drafts into the outbox.
async function advanceJourneys(env): Promise<{enrolled, drafted, exited}>

// Cron task B: send approved outbox rows, then advance the lead to the next step.
async function processApprovedOutbox(env): Promise<{sent, failed}>

advanceJourneys() logic (small batch per run)

  1. Enroll. For each active journey, find leads matching entry_segment / entry_rule_json not already in nurture_lead_state for that journey, and not suppressed. Insert state rows (status=active, next_action_at = now + step0.delay_seconds, respecting quiet hours via users.timezone).
  2. Draft due steps. Select active leads where next_action_at <= now, capped (e.g. 10 per run to respect cost and the 90/day Resend ceiling). For each: evaluate the step condition_json against email_events for last_send_id (opened? clicked?). If condition fails, branch or exit per the step. Otherwise build the profile, generate + validate the message, insert into nurture_outbox (pending_approval), and set the lead status=awaiting_approval. Enforce the max_per_7d frequency cap before generating.

processApprovedOutbox() logic

Select approved rows → send via sendCampaignEmail() (suppression, dedup, rate limit, tracking all reused) → on success set sent, store sent_send_id, and advance the lead (current_step += 1, next_action_at = now + nextStep.delay, or completed). On failure mark failed and leave the lead for retry.

Cron wiring

Add two calls to the existing scheduled() handler in src/index.ts, after the email/social tasks:

typescript
await advanceJourneys(env);          // task 12
await processApprovedOutbox(env);    // task 13

Human-approval flow (admin)

  • GET /admin/nurture/outbox (admin-gated like /admin/gtm): list pending_approval with the lead profile summary, generated subject/body preview, and validation flags.
  • POST /admin/nurture/outbox/:id/approve (optional edited subject/body in the body) → sets approved.
  • POST /admin/nurture/outbox/:id/reject (reason) → sets rejected, exits or pauses the lead.
  • A CLI helper mirroring scripts/email-campaign.sh for terminal review.

Guardrails (built in from day one)

  • Approval required on every step by default. Nothing sends without a human decision in Phase 1.
  • Suppression + dedup + rate limits are inherited from sendCampaignEmail(), unchanged.
  • Frequency cap (max_per_7d) checked before generating, so a lead can't be over-messaged across steps.
  • Quiet hours / timezone: next_action_at respects users.timezone.
  • LLM validation pass + brand-voice system prompt (the CLAUDE.md writing rules), so drafts are on-voice and flagged when risky.
  • Kill switch: set a journey status=paused to halt enrollment and drafting immediately.
  • Compliance: one-click unsubscribe is already injected by wrapEmailHtml(); confirm the physical-address footer (CAN-SPAM) is present; only enroll consented users; honor deletion (GDPR).

B2B / multi-channel extension (later phases, designed for now)

  • nurture_journeys.audience and nurture_lead_state.crm_contact_id reserve the B2B track. A Zoho sync (currently only an external MCP, not in the worker) would populate crm_contact_id and a contacts table, and buildLeadProfile() gains a CRM branch.
  • nurture_journey_steps.channel already abstracts the channel. Phase 2 adds a dispatch(channel, lead, msg) that routes to the social scheduler (DM/scheduled) or an in-app surface. Frequency capping becomes cross-channel.

Phased build plan

Phase 1 (this design): B2C, email, approval queue.

  • M1: migration (4 tables) + buildLeadProfile() + generateNurtureMessage() + validateNurtureMessage().
  • M2: advanceJourneys() enrollment + due-step drafting into the outbox.
  • M3: admin approval routes/page + CLI helper.
  • M4: processApprovedOutbox() + cron wiring + frequency cap + quiet hours.
  • M5: seed one real journey and dry-run end to end before activating.

Phase 2: multi-channel dispatch (social DM, in-app) + cross-channel frequency cap. Phase 3: Zoho CRM sync + B2B track + lead scoring. Phase 4: loosen approval to auto-send for proven, high-confidence steps; keep off-template messages in the queue.

The inactive-recent reactivation journey (the segment already exists in SEGMENT_QUERIES): users who signed up recently with zero completed generations and no games. It's a clear, valuable win, the audience is well-defined, and personalization has obvious hooks (their landing page tells you what they came for).

  • Step 0 (t+0): "you came to make X, here's the 60-second path" (intent personalized by landing_page).
  • Step 1 (t+3d, if no open): different angle / different subject.
  • Step 2 (t+7d, if opened but not activated): a specific made-on-Cinevva example matching their interest.
  • Exit on activation (first completed generation or game).