Multi-Turn Conversation Flows for ChatGPT Apps: Complete Implementation Guide

Multi-turn conversation flows are the backbone of sophisticated ChatGPT applications. Unlike single-shot Q&A interactions, multi-turn conversations maintain context across multiple user inputs, enabling complex workflows like booking appointments, processing orders, or conducting diagnostic interviews. This guide demonstrates five proven architectural patterns for building robust conversation flows that feel natural and intelligent.

Table of Contents

  1. Understanding Multi-Turn Conversation Architecture
  2. State Machine Pattern
  3. Conversation Graph Pattern
  4. Slot Filling Pattern
  5. Context Stacking Pattern
  6. Conversation Memory Manager
  7. Topic Switching and Recovery
  8. Production Best Practices

Understanding Multi-Turn Conversation Architecture

Multi-turn conversations differ fundamentally from stateless interactions. Each user message must be interpreted within the context of previous exchanges, tracking conversation state, user intent, and collected information across turns.

Key Challenges in Multi-Turn Conversations

Context Continuity: Maintaining conversation context across turns without losing track of the user's original goal. A user might ask "What's the price?" five turns into a conversation about booking a yoga class—your app needs to know they're asking about the class price, not a generic inquiry.

State Management: Tracking where users are in multi-step workflows (e.g., booking flow: check availability → select time → confirm → payment). State must persist across HTTP requests in ChatGPT's stateless architecture.

Slot Filling: Collecting required information gradually across turns ("Which day works for you?" → "What time?" → "Your email?"). Missing slots should be requested naturally, not via robotic forms.

Topic Switching: Handling context shifts when users change subjects mid-conversation ("Actually, I want to book a massage instead of yoga"). The system must gracefully abandon partial state and start fresh.

Conversation Memory: Referencing earlier conversation turns ("Use the same time as last week" or "The email I gave you earlier"). This requires persistent memory beyond immediate context.

The following five patterns address these challenges with production-ready code examples.

State Machine Pattern

State machines model conversations as discrete states (nodes) and transitions (edges). Each state represents a conversation stage, with transitions triggered by user input or app logic. This pattern excels for linear workflows with clear stages.

When to Use State Machines

  • Booking flows: Check availability → Select time → Confirm → Payment
  • Onboarding sequences: Welcome → Collect name → Collect email → Set preferences → Complete
  • Diagnostic interviews: Symptom description → Severity questions → Recommendation
  • Form filling: Sequential data collection with validation at each step

State Machine Implementation

// state-machine-conversation.js - 120 lines
// State machine for fitness class booking flow

class StateMachineConversation {
  constructor(userId) {
    this.userId = userId;
    this.currentState = 'INITIAL';
    this.context = {};

    // Define state machine transitions
    this.states = {
      INITIAL: {
        enter: () => this.askClassType(),
        transitions: { HAS_CLASS_TYPE: 'SELECT_DATE' }
      },
      SELECT_DATE: {
        enter: () => this.askDate(),
        transitions: {
          HAS_DATE: 'SELECT_TIME',
          INVALID_DATE: 'SELECT_DATE' // Stay in same state
        }
      },
      SELECT_TIME: {
        enter: () => this.askTime(),
        transitions: {
          HAS_TIME: 'CONFIRM',
          NO_AVAILABILITY: 'SELECT_DATE' // Go back
        }
      },
      CONFIRM: {
        enter: () => this.showConfirmation(),
        transitions: {
          CONFIRMED: 'PAYMENT',
          CANCELLED: 'INITIAL' // Start over
        }
      },
      PAYMENT: {
        enter: () => this.processPayment(),
        transitions: {
          PAYMENT_SUCCESS: 'COMPLETE',
          PAYMENT_FAILED: 'PAYMENT' // Retry payment
        }
      },
      COMPLETE: {
        enter: () => this.sendConfirmationEmail(),
        transitions: {}
      }
    };
  }

  // Process user message and advance state
  async processMessage(userMessage) {
    const currentStateConfig = this.states[this.currentState];

    // Extract entities from user message
    const entities = await this.extractEntities(userMessage);

    // Determine transition based on entities
    const transition = this.determineTransition(entities);

    if (transition && currentStateConfig.transitions[transition]) {
      // Valid transition - move to next state
      const nextState = currentStateConfig.transitions[transition];
      this.currentState = nextState;

      // Execute entry action for new state
      return await this.states[nextState].enter();
    } else {
      // No valid transition - stay in current state, ask for clarification
      return this.handleInvalidInput(userMessage);
    }
  }

  // State entry actions
  askClassType() {
    return {
      message: "What type of class would you like to book? We offer Yoga, Pilates, Spinning, and CrossFit.",
      expectedInput: "class type",
      currentState: this.currentState
    };
  }

  askDate() {
    const classType = this.context.classType;
    return {
      message: `Great choice! When would you like to attend ${classType}? (e.g., "tomorrow", "this Saturday", "December 28")`,
      expectedInput: "date",
      currentState: this.currentState
    };
  }

  askTime() {
    const availableTimes = this.context.availableSlots;
    return {
      message: `Available times for ${this.context.classType} on ${this.context.date}:\n${availableTimes.join('\n')}\n\nWhich time works best?`,
      expectedInput: "time selection",
      currentState: this.currentState
    };
  }

  showConfirmation() {
    const { classType, date, time, instructor } = this.context;
    return {
      message: `Perfect! You're booking:\n\n**${classType}** with ${instructor}\n${date} at ${time}\n\nConfirm booking?`,
      expectedInput: "yes/no confirmation",
      currentState: this.currentState,
      actions: ['Confirm Booking', 'Cancel']
    };
  }

  async processPayment() {
    // Integration with payment processor
    const paymentResult = await this.chargeCard(this.context.amount);

    if (paymentResult.success) {
      this.context.bookingId = paymentResult.bookingId;
      return await this.processMessage('PAYMENT_SUCCESS');
    } else {
      return {
        message: `Payment failed: ${paymentResult.error}. Please try again or contact support.`,
        currentState: this.currentState
      };
    }
  }

  sendConfirmationEmail() {
    const { email, bookingId, classType, date, time } = this.context;
    // Email sending logic here

    return {
      message: `Booking confirmed! 🎉\n\nConfirmation #${bookingId} sent to ${email}.\n\nSee you at ${classType} on ${date} at ${time}!`,
      currentState: this.currentState,
      conversationComplete: true
    };
  }

  // Transition logic
  determineTransition(entities) {
    switch (this.currentState) {
      case 'INITIAL':
        if (entities.classType) {
          this.context.classType = entities.classType;
          return 'HAS_CLASS_TYPE';
        }
        break;

      case 'SELECT_DATE':
        if (entities.date && this.isValidDate(entities.date)) {
          this.context.date = entities.date;
          this.context.availableSlots = this.fetchAvailableSlots(
            this.context.classType,
            entities.date
          );
          return 'HAS_DATE';
        } else {
          return 'INVALID_DATE';
        }

      case 'SELECT_TIME':
        if (entities.time && this.isSlotAvailable(entities.time)) {
          this.context.time = entities.time;
          return 'HAS_TIME';
        } else {
          return 'NO_AVAILABILITY';
        }

      case 'CONFIRM':
        if (entities.confirmation === 'yes') {
          return 'CONFIRMED';
        } else if (entities.confirmation === 'no') {
          return 'CANCELLED';
        }
        break;
    }

    return null; // No valid transition
  }

  handleInvalidInput(userMessage) {
    return {
      message: `I didn't quite catch that. ${this.states[this.currentState].enter().message}`,
      currentState: this.currentState
    };
  }

  // Helper methods (implementation details omitted for brevity)
  async extractEntities(message) { /* NLP entity extraction */ }
  isValidDate(date) { /* Date validation */ }
  fetchAvailableSlots(classType, date) { /* Database query */ }
  isSlotAvailable(time) { /* Check availability */ }
  async chargeCard(amount) { /* Payment processing */ }
}

module.exports = StateMachineConversation;

Key advantages: Predictable conversation flow, clear state transitions, easy to debug and visualize. Ideal for workflows where users follow a defined sequence.

Limitations: Rigid structure doesn't handle non-linear conversations well. Users who jump between steps (e.g., "Actually, change the date to Friday") require explicit back-transition handling.

For more flexible conversation routing, see the Conversation Graph Pattern below.

Conversation Graph Pattern

Conversation graphs extend state machines with non-linear navigation, allowing users to move freely between topics. This pattern uses a directed graph where nodes represent conversation topics and edges represent possible transitions.

When to Use Conversation Graphs

  • Customer support: Users jump between billing questions, technical support, and account management
  • Product discovery: Users explore different product categories non-linearly
  • Multi-topic assistants: Apps handling diverse requests (booking + FAQs + account management)
  • Exploratory conversations: Users don't follow a predetermined path

Conversation Graph Implementation

// conversation-graph.js - 130 lines
// Graph-based conversation routing for multi-topic chatbot

class ConversationGraph {
  constructor(userId) {
    this.userId = userId;
    this.currentNode = 'ROOT';
    this.conversationHistory = [];
    this.context = {};

    // Define conversation graph
    this.graph = {
      ROOT: {
        handler: () => this.welcomeMessage(),
        edges: {
          'book_class': 'BOOKING_FLOW',
          'check_schedule': 'SCHEDULE_VIEW',
          'account_settings': 'ACCOUNT_MENU',
          'pricing': 'PRICING_INFO',
          'help': 'HELP_MENU'
        }
      },

      BOOKING_FLOW: {
        handler: () => this.startBookingFlow(),
        edges: {
          'select_class': 'CLASS_SELECTION',
          'cancel': 'ROOT',
          'help': 'HELP_MENU'
        }
      },

      CLASS_SELECTION: {
        handler: () => this.showClassOptions(),
        edges: {
          'yoga': 'YOGA_BOOKING',
          'pilates': 'PILATES_BOOKING',
          'spinning': 'SPINNING_BOOKING',
          'back': 'BOOKING_FLOW',
          'check_schedule': 'SCHEDULE_VIEW' // Allow topic switch
        }
      },

      YOGA_BOOKING: {
        handler: () => this.bookYogaClass(),
        edges: {
          'select_time': 'TIME_SELECTION',
          'pricing': 'PRICING_INFO', // Jump to pricing mid-booking
          'cancel': 'ROOT'
        }
      },

      TIME_SELECTION: {
        handler: () => this.selectTime(),
        edges: {
          'confirm': 'PAYMENT',
          'change_date': 'CLASS_SELECTION',
          'back': 'YOGA_BOOKING'
        }
      },

      SCHEDULE_VIEW: {
        handler: () => this.showSchedule(),
        edges: {
          'book_class': 'BOOKING_FLOW',
          'filter_by_instructor': 'SCHEDULE_VIEW', // Self-loop with filter
          'back': 'ROOT'
        }
      },

      PRICING_INFO: {
        handler: () => this.showPricing(),
        edges: {
          'book_class': 'BOOKING_FLOW',
          'back': this.getPreviousNode() // Dynamic back navigation
        }
      },

      ACCOUNT_MENU: {
        handler: () => this.showAccountOptions(),
        edges: {
          'update_email': 'UPDATE_EMAIL',
          'cancel_membership': 'CANCEL_FLOW',
          'payment_methods': 'PAYMENT_SETTINGS',
          'back': 'ROOT'
        }
      },

      HELP_MENU: {
        handler: () => this.showHelpOptions(),
        edges: {
          'contact_support': 'SUPPORT_CONTACT',
          'faq': 'FAQ_VIEW',
          'back': this.getPreviousNode() // Return to previous context
        }
      }
    };
  }

  async processMessage(userMessage) {
    // Store message in history
    this.conversationHistory.push({
      role: 'user',
      message: userMessage,
      node: this.currentNode,
      timestamp: Date.now()
    });

    // Extract intent from user message
    const intent = await this.extractIntent(userMessage);

    // Get current node configuration
    const currentNodeConfig = this.graph[this.currentNode];

    // Check if intent matches an edge from current node
    if (currentNodeConfig.edges[intent]) {
      // Valid transition - navigate to new node
      const nextNode = currentNodeConfig.edges[intent];

      // Handle dynamic edge resolution (e.g., "back" to previous node)
      const resolvedNextNode = typeof nextNode === 'function'
        ? nextNode()
        : nextNode;

      this.currentNode = resolvedNextNode;

      // Execute handler for new node
      const response = await this.graph[this.currentNode].handler();

      this.conversationHistory.push({
        role: 'assistant',
        message: response.message,
        node: this.currentNode,
        timestamp: Date.now()
      });

      return response;
    } else {
      // Intent not found in current node's edges
      // Try global intent resolution (help, cancel, etc.)
      const globalIntent = this.resolveGlobalIntent(intent);

      if (globalIntent) {
        this.currentNode = globalIntent.node;
        return await this.graph[this.currentNode].handler();
      }

      // No valid transition - clarify with user
      return this.handleUnknownIntent(userMessage, currentNodeConfig.edges);
    }
  }

  // Node handlers
  welcomeMessage() {
    return {
      message: "Welcome to FitnessPro! 💪\n\nI can help you:\n• Book a class\n• Check your schedule\n• Manage your account\n• Answer pricing questions\n\nWhat would you like to do?",
      currentNode: this.currentNode,
      availableActions: Object.keys(this.graph[this.currentNode].edges)
    };
  }

  startBookingFlow() {
    return {
      message: "Great! Let's book a class. What type of class are you interested in?",
      currentNode: this.currentNode,
      availableActions: ['Yoga', 'Pilates', 'Spinning', 'CrossFit']
    };
  }

  showClassOptions() {
    return {
      message: "We offer:\n\n🧘 **Yoga** - Flexibility & mindfulness\n💪 **Pilates** - Core strength\n🚴 **Spinning** - Cardio workout\n🏋️ **CrossFit** - High-intensity training\n\nWhich interests you?",
      currentNode: this.currentNode
    };
  }

  async bookYogaClass() {
    const upcomingClasses = await this.fetchUpcomingClasses('yoga');

    return {
      message: `Yoga classes this week:\n\n${this.formatClassList(upcomingClasses)}\n\nWhich class would you like to book?`,
      currentNode: this.currentNode,
      data: { classes: upcomingClasses }
    };
  }

  showSchedule() {
    const schedule = this.fetchUserSchedule(this.userId);

    return {
      message: `Your upcoming classes:\n\n${this.formatSchedule(schedule)}\n\nWould you like to book another class?`,
      currentNode: this.currentNode,
      data: { schedule }
    };
  }

  showPricing() {
    return {
      message: "💰 **Pricing Plans**\n\n• Drop-in: $20/class\n• 10-pack: $180 ($18/class)\n• Unlimited Monthly: $150\n\nWhich plan interests you?",
      currentNode: this.currentNode
    };
  }

  // Navigation helpers
  getPreviousNode() {
    // Find last node before current node
    for (let i = this.conversationHistory.length - 1; i >= 0; i--) {
      const entry = this.conversationHistory[i];
      if (entry.role === 'assistant' && entry.node !== this.currentNode) {
        return entry.node;
      }
    }
    return 'ROOT'; // Default to root if no history
  }

  resolveGlobalIntent(intent) {
    // Global intents available from any node
    const globalIntents = {
      'help': { node: 'HELP_MENU' },
      'cancel': { node: 'ROOT' },
      'start_over': { node: 'ROOT' }
    };

    return globalIntents[intent] || null;
  }

  handleUnknownIntent(userMessage, availableEdges) {
    const edgeList = Object.keys(availableEdges).join(', ');

    return {
      message: `I'm not sure what you mean. From here, you can: ${edgeList}. What would you like to do?`,
      currentNode: this.currentNode,
      clarificationNeeded: true
    };
  }

  // Helper methods
  async extractIntent(message) {
    // NLP intent classification (integrate with OpenAI, DialogFlow, etc.)
    // Returns intent string matching edge keys
    return 'book_class'; // Simplified for example
  }

  async fetchUpcomingClasses(classType) { /* Database query */ }
  fetchUserSchedule(userId) { /* Fetch user's booked classes */ }
  formatClassList(classes) { /* Format for display */ }
  formatSchedule(schedule) { /* Format for display */ }
}

module.exports = ConversationGraph;

Key advantages: Flexible navigation, supports topic switching, scales to complex multi-domain conversations. Users can explore freely without feeling constrained.

Limitations: Requires robust intent classification. Graph can become complex with many nodes—use visualization tools to maintain graph structure.

For structured data collection across non-linear conversations, combine this with the Slot Filling Pattern.

Slot Filling Pattern

Slot filling collects required information incrementally, regardless of conversation flow. Each "slot" represents a required piece of data (e.g., name, email, date). The system tracks which slots are filled and requests missing ones naturally.

When to Use Slot Filling

  • Form completion: Collecting profile information, preferences, or settings
  • Transaction workflows: Gathering payment details, shipping addresses, or booking information
  • Flexible data collection: Users can provide information in any order ("I want yoga on Friday at 6pm" fills three slots at once)
  • Partial input handling: Users might say "Book yoga on Friday" without specifying time—system asks for missing time slot

Slot Filling Implementation

// slot-filling-conversation.js - 110 lines
// Flexible slot-based conversation for booking system

class SlotFillingConversation {
  constructor(userId) {
    this.userId = userId;
    this.intent = null;

    // Define required slots for different intents
    this.slotDefinitions = {
      book_class: {
        required: ['class_type', 'date', 'time', 'email'],
        optional: ['instructor_preference', 'special_requests'],
        prompts: {
          class_type: "What type of class would you like to book? (Yoga, Pilates, Spinning, CrossFit)",
          date: "Which day works for you?",
          time: "What time do you prefer?",
          email: "What's your email address for confirmation?",
          instructor_preference: "Do you have an instructor preference?",
          special_requests: "Any special requests or accommodations needed?"
        },
        validators: {
          class_type: (value) => ['yoga', 'pilates', 'spinning', 'crossfit'].includes(value.toLowerCase()),
          date: (value) => this.isValidFutureDate(value),
          time: (value) => this.isValidTime(value),
          email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
        }
      },

      cancel_booking: {
        required: ['booking_id', 'confirmation'],
        prompts: {
          booking_id: "What's your booking confirmation number?",
          confirmation: "Are you sure you want to cancel this booking? (Yes/No)"
        },
        validators: {
          booking_id: (value) => /^[A-Z0-9]{8}$/.test(value),
          confirmation: (value) => ['yes', 'no'].includes(value.toLowerCase())
        }
      },

      reschedule_booking: {
        required: ['booking_id', 'new_date', 'new_time'],
        prompts: {
          booking_id: "What's your booking confirmation number?",
          new_date: "Which new date works for you?",
          new_time: "What time on that day?"
        },
        validators: {
          booking_id: (value) => /^[A-Z0-9]{8}$/.test(value),
          new_date: (value) => this.isValidFutureDate(value),
          new_time: (value) => this.isValidTime(value)
        }
      }
    };

    // Current conversation state
    this.slots = {};
    this.filledSlots = new Set();
  }

  async processMessage(userMessage) {
    // Extract intent if not already set
    if (!this.intent) {
      this.intent = await this.extractIntent(userMessage);

      if (!this.slotDefinitions[this.intent]) {
        return {
          message: "I'm not sure what you'd like to do. You can book a class, cancel a booking, or reschedule.",
          needsClarification: true
        };
      }
    }

    // Extract entities/slots from user message
    const extractedSlots = await this.extractSlots(userMessage, this.intent);

    // Validate and fill slots
    for (const [slotName, slotValue] of Object.entries(extractedSlots)) {
      const validator = this.slotDefinitions[this.intent].validators[slotName];

      if (validator && validator(slotValue)) {
        this.slots[slotName] = slotValue;
        this.filledSlots.add(slotName);
      } else if (validator) {
        // Validation failed - notify user
        return {
          message: `Sorry, "${slotValue}" isn't a valid ${slotName}. ${this.slotDefinitions[this.intent].prompts[slotName]}`,
          invalidSlot: slotName
        };
      } else {
        // No validator - accept value
        this.slots[slotName] = slotValue;
        this.filledSlots.add(slotName);
      }
    }

    // Check if all required slots are filled
    const requiredSlots = this.slotDefinitions[this.intent].required;
    const missingSlots = requiredSlots.filter(slot => !this.filledSlots.has(slot));

    if (missingSlots.length === 0) {
      // All required slots filled - execute intent
      return await this.executeIntent();
    } else {
      // Ask for next missing slot
      const nextSlot = missingSlots[0];
      return {
        message: this.slotDefinitions[this.intent].prompts[nextSlot],
        requestingSlot: nextSlot,
        filledSlots: Array.from(this.filledSlots),
        missingSlots
      };
    }
  }

  async executeIntent() {
    switch (this.intent) {
      case 'book_class':
        return await this.bookClass();
      case 'cancel_booking':
        return await this.cancelBooking();
      case 'reschedule_booking':
        return await this.rescheduleBooking();
      default:
        return { message: "Intent execution not implemented." };
    }
  }

  async bookClass() {
    const { class_type, date, time, email } = this.slots;

    // Check availability
    const isAvailable = await this.checkAvailability(class_type, date, time);

    if (!isAvailable) {
      // Clear time slot and ask for alternative
      this.slots.time = null;
      this.filledSlots.delete('time');

      const alternativeTimes = await this.getAlternativeTimes(class_type, date);

      return {
        message: `Sorry, ${time} is fully booked. Available times on ${date}:\n\n${alternativeTimes.join('\n')}\n\nWhich works for you?`,
        requestingSlot: 'time',
        alternatives: alternativeTimes
      };
    }

    // Create booking
    const booking = await this.createBooking({
      userId: this.userId,
      classType: class_type,
      date,
      time,
      email
    });

    return {
      message: `Booking confirmed! 🎉\n\n**${class_type}** class\n${date} at ${time}\n\nConfirmation #${booking.id} sent to ${email}.`,
      bookingId: booking.id,
      conversationComplete: true
    };
  }

  async cancelBooking() {
    const { booking_id, confirmation } = this.slots;

    if (confirmation.toLowerCase() === 'no') {
      return {
        message: "Okay, your booking remains active. Is there anything else I can help with?",
        conversationComplete: true
      };
    }

    // Cancel booking
    await this.performCancellation(booking_id);

    return {
      message: `Booking ${booking_id} has been cancelled. You'll receive a confirmation email shortly.`,
      conversationComplete: true
    };
  }

  async rescheduleBooking() {
    const { booking_id, new_date, new_time } = this.slots;

    // Verify booking exists
    const booking = await this.fetchBooking(booking_id);

    if (!booking) {
      return {
        message: `I couldn't find booking ${booking_id}. Please check the confirmation number.`,
        invalidSlot: 'booking_id'
      };
    }

    // Check new time availability
    const isAvailable = await this.checkAvailability(booking.classType, new_date, new_time);

    if (!isAvailable) {
      this.slots.new_time = null;
      this.filledSlots.delete('new_time');

      return {
        message: `${new_time} is unavailable. Please select another time.`,
        requestingSlot: 'new_time'
      };
    }

    // Update booking
    await this.updateBooking(booking_id, { date: new_date, time: new_time });

    return {
      message: `Booking rescheduled! Your ${booking.classType} class is now on ${new_date} at ${new_time}.`,
      conversationComplete: true
    };
  }

  // Helper methods
  async extractIntent(message) { /* NLP intent classification */ }
  async extractSlots(message, intent) { /* NLP entity extraction */ }
  isValidFutureDate(date) { /* Date validation */ }
  isValidTime(time) { /* Time validation */ }
  async checkAvailability(classType, date, time) { /* Database query */ }
  async getAlternativeTimes(classType, date) { /* Database query */ }
  async createBooking(bookingData) { /* Database insert */ }
  async performCancellation(bookingId) { /* Database update */ }
  async fetchBooking(bookingId) { /* Database query */ }
  async updateBooking(bookingId, updates) { /* Database update */ }
}

module.exports = SlotFillingConversation;

Key advantages: Flexible input order, natural conversation flow, handles partial information gracefully. Users can say "Book yoga on Friday at 6pm with Sarah" and fill four slots at once.

Limitations: Requires robust entity extraction. Complex slot dependencies (e.g., time slot depends on selected date) need custom logic.

For managing context across unrelated conversation topics, see the Context Stacking Pattern.

Context Stacking Pattern

Context stacking maintains multiple conversation contexts simultaneously, allowing users to interrupt one topic, handle another, and return seamlessly. Think of it as a "call stack" for conversations—each context is pushed onto the stack and popped when complete.

When to Use Context Stacking

  • Interruption handling: User is booking a class, asks a pricing question mid-booking, then continues booking
  • Nested conversations: "While you're checking availability, can you also tell me about instructors?"
  • Multi-tasking: Customer support handling multiple issues in one conversation
  • Context switching: Users who frequently change topics but expect to return to previous context

Context Stacking Implementation

// context-stack-conversation.js - 100 lines
// Stack-based context management for interruption handling

class ContextStackConversation {
  constructor(userId) {
    this.userId = userId;
    this.contextStack = [];
    this.currentContext = null;
  }

  async processMessage(userMessage) {
    // Check if message is a context switch (new topic)
    const newIntent = await this.detectContextSwitch(userMessage);

    if (newIntent && newIntent !== this.currentContext?.intent) {
      // Push current context onto stack if it's not complete
      if (this.currentContext && !this.currentContext.complete) {
        this.contextStack.push(this.currentContext);
      }

      // Create new context
      this.currentContext = {
        intent: newIntent,
        slots: {},
        conversationHistory: [],
        createdAt: Date.now(),
        complete: false
      };

      return await this.handleIntent(userMessage);
    } else {
      // Continue current context
      return await this.handleIntent(userMessage);
    }
  }

  async handleIntent(userMessage) {
    // Add message to current context history
    this.currentContext.conversationHistory.push({
      role: 'user',
      message: userMessage,
      timestamp: Date.now()
    });

    // Process based on current intent
    let response;

    switch (this.currentContext.intent) {
      case 'book_class':
        response = await this.handleBooking(userMessage);
        break;
      case 'pricing_inquiry':
        response = await this.handlePricingInquiry(userMessage);
        break;
      case 'schedule_check':
        response = await this.handleScheduleCheck(userMessage);
        break;
      case 'instructor_info':
        response = await this.handleInstructorInfo(userMessage);
        break;
      default:
        response = { message: "I'm not sure how to help with that." };
    }

    // Add response to context history
    this.currentContext.conversationHistory.push({
      role: 'assistant',
      message: response.message,
      timestamp: Date.now()
    });

    // Check if current context is complete
    if (response.contextComplete) {
      this.currentContext.complete = true;

      // Pop previous context from stack
      if (this.contextStack.length > 0) {
        const previousContext = this.contextStack.pop();

        const resumeMessage = this.generateResumeMessage(previousContext);

        this.currentContext = previousContext;

        return {
          message: `${response.message}\n\n---\n\n${resumeMessage}`,
          contextSwitched: true,
          resumedContext: previousContext.intent
        };
      } else {
        // No previous context - conversation complete
        return {
          ...response,
          conversationComplete: true
        };
      }
    }

    return response;
  }

  async handleBooking(userMessage) {
    // Booking logic with slot filling
    const requiredSlots = ['class_type', 'date', 'time'];
    const extractedSlots = await this.extractSlots(userMessage);

    // Update slots
    Object.assign(this.currentContext.slots, extractedSlots);

    // Check if all slots filled
    const missingSlots = requiredSlots.filter(
      slot => !this.currentContext.slots[slot]
    );

    if (missingSlots.length === 0) {
      // Complete booking
      const booking = await this.createBooking(this.currentContext.slots);

      return {
        message: `Booking confirmed! Confirmation #${booking.id}`,
        contextComplete: true
      };
    } else {
      return {
        message: `What ${missingSlots[0].replace('_', ' ')} works for you?`,
        contextComplete: false
      };
    }
  }

  async handlePricingInquiry(userMessage) {
    // Quick pricing response
    const pricingInfo = this.getPricingInfo();

    return {
      message: `Our pricing:\n\n${pricingInfo}\n\nAnything else you'd like to know?`,
      contextComplete: true // Pricing is typically a quick interruption
    };
  }

  async handleScheduleCheck(userMessage) {
    const schedule = await this.fetchUserSchedule(this.userId);

    return {
      message: `Your upcoming classes:\n\n${this.formatSchedule(schedule)}`,
      contextComplete: true
    };
  }

  async handleInstructorInfo(userMessage) {
    const instructor = await this.extractInstructorName(userMessage);
    const info = await this.fetchInstructorInfo(instructor);

    return {
      message: `About ${instructor}:\n\n${info.bio}\n\nSpecializes in: ${info.specialties.join(', ')}`,
      contextComplete: true
    };
  }

  generateResumeMessage(context) {
    const intent = context.intent.replace('_', ' ');
    const filledSlots = Object.keys(context.slots);

    if (filledSlots.length > 0) {
      const summary = filledSlots
        .map(slot => `${slot}: ${context.slots[slot]}`)
        .join(', ');

      return `Let's continue with your ${intent}. So far: ${summary}. What else do you need to provide?`;
    } else {
      return `Let's continue with your ${intent}. What would you like to do?`;
    }
  }

  async detectContextSwitch(message) {
    // Detect if message indicates new intent/topic
    const intent = await this.extractIntent(message);

    // Keywords indicating context switch
    const switchKeywords = ['actually', 'wait', 'before that', 'first', 'also', 'by the way'];
    const containsSwitchKeyword = switchKeywords.some(keyword =>
      message.toLowerCase().includes(keyword)
    );

    if (containsSwitchKeyword || (intent && intent !== this.currentContext?.intent)) {
      return intent;
    }

    return null;
  }

  // Helper methods
  async extractIntent(message) { /* NLP intent classification */ }
  async extractSlots(message) { /* NLP entity extraction */ }
  async extractInstructorName(message) { /* NLP entity extraction */ }
  async createBooking(slots) { /* Database insert */ }
  async fetchUserSchedule(userId) { /* Database query */ }
  async fetchInstructorInfo(instructor) { /* Database query */ }
  getPricingInfo() { /* Return pricing details */ }
  formatSchedule(schedule) { /* Format for display */ }
}

module.exports = ContextStackConversation;

Key advantages: Natural interruption handling, maintains context across topic switches, supports complex multi-threaded conversations. Users feel understood even when jumping between topics.

Limitations: Stack depth can grow large in complex conversations—implement stack size limits. Requires clear signals to detect context switches.

For long-term memory across conversations, see the Conversation Memory Manager below.

Conversation Memory Manager

Conversation memory persists information across conversations, enabling continuity between sessions. Unlike context (which is conversation-scoped), memory is user-scoped and permanent.

When to Use Conversation Memory

  • Personalization: "Book my usual class" or "Same time as last week"
  • Preference tracking: Remembering favorite instructors, preferred times, or class types
  • Historical reference: "The email I gave you last time" or "My booking from two weeks ago"
  • Long-term relationships: Apps where users return repeatedly and expect continuity

Conversation Memory Implementation

// memory-manager.js - 80 lines
// Persistent memory system for cross-conversation continuity

class ConversationMemoryManager {
  constructor(userId, storage) {
    this.userId = userId;
    this.storage = storage; // Database/Redis/Firestore connection
    this.shortTermMemory = []; // Current conversation only
    this.longTermMemory = null; // Loaded from storage
  }

  async initialize() {
    // Load user's long-term memory from storage
    this.longTermMemory = await this.storage.get(`memory:${this.userId}`) || {
      preferences: {},
      facts: {},
      history: [],
      entities: {}
    };
  }

  async storeShortTerm(key, value, context = {}) {
    // Store in current conversation memory
    this.shortTermMemory.push({
      key,
      value,
      context,
      timestamp: Date.now()
    });
  }

  async storeReference(referenceType, referenceValue) {
    // Store referenceable entities (e.g., "my email", "my instructor")
    this.longTermMemory.entities[referenceType] = {
      value: referenceValue,
      lastUsed: Date.now()
    };

    await this.persist();
  }

  async storePreference(preferenceType, preferenceValue) {
    // Store user preferences for future conversations
    this.longTermMemory.preferences[preferenceType] = {
      value: preferenceValue,
      confidence: 1.0, // Explicit preference = 100% confidence
      source: 'explicit',
      timestamp: Date.now()
    };

    await this.persist();
  }

  async inferPreference(preferenceType, inferredValue, confidence = 0.5) {
    // Infer preferences from behavior
    const existing = this.longTermMemory.preferences[preferenceType];

    if (!existing || existing.confidence < confidence) {
      this.longTermMemory.preferences[preferenceType] = {
        value: inferredValue,
        confidence,
        source: 'inferred',
        timestamp: Date.now()
      };

      await this.persist();
    }
  }

  async storeFact(factType, factValue) {
    // Store factual information about user
    this.longTermMemory.facts[factType] = {
      value: factValue,
      timestamp: Date.now()
    };

    await this.persist();
  }

  async recall(key) {
    // Try short-term memory first (current conversation)
    const shortTerm = this.shortTermMemory
      .filter(entry => entry.key === key)
      .sort((a, b) => b.timestamp - a.timestamp)[0];

    if (shortTerm) {
      return shortTerm.value;
    }

    // Try long-term memory (across conversations)
    if (this.longTermMemory.entities[key]) {
      return this.longTermMemory.entities[key].value;
    }

    return null;
  }

  async getPreference(preferenceType) {
    const preference = this.longTermMemory.preferences[preferenceType];
    return preference?.value || null;
  }

  async getFact(factType) {
    const fact = this.longTermMemory.facts[factType];
    return fact?.value || null;
  }

  async addToHistory(conversationSummary) {
    // Store conversation summary in history
    this.longTermMemory.history.push({
      summary: conversationSummary,
      timestamp: Date.now()
    });

    // Keep only last 50 conversations
    if (this.longTermMemory.history.length > 50) {
      this.longTermMemory.history = this.longTermMemory.history.slice(-50);
    }

    await this.persist();
  }

  async getRecentHistory(limit = 5) {
    return this.longTermMemory.history
      .slice(-limit)
      .reverse();
  }

  async resolveReference(referencePhrase) {
    // Resolve natural language references to stored data
    const referenceMap = {
      'my email': () => this.recall('email'),
      'my usual class': () => this.getPreference('class_type'),
      'my favorite instructor': () => this.getPreference('instructor'),
      'my preferred time': () => this.getPreference('time_slot'),
      'last week': () => this.getHistoricalBooking(-7),
      'same time as last time': () => this.getLastBookingTime()
    };

    for (const [pattern, resolver] of Object.entries(referenceMap)) {
      if (referencePhrase.toLowerCase().includes(pattern)) {
        return await resolver();
      }
    }

    return null;
  }

  async persist() {
    // Save long-term memory to storage
    await this.storage.set(`memory:${this.userId}`, this.longTermMemory);
  }

  // Helper methods for memory queries
  async getHistoricalBooking(daysAgo) {
    const targetDate = Date.now() + (daysAgo * 24 * 60 * 60 * 1000);
    // Query booking database for bookings near targetDate
    return null; // Simplified
  }

  async getLastBookingTime() {
    const history = await this.getRecentHistory(10);
    // Extract last booking time from history
    return null; // Simplified
  }
}

module.exports = ConversationMemoryManager;

Usage example:

const memory = new ConversationMemoryManager(userId, firestoreStorage);
await memory.initialize();

// User says: "Book my usual class"
const usualClass = await memory.getPreference('class_type');
// Returns: "yoga"

// User says: "Use the email I gave you last time"
const email = await memory.recall('email');
// Returns: "user@example.com"

// Store new preference when user books yoga 3 times
await memory.inferPreference('class_type', 'yoga', 0.8);

// Store explicit fact
await memory.storeFact('membership_tier', 'premium');

Key advantages: Personalized experiences, reduces repetitive questions, builds long-term user relationships. Users feel recognized across conversations.

Limitations: Privacy concerns—clearly disclose what's stored and provide data deletion. Memory can become stale—implement expiration logic for time-sensitive data.

For more advanced personalization techniques, see our guide on ChatGPT App Analytics & Tracking Optimization.

Topic Switching and Recovery

Topic switching occurs when users change subjects mid-conversation. Robust conversation systems detect switches, preserve previous context, and offer graceful returns.

Detecting Topic Switches

async function detectTopicSwitch(userMessage, currentIntent) {
  // Signal words indicating topic change
  const switchSignals = [
    'actually', 'wait', 'never mind', 'instead',
    'before that', 'first', 'also', 'by the way'
  ];

  const containsSignal = switchSignals.some(signal =>
    userMessage.toLowerCase().includes(signal)
  );

  // Extract new intent
  const newIntent = await extractIntent(userMessage);

  // Topic switch if signal present OR intent changed dramatically
  if (containsSignal || (newIntent && newIntent !== currentIntent)) {
    return {
      switched: true,
      newIntent,
      signal: containsSignal ? 'explicit' : 'implicit'
    };
  }

  return { switched: false };
}

Offering Context Recovery

async function offerContextRecovery(abandonedContext) {
  const { intent, slots, progress } = abandonedContext;

  // Calculate how much progress was made
  const completionPercentage = (
    Object.keys(slots).length / requiredSlots.length
  ) * 100;

  if (completionPercentage > 50) {
    // Significant progress - offer to resume
    return {
      message: `Would you like to continue your ${intent}? You were ${Math.round(completionPercentage)}% complete.`,
      actions: ['Resume', 'Start Over', 'Cancel']
    };
  } else {
    // Minimal progress - just acknowledge
    return {
      message: "Okay, let's start fresh. What would you like to do?",
      contextDiscarded: true
    };
  }
}

Handling Ambiguous Switches

async function handleAmbiguousSwitch(userMessage, currentContext) {
  // When user message could belong to current context OR new topic

  // Score message relevance to current context
  const currentContextScore = await scoreRelevance(
    userMessage,
    currentContext.intent
  );

  // Score message as potential new topic
  const newTopicScores = await scoreAsNewTopics(userMessage);
  const bestNewTopic = newTopicScores[0];

  if (currentContextScore > 0.7) {
    // Likely continuation of current context
    return { continueContext: true };
  } else if (bestNewTopic.score > 0.8) {
    // Clearly a new topic
    return {
      switchContext: true,
      newIntent: bestNewTopic.intent
    };
  } else {
    // Ambiguous - ask user
    return {
      clarificationNeeded: true,
      message: `Are you asking about ${currentContext.intent} or starting a new topic (${bestNewTopic.intent})?`,
      options: [currentContext.intent, bestNewTopic.intent]
    };
  }
}

For more on conversation design patterns, see our comprehensive guide on Building ChatGPT Applications.

Production Best Practices

1. Implement Conversation Timeouts

Conversations shouldn't persist indefinitely. Implement timeouts to clear stale context:

class ConversationTimeout {
  constructor(timeoutMinutes = 30) {
    this.timeoutMs = timeoutMinutes * 60 * 1000;
    this.lastActivity = Date.now();
  }

  updateActivity() {
    this.lastActivity = Date.now();
  }

  isExpired() {
    return Date.now() - this.lastActivity > this.timeoutMs;
  }

  getTimeRemaining() {
    const elapsed = Date.now() - this.lastActivity;
    return Math.max(0, this.timeoutMs - elapsed);
  }
}

2. Store Conversation State in Database

For ChatGPT apps (stateless HTTP requests), persist state between messages:

async function saveConversationState(userId, state) {
  await firestore.collection('conversations').doc(userId).set({
    currentState: state.currentState,
    context: state.context,
    slots: state.slots,
    history: state.history,
    lastUpdated: Date.now()
  }, { merge: true });
}

async function loadConversationState(userId) {
  const doc = await firestore.collection('conversations').doc(userId).get();
  return doc.exists ? doc.data() : null;
}

3. Log Conversation Analytics

Track conversation metrics to identify improvement opportunities:

async function logConversationMetrics(conversationId, metrics) {
  await analytics.track({
    conversationId,
    userId: metrics.userId,
    totalTurns: metrics.turns,
    completionRate: metrics.completed ? 1 : 0,
    averageResponseTime: metrics.avgResponseTime,
    topicSwitches: metrics.switchCount,
    slotsFilledPerTurn: metrics.slotsFilledPerTurn,
    intentConfidence: metrics.avgIntentConfidence,
    conversationDuration: metrics.duration
  });
}

4. Handle Conversation Errors Gracefully

async function handleConversationError(error, context) {
  // Log error for debugging
  console.error('Conversation error:', error, { context });

  // Provide helpful fallback to user
  return {
    message: "I'm having trouble processing that. Could you rephrase, or type 'start over' to begin fresh?",
    error: true,
    fallbackOptions: ['Start Over', 'Speak to Human', 'Try Again']
  };
}

5. Implement Conversation Testing

Test conversation flows with automated scenarios:

async function testConversationFlow(flow) {
  const conversation = new StateMachineConversation(testUserId);

  for (const turn of flow.turns) {
    const response = await conversation.processMessage(turn.userMessage);

    assert.equal(response.currentState, turn.expectedState);
    assert.include(response.message, turn.expectedKeywords);
  }
}

// Example test
testConversationFlow({
  turns: [
    {
      userMessage: "Book yoga",
      expectedState: "SELECT_DATE",
      expectedKeywords: ["when", "date"]
    },
    {
      userMessage: "Tomorrow",
      expectedState: "SELECT_TIME",
      expectedKeywords: ["time", "available"]
    }
  ]
});

For advanced testing strategies, see our guide on ChatGPT App Testing & QA.

Related Resources

For more ChatGPT app development tutorials and guides, visit the MakeAIHQ Blog.


Ready to build ChatGPT apps with sophisticated conversation flows? Start your free trial on MakeAIHQ and deploy production-ready ChatGPT applications without writing code. Our visual conversation builder implements all five patterns covered in this guide—state machines, conversation graphs, slot filling, context stacking, and memory management—with zero configuration required.

Get Started Free →