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)
| Primitive | Where | Reused for |
|---|---|---|
| Email send path (suppression, dedup, rate limit, tracking, RFC 8058 unsubscribe) | src/email-send.ts, src/email-tracking.ts | All Phase 1 sends go through sendCampaignEmail() unchanged |
| Open/click events | email_events table | Step 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 signal | users, generations, games, analytics_sessions | buildLeadProfile() joins these for the LLM |
| Segment queries | SEGMENT_QUERIES in src/email-send.ts | Journey entry conditions |
| LLM client | OpenAI responses API, pattern in src/game-creator.ts | generateNurtureMessage() |
| Rate limits | KV 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)
-- 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
// 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)
- Enroll. For each
activejourney, find leads matchingentry_segment/entry_rule_jsonnot already innurture_lead_statefor that journey, and not suppressed. Insert state rows (status=active,next_action_at = now + step0.delay_seconds, respecting quiet hours viausers.timezone). - Draft due steps. Select
activeleads wherenext_action_at <= now, capped (e.g. 10 per run to respect cost and the 90/day Resend ceiling). For each: evaluate the stepcondition_jsonagainstemail_eventsforlast_send_id(opened? clicked?). If condition fails, branch or exit per the step. Otherwise build the profile, generate + validate the message, insert intonurture_outbox(pending_approval), and set the leadstatus=awaiting_approval. Enforce themax_per_7dfrequency 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:
await advanceJourneys(env); // task 12
await processApprovedOutbox(env); // task 13Human-approval flow (admin)
GET /admin/nurture/outbox(admin-gated like/admin/gtm): listpending_approvalwith the lead profile summary, generated subject/body preview, and validation flags.POST /admin/nurture/outbox/:id/approve(optional edited subject/body in the body) → setsapproved.POST /admin/nurture/outbox/:id/reject(reason) → setsrejected, exits or pauses the lead.- A CLI helper mirroring
scripts/email-campaign.shfor 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_atrespectsusers.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=pausedto 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.audienceandnurture_lead_state.crm_contact_idreserve the B2B track. A Zoho sync (currently only an external MCP, not in the worker) would populatecrm_contact_idand a contacts table, andbuildLeadProfile()gains a CRM branch.nurture_journey_steps.channelalready abstracts the channel. Phase 2 adds adispatch(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.
Recommended first journey
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).