← Monday's Prompts

Automate User Onboarding 🚀

Turn Monday's 3 prompts into intelligent onboarding flows

September 30, 2025
🚀 Product⚡ TypeScript + Python📈 100 → 10K users/day

The Problem

On Monday you tested the 3 prompts in ChatGPT. You saw how user profiling → adaptive paths → progress tracking works. But here's the reality: you can't manually customize onboarding for 1,000 new users per day. One-size-fits-all flows get 20% activation. Personalized flows get 60%. The difference? Automation that actually understands user context.

3+ hours
Daily manual intervention per PM
20% activation
With generic one-path onboarding
Can't scale
Beyond 100-200 users/day manually

See It Work

Watch the 3 prompts chain together automatically. This is what you'll build to personalize onboarding at scale.

The Code

Three levels: start simple, add intelligence, then scale to production. Pick where you are and what you need.

Level 1: Simple Sequential Flow

Good for: 0-100 users/day | Setup time: 30 minutes

// Simple Sequential Onboarding (0-100 users/day)
import Anthropic from '@anthropic-ai/sdk';
import { SegmentClient } from '@segment/analytics-node';

interface OnboardingResult {
  userProfile: any;
  recommendedPath: any;
  nextSteps: any[];
}

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY!,
});

const segment = new SegmentClient({
  writeKey: process.env.SEGMENT_WRITE_KEY!,
});

async function automateOnboarding(
  userId: string,
  signupData: any,
  behaviorData: any
): Promise<OnboardingResult> {
  // Step 1: Profile user from signup + behavior
  const profilePrompt = `Analyze this user signup and behavior data to create a user profile.
  
Signup data:
${JSON.stringify(signupData, null, 2)}

Behavior data:
${JSON.stringify(behaviorData, null, 2)}

Extract:
- User persona (PM, developer, marketer, etc)
- Company segment (enterprise, SMB, startup)
- Intent signals (what they're trying to accomplish)
- Technical level (beginner, intermediate, advanced)
- Activation risk (low, medium, high)

Output as JSON only.`;

  const profileResponse = await anthropic.messages.create({
    model: 'claude-3-5-sonnet-20241022',
    max_tokens: 2048,
    messages: [{ role: 'user', content: profilePrompt }],
  });

  const profileContent = profileResponse.content[0];
  if (profileContent.type !== 'text') throw new Error('Invalid response');
  const userProfile = JSON.parse(profileContent.text);

  // Step 2: Generate personalized onboarding path
  const pathPrompt = `Based on this user profile, generate a personalized onboarding flow.

User profile:
${JSON.stringify(userProfile, null, 2)}

Create 3-5 onboarding steps that:
- Address their specific intent signals
- Match their technical level
- Skip irrelevant generic steps
- Prioritize activation drivers

For each step include:
- title: Clear action-oriented title
- description: Why this step matters to THEM
- cta: Button text
- estimated_time: How long it takes
- priority: critical/high/medium/low

Output as JSON array.`;

  const pathResponse = await anthropic.messages.create({
    model: 'claude-3-5-sonnet-20241022',
    max_tokens: 2048,
    messages: [{ role: 'user', content: pathPrompt }],
  });

  const pathContent = pathResponse.content[0];
  if (pathContent.type !== 'text') throw new Error('Invalid response');
  const recommendedPath = JSON.parse(pathContent.text);

  // Step 3: Track initial state in Segment
  segment.track({
    userId,
    event: 'Onboarding Started',
    properties: {
      persona: userProfile.persona,
      activation_risk: userProfile.activation_risk,
      flow_type: recommendedPath.flow_name,
      total_steps: recommendedPath.steps?.length || 0,
    },
  });

  return {
    userProfile,
    recommendedPath,
    nextSteps: recommendedPath.steps || [],
  };
}

// Usage
const result = await automateOnboarding(
  'usr_abc123',
  {
    email: 'sarah@techstartup.com',
    company: 'TechCorp',
    role: 'Product Manager',
    signup_source: 'organic_search',
  },
  {
    viewed_features: ['gantt_charts', 'integrations'],
    viewed_pricing: true,
    integration_interest: 'asana',
  }
);

console.log(`Generated ${result.nextSteps.length}-step personalized flow`);

Level 2: With Progress Tracking & Nudges

Good for: 100-1,000 users/day | Setup time: 2 hours

# With Progress Tracking & Automated Nudges (100-1000 users/day)
import anthropic
import segment.analytics as analytics
from datetime import datetime, timedelta
import redis
import json

analytics.write_key = os.getenv('SEGMENT_WRITE_KEY')
client = anthropic.Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY'))
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)

class OnboardingTracker:
    def __init__(self, user_id: str):
        self.user_id = user_id
        self.state_key = f'onboarding:{user_id}'
    
    def get_state(self) -> dict:
        """Get current onboarding state from Redis"""
        state_json = redis_client.get(self.state_key)
        if state_json:
            return json.loads(state_json)
        return {
            'current_step': 0,
            'completed_steps': [],
            'started_at': datetime.now().isoformat(),
            'last_activity': datetime.now().isoformat(),
            'stuck_count': 0
        }
    
    def update_state(self, updates: dict):
        """Update onboarding state"""
        state = self.get_state()
        state.update(updates)
        state['last_activity'] = datetime.now().isoformat()
        redis_client.setex(
            self.state_key,
            timedelta(days=30),
            json.dumps(state)
        )
        return state
    
    def check_stuck(self) -> bool:
        """Detect if user is stuck on current step"""
        state = self.get_state()
        last_activity = datetime.fromisoformat(state['last_activity'])
        time_since_activity = datetime.now() - last_activity
        
        # Stuck if no progress in 5 minutes
        if time_since_activity > timedelta(minutes=5):
            state['stuck_count'] += 1
            self.update_state(state)
            return True
        return False

def generate_nudge(user_profile: dict, current_step: dict, stuck_reason: str) -> dict:
    """Generate personalized intervention message"""
    nudge_prompt = f"""Generate a helpful intervention message for a stuck user.
    
User profile:
{json.dumps(user_profile, indent=2)}

Current step they're stuck on:
{json.dumps(current_step, indent=2)}

Stuck reason: {stuck_reason}

Generate:
- A short, encouraging in-app message (title + body + 2 CTAs)
- An email follow-up (subject + preview + body)

Be specific to their situation. Don't be generic.
Use their first name. Reference what they were trying to do.

Output as JSON with 'in_app' and 'email' keys."""
    
    response = client.messages.create(
        model='claude-3-5-sonnet-20241022',
        max_tokens=2048,
        messages=[{'role': 'user', 'content': nudge_prompt}]
    )
    
    return json.loads(response.content[0].text)

def automate_onboarding_with_tracking(user_id: str, event_type: str, event_data: dict):
    """Handle onboarding events with progress tracking"""
    tracker = OnboardingTracker(user_id)
    state = tracker.get_state()
    
    if event_type == 'step_viewed':
        # User viewed a step - check if stuck
        view_count = state.get(f"step_{event_data['step_number']}_views", 0) + 1
        tracker.update_state({
            f"step_{event_data['step_number']}_views": view_count
        })
        
        # If viewed same step 3+ times, they're stuck
        if view_count >= 3 and tracker.check_stuck():
            # Generate personalized nudge
            user_profile = json.loads(redis_client.get(f'profile:{user_id}'))
            current_step = event_data['step_data']
            
            nudge = generate_nudge(
                user_profile,
                current_step,
                f"Viewed step {view_count} times without completing"
            )
            
            # Send in-app message immediately
            analytics.track(
                user_id=user_id,
                event='Nudge Sent',
                properties={
                    'channel': 'in_app',
                    'trigger': 'stuck_on_step',
                    'step': event_data['step_number']
                }
            )
            
            # Schedule email for 5 minutes if still stuck
            # (Use Celery or similar for delayed tasks)
            
            return {'action': 'nudge_sent', 'nudge': nudge}
    
    elif event_type == 'step_completed':
        # User completed a step - celebrate and move forward
        completed_steps = state['completed_steps']
        completed_steps.append(event_data['step_number'])
        
        tracker.update_state({
            'completed_steps': completed_steps,
            'current_step': event_data['step_number'] + 1,
            'stuck_count': 0  # Reset stuck counter
        })
        
        analytics.track(
            user_id=user_id,
            event='Onboarding Step Completed',
            properties={
                'step': event_data['step_number'],
                'total_completed': len(completed_steps),
                'time_to_complete': event_data.get('time_spent')
            }
        )
        
        return {'action': 'step_completed', 'next_step': event_data['step_number'] + 1}
    
    return {'action': 'tracked', 'state': state}

# Usage
result = automate_onboarding_with_tracking(
    user_id='usr_abc123',
    event_type='step_viewed',
    event_data={
        'step_number': 1,
        'step_data': {'title': 'Import Asana Projects', 'action': 'connect_integration'}
    }
)

if result['action'] == 'nudge_sent':
    print(f"Sent nudge: {result['nudge']['in_app']['title']}")

Level 3: Production Multi-Channel System

Good for: 1,000+ users/day | Setup time: 1 day

// Production Multi-Channel Onboarding System (1000+ users/day)
import Anthropic from '@anthropic-ai/sdk';
import { SegmentClient } from '@segment/analytics-node';
import { createClient } from 'redis';
import { Queue, Worker } from 'bullmq';
import Mixpanel from 'mixpanel';

interface OnboardingState {
  userId: string;
  currentStep: number;
  completedSteps: number[];
  startedAt: Date;
  lastActivity: Date;
  stuckCount: number;
  persona: string;
  activationRisk: string;
  flowType: string;
}

interface NudgeConfig {
  trigger: string;
  channel: 'in_app' | 'email' | 'sms' | 'push';
  delay: number;
  conditions: any;
}

class OnboardingOrchestrator {
  private anthropic: Anthropic;
  private segment: SegmentClient;
  private redis: ReturnType<typeof createClient>;
  private nudgeQueue: Queue;
  private mixpanel: any;

  constructor() {
    this.anthropic = new Anthropic({
      apiKey: process.env.ANTHROPIC_API_KEY!,
    });
    
    this.segment = new SegmentClient({
      writeKey: process.env.SEGMENT_WRITE_KEY!,
    });
    
    this.redis = createClient({
      url: process.env.REDIS_URL,
    });
    
    this.nudgeQueue = new Queue('onboarding-nudges', {
      connection: { host: 'localhost', port: 6379 },
    });
    
    this.mixpanel = Mixpanel.init(process.env.MIXPANEL_TOKEN!);
  }

  async initialize() {
    await this.redis.connect();
    this.startNudgeWorker();
  }

  async profileAndRoute(userId: string, signupData: any, behaviorData: any) {
    // Step 1: Generate user profile with caching
    const cacheKey = `profile:${userId}`;
    let profile = await this.redis.get(cacheKey);
    
    if (!profile) {
      const profileResponse = await this.anthropic.messages.create({
        model: 'claude-3-5-sonnet-20241022',
        max_tokens: 2048,
        messages: [
          {
            role: 'user',
            content: `Profile this user: ${JSON.stringify({ signupData, behaviorData })}`,
          },
        ],
      });
      
      const content = profileResponse.content[0];
      if (content.type !== 'text') throw new Error('Invalid response');
      profile = content.text;
      
      // Cache for 30 days
      await this.redis.setEx(cacheKey, 2592000, profile);
    }
    
    const userProfile = JSON.parse(profile);
    
    // Step 2: Select optimal onboarding flow
    const flowType = this.selectFlow(userProfile);
    const onboardingSteps = await this.generateSteps(userProfile, flowType);
    
    // Step 3: Initialize state
    const state: OnboardingState = {
      userId,
      currentStep: 0,
      completedSteps: [],
      startedAt: new Date(),
      lastActivity: new Date(),
      stuckCount: 0,
      persona: userProfile.persona,
      activationRisk: userProfile.activation_risk,
      flowType,
    };
    
    await this.redis.setEx(
      `onboarding:${userId}`,
      2592000,
      JSON.stringify(state)
    );
    
    // Step 4: Track in multiple analytics platforms
    this.segment.track({
      userId,
      event: 'Onboarding Started',
      properties: {
        persona: userProfile.persona,
        flow_type: flowType,
        activation_risk: userProfile.activation_risk,
      },
    });
    
    this.mixpanel.track('Onboarding Started', {
      distinct_id: userId,
      persona: userProfile.persona,
      flow_type: flowType,
    });
    
    // Step 5: Schedule proactive nudges
    await this.scheduleNudges(userId, state, onboardingSteps);
    
    return { profile: userProfile, flow: onboardingSteps, state };
  }

  private selectFlow(profile: any): string {
    // Rule-based flow selection with ML scoring
    const flowScores = {
      competitor_migration: 0,
      power_user_fast_track: 0,
      beginner_guided: 0,
      team_admin_setup: 0,
    };
    
    // Score based on signals
    if (profile.integration_interest) flowScores.competitor_migration += 0.4;
    if (profile.technical_level === 'advanced') flowScores.power_user_fast_track += 0.3;
    if (profile.role === 'admin') flowScores.team_admin_setup += 0.3;
    if (profile.activation_risk === 'high') flowScores.beginner_guided += 0.5;
    
    // Return highest scoring flow
    return Object.entries(flowScores).sort((a, b) => b[1] - a[1])[0][0];
  }

  private async generateSteps(profile: any, flowType: string) {
    const response = await this.anthropic.messages.create({
      model: 'claude-3-5-sonnet-20241022',
      max_tokens: 2048,
      messages: [
        {
          role: 'user',
          content: `Generate ${flowType} onboarding steps for: ${JSON.stringify(profile)}`,
        },
      ],
    });
    
    const content = response.content[0];
    if (content.type !== 'text') throw new Error('Invalid response');
    return JSON.parse(content.text);
  }

  private async scheduleNudges(
    userId: string,
    state: OnboardingState,
    steps: any[]
  ) {
    // Schedule nudges for each step
    const nudgeConfigs: NudgeConfig[] = [
      {
        trigger: 'no_progress_5min',
        channel: 'in_app',
        delay: 300000, // 5 minutes
        conditions: { current_step: state.currentStep },
      },
      {
        trigger: 'no_progress_1hour',
        channel: 'email',
        delay: 3600000, // 1 hour
        conditions: { completion_rate: { $lt: 0.5 } },
      },
      {
        trigger: 'abandoned_24hours',
        channel: 'email',
        delay: 86400000, // 24 hours
        conditions: { completed_steps: { $eq: 0 } },
      },
    ];
    
    for (const config of nudgeConfigs) {
      await this.nudgeQueue.add(
        config.trigger,
        { userId, state, steps, config },
        { delay: config.delay }
      );
    }
  }

  private startNudgeWorker() {
    const worker = new Worker(
      'onboarding-nudges',
      async (job) => {
        const { userId, state, steps, config } = job.data;
        
        // Check if conditions still met
        const currentState = await this.getState(userId);
        if (!this.checkNudgeConditions(currentState, config.conditions)) {
          return { skipped: true, reason: 'conditions_not_met' };
        }
        
        // Generate personalized nudge
        const nudge = await this.generateNudge(
          currentState,
          steps[currentState.currentStep],
          config.trigger
        );
        
        // Send via appropriate channel
        await this.sendNudge(userId, config.channel, nudge);
        
        return { sent: true, channel: config.channel };
      },
      { connection: { host: 'localhost', port: 6379 } }
    );
  }

  private async getState(userId: string): Promise<OnboardingState> {
    const stateJson = await this.redis.get(`onboarding:${userId}`);
    return stateJson ? JSON.parse(stateJson) : null;
  }

  private checkNudgeConditions(state: OnboardingState, conditions: any): boolean {
    // Implement condition checking logic
    if (conditions.current_step !== undefined) {
      return state.currentStep === conditions.current_step;
    }
    if (conditions.completion_rate) {
      const rate = state.completedSteps.length / 4; // Assuming 4 total steps
      return rate < conditions.completion_rate.$lt;
    }
    return true;
  }

  private async generateNudge(
    state: OnboardingState,
    currentStep: any,
    trigger: string
  ) {
    const response = await this.anthropic.messages.create({
      model: 'claude-3-5-sonnet-20241022',
      max_tokens: 1024,
      messages: [
        {
          role: 'user',
          content: `Generate nudge for trigger '${trigger}': ${JSON.stringify({ state, currentStep })}`,
        },
      ],
    });
    
    const content = response.content[0];
    if (content.type !== 'text') throw new Error('Invalid response');
    return JSON.parse(content.text);
  }

  private async sendNudge(userId: string, channel: string, nudge: any) {
    // Send via appropriate channel (Intercom, Customer.io, Twilio, etc)
    this.segment.track({
      userId,
      event: 'Nudge Sent',
      properties: { channel, trigger: nudge.trigger },
    });
  }
}

// Usage
const orchestrator = new OnboardingOrchestrator();
await orchestrator.initialize();

const result = await orchestrator.profileAndRoute(
  'usr_abc123',
  { email: 'sarah@techstartup.com', role: 'Product Manager' },
  { viewed_features: ['gantt_charts'], integration_interest: 'asana' }
);

console.log(`Started ${result.flow.flow_type} with ${result.flow.steps.length} steps`);

When to Level Up

1

Start: Simple Sequential Flow

0-100 users/day

  • Basic user profiling with LLM
  • Static onboarding paths (3-4 pre-defined flows)
  • Manual progress tracking in Segment
  • Basic email follow-ups (scheduled sends)
2

Scale: Add Intelligence & Tracking

100-1,000 users/day

  • Real-time progress tracking in Redis
  • Automated stuck detection (view counts, time thresholds)
  • Dynamic nudge generation based on user context
  • Multi-channel interventions (in-app + email)
  • A/B testing different onboarding paths
3

Production: Multi-Channel Orchestration

1,000-5,000 users/day

  • Rule-based + ML flow selection
  • Queue-based nudge scheduling (BullMQ)
  • Multi-platform analytics (Segment + Mixpanel + Amplitude)
  • Conditional routing (if stuck → intervention, if progressing → celebrate)
  • Integration with product analytics for behavior signals
4

Enterprise: Predictive Activation System

5,000+ users/day

  • ML-based activation prediction (LightGBM/XGBoost on behavior signals)
  • Real-time personalization engine (feature flags, dynamic content)
  • Multi-agent system (profiling agent, routing agent, intervention agent)
  • Cross-platform state sync (web + mobile + email)
  • Advanced experimentation platform (multi-armed bandits for flow optimization)

Product-Specific Gotchas

The code examples work. But product onboarding has unique challenges you need to handle.

Behavior Signal Lag in Product Analytics

Segment/Mixpanel can have 30-60 second delays. If you base nudges on stale data, you'll send interventions after users already progressed. Use Redis for real-time state, sync to analytics async.

// Real-time state in Redis, async sync to analytics
class StateManager {
  async updateProgress(userId: string, step: number) {
    // 1. Update Redis immediately (< 10ms)
    await redis.set(
      `onboarding:${userId}:step`,
      step,
      { EX: 2592000 } // 30 days
    );
    
    // 2. Queue analytics update (non-blocking)
    await analyticsQueue.add('track', {
      userId,
      event: 'Step Completed',
      properties: { step },
    });
    
    // 3. Check for nudge triggers on fresh data
    const state = await this.getRealtimeState(userId);
    if (this.shouldNudge(state)) {
      await this.sendNudge(userId, state);
    }
  }
}

Mobile vs Web State Synchronization

Users start onboarding on web, continue on mobile (or vice versa). If state isn't synced across platforms, you'll show step 1 on mobile after they completed step 3 on web. Use shared state store (Redis/DynamoDB) with platform-agnostic keys.

# Platform-agnostic state management
import redis
import json

class CrossPlatformState:
    def __init__(self, user_id: str):
        self.user_id = user_id
        self.redis = redis.Redis()
        self.state_key = f'onboarding:{user_id}'  # No platform suffix
    
    def get_state(self, platform: str = None) -> dict:
        """Get state regardless of platform"""
        state_json = self.redis.get(self.state_key)
        if not state_json:
            return self.initialize_state()
        
        state = json.loads(state_json)
        # Add platform context for logging only
        state['last_platform'] = platform
        return state
    
    def update_state(self, updates: dict, platform: str):
        """Update from any platform"""
        state = self.get_state()
        state.update(updates)
        state['last_platform'] = platform
        state['last_updated'] = datetime.now().isoformat()
        
        # Atomic update with optimistic locking
        self.redis.set(self.state_key, json.dumps(state))
        
        # Broadcast to other platforms via pub/sub
        self.redis.publish(
            f'onboarding:updates:{self.user_id}',
            json.dumps({'platform': platform, 'updates': updates})
        )

# Usage across platforms
state_manager = CrossPlatformState('usr_abc123')

# Web completes step 3
state_manager.update_state({'current_step': 3}, platform='web')

# Mobile immediately sees step 3
mobile_state = state_manager.get_state(platform='mobile')
print(mobile_state['current_step'])  # 3, not 0

Feature Flag Integration for Dynamic Flows

Onboarding flows change as you ship features. Hardcoded flows break when features are behind flags. Integrate with LaunchDarkly/Split.io to show steps only if user has access to that feature.

// Dynamic onboarding based on feature flags
import { LDClient } from 'launchdarkly-node-server-sdk';

const ldClient = LDClient.init(process.env.LAUNCHDARKLY_SDK_KEY!);

interface OnboardingStep {
  id: string;
  title: string;
  feature_flag?: string;
  fallback_step?: string;
}

async function filterStepsByFlags(
  userId: string,
  userContext: any,
  steps: OnboardingStep[]
): Promise<OnboardingStep[]> {
  const availableSteps: OnboardingStep[] = [];
  
  for (const step of steps) {
    if (!step.feature_flag) {
      // No flag = always show
      availableSteps.push(step);
      continue;
    }
    
    // Check if user has access to feature
    const hasAccess = await ldClient.variation(
      step.feature_flag,
      userContext,
      false // default = hidden
    );
    
    if (hasAccess) {
      availableSteps.push(step);
    } else if (step.fallback_step) {
      // Show fallback if feature not available
      const fallback = steps.find(s => s.id === step.fallback_step);
      if (fallback) availableSteps.push(fallback);
    }
  }
  
  return availableSteps;
}

// Usage
const allSteps = [
  { id: 'import_data', title: 'Import Data' },
  {
    id: 'setup_ai_assistant',
    title: 'Set Up AI Assistant',
    feature_flag: 'ai_assistant_enabled',
    fallback_step: 'manual_setup',
  },
  { id: 'manual_setup', title: 'Manual Setup' },
];

const userSteps = await filterStepsByFlags(
  'usr_abc123',
  { key: 'usr_abc123', plan: 'enterprise' },
  allSteps
);

// Enterprise user sees AI assistant step
// Free user sees manual setup instead

Timezone-Aware Nudge Scheduling

Sending email nudges at 3 AM user local time kills engagement. Store user timezone (from signup IP or explicit selection), schedule nudges for optimal hours (9 AM - 8 PM local time).

# Timezone-aware nudge scheduling
from datetime import datetime, timedelta
import pytz
from timezonefinder import TimezoneFinder
import geoip2.database

class TimezoneAwareScheduler:
    def __init__(self):
        self.tf = TimezoneFinder()
        self.geoip = geoip2.database.Reader('GeoLite2-City.mmdb')
    
    def get_user_timezone(self, user_id: str, signup_ip: str) -> str:
        """Determine user timezone from IP or stored preference"""
        # Check if user set timezone explicitly
        stored_tz = redis.get(f'user:{user_id}:timezone')
        if stored_tz:
            return stored_tz.decode()
        
        # Fallback to IP geolocation
        try:
            response = self.geoip.city(signup_ip)
            lat, lng = response.location.latitude, response.location.longitude
            timezone = self.tf.timezone_at(lat=lat, lng=lng)
            
            # Cache for future use
            redis.set(f'user:{user_id}:timezone', timezone)
            return timezone
        except:
            return 'America/New_York'  # Default fallback
    
    def schedule_nudge(
        self,
        user_id: str,
        signup_ip: str,
        nudge_type: str,
        delay_hours: int
    ):
        """Schedule nudge for optimal local time"""
        user_tz = self.get_user_timezone(user_id, signup_ip)
        user_time = datetime.now(pytz.timezone(user_tz))
        
        # Calculate target time (delay_hours from now)
        target_time = user_time + timedelta(hours=delay_hours)
        
        # Adjust to business hours (9 AM - 8 PM)
        if target_time.hour < 9:
            target_time = target_time.replace(hour=9, minute=0)
        elif target_time.hour >= 20:
            # Push to next day 9 AM
            target_time = (target_time + timedelta(days=1)).replace(hour=9, minute=0)
        
        # Convert to UTC for queue
        target_utc = target_time.astimezone(pytz.UTC)
        delay_seconds = int((target_utc - datetime.now(pytz.UTC)).total_seconds())
        
        # Schedule in queue
        nudge_queue.add(
            nudge_type,
            {'user_id': user_id},
            delay=delay_seconds * 1000  # BullMQ uses milliseconds
        )
        
        return target_time.isoformat()

# Usage
scheduler = TimezoneAwareScheduler()

# Schedule "no progress" email for 1 hour from now
# But adjust to user's business hours
scheduled_time = scheduler.schedule_nudge(
    user_id='usr_abc123',
    signup_ip='203.0.113.42',
    nudge_type='no_progress_1hour',
    delay_hours=1
)

print(f"Nudge scheduled for {scheduled_time} user local time")

Preventing Nudge Fatigue with Frequency Caps

Sending 5 nudges in 1 hour annoys users. Implement frequency caps: max 2 in-app messages per hour, 1 email per day, 1 SMS per week. Track across all channels.

// Frequency cap enforcement
import { Redis } from 'ioredis';

interface FrequencyCap {
  channel: string;
  max_count: number;
  window_seconds: number;
}

const FREQUENCY_CAPS: FrequencyCap[] = [
  { channel: 'in_app', max_count: 2, window_seconds: 3600 }, // 2 per hour
  { channel: 'email', max_count: 1, window_seconds: 86400 }, // 1 per day
  { channel: 'sms', max_count: 1, window_seconds: 604800 }, // 1 per week
  { channel: 'push', max_count: 3, window_seconds: 86400 }, // 3 per day
];

class NudgeFrequencyManager {
  private redis: Redis;

  constructor() {
    this.redis = new Redis(process.env.REDIS_URL);
  }

  async canSendNudge(
    userId: string,
    channel: string
  ): Promise<{ allowed: boolean; reason?: string; retry_after?: number }> {
    const cap = FREQUENCY_CAPS.find((c) => c.channel === channel);
    if (!cap) return { allowed: true }; // No cap = always allow

    const key = `nudge_freq:${userId}:${channel}`;
    const count = await this.redis.get(key);

    if (!count) {
      // First nudge in window
      return { allowed: true };
    }

    const currentCount = parseInt(count, 10);
    if (currentCount >= cap.max_count) {
      const ttl = await this.redis.ttl(key);
      return {
        allowed: false,
        reason: `Frequency cap reached: ${currentCount}/${cap.max_count} in ${cap.window_seconds}s`,
        retry_after: ttl,
      };
    }

    return { allowed: true };
  }

  async recordNudge(userId: string, channel: string): Promise<void> {
    const cap = FREQUENCY_CAPS.find((c) => c.channel === channel);
    if (!cap) return;

    const key = `nudge_freq:${userId}:${channel}`;
    const current = await this.redis.get(key);

    if (!current) {
      // First nudge - set with expiry
      await this.redis.setex(key, cap.window_seconds, '1');
    } else {
      // Increment existing counter
      await this.redis.incr(key);
    }
  }

  async getNudgeHistory(
    userId: string,
    channel?: string
  ): Promise<{ channel: string; count: number; window_remaining: number }[]> {
    const pattern = channel
      ? `nudge_freq:${userId}:${channel}`
      : `nudge_freq:${userId}:*`;

    const keys = await this.redis.keys(pattern);
    const history = [];

    for (const key of keys) {
      const [, , , channelName] = key.split(':');
      const count = parseInt((await this.redis.get(key)) || '0', 10);
      const ttl = await this.redis.ttl(key);

      history.push({
        channel: channelName,
        count,
        window_remaining: ttl,
      });
    }

    return history;
  }
}

// Usage
const freqManager = new NudgeFrequencyManager();

// Before sending nudge
const canSend = await freqManager.canSendNudge('usr_abc123', 'email');

if (canSend.allowed) {
  // Send the nudge
  await sendEmailNudge('usr_abc123', nudgeContent);
  
  // Record that we sent it
  await freqManager.recordNudge('usr_abc123', 'email');
} else {
  console.log(`Skipped nudge: ${canSend.reason}`);
  console.log(`Retry after ${canSend.retry_after} seconds`);
}

// Check user's nudge history
const history = await freqManager.getNudgeHistory('usr_abc123');
console.log('Recent nudges:', history);
// Output: [{ channel: 'email', count: 1, window_remaining: 82341 }]

Cost Calculator

Manual Onboarding Intervention

PM time reviewing stuck users (2 hrs/day)
$50/hr fully loaded
$100/day
Support team answering onboarding questions (4 hrs/day)
$30/hr fully loaded
$120/day
Manual email follow-ups (1 hr/day)
Marketing coordinator time
$30/day
Lost activation revenue (40% vs 60% rate)
100 signups/day × $100 LTV × 20% gap
$2000/day
Total:$2,250/day
daily

Limitations:

  • Can't scale beyond 100-200 users/day
  • Inconsistent experience (depends on who responds)
  • Slow response time (hours to days)
  • No data-driven optimization

Automated Onboarding Platform

LLM API costs (Claude 3.5 Sonnet)
1000 users × 3 calls × $0.015/call
$45/day
Redis hosting (managed instance)
ElastiCache r6g.large
$10/day
Segment/Mixpanel API costs
Event tracking volume
$20/day
Email/SMS delivery (Customer.io)
Automated nudges
$15/day
Queue infrastructure (BullMQ + workers)
Compute for background jobs
$5/day
PM time for optimization (30 min/day)
Reviewing metrics, tweaking flows
$25/day
Total:$120/day
daily

Benefits:

  • Scales to 10,000+ users/day with same infrastructure
  • Consistent personalized experience for every user
  • Real-time interventions (seconds vs hours)
  • Data-driven continuous optimization
  • 60% activation rate (vs 20% manual)
$2,130/day saved
95% cost reduction | $63,900/month | $766,800/year
💡 Pays for itself in 2 days. After that, pure profit.
🚀

Want This Running in Your Product?

We build custom onboarding automation systems that actually drive activation. From profiling to nudges to analytics integration. Production-ready in 2 weeks.