Skip to content

OnEvent Operator — Implementation Plan

Overview

The OnEvent operator is a new flow operator type that listens for runtime events from interactive blocks (form, slider, video, countdown, etc.) and advances the flow when an event fires. This enables non-technical users to react to block interactions without writing JavaScript.

Architecture: Parallel Listeners vs Sequential Flow

Current flow model

The conversation flow is sequential: each operator executes and moves to nextStepId. Answer operators branch based on position. All other operators are linear.

OnEvent model

OnEvent operators are parallel listeners. They register at flow init time and wait for events independently of the main sequential flow. When an event fires, the OnEvent operator resolves its nextStepId and executes the linked operators (which may include ChangeText, ShowHide, SetVariable, etc.).

Sequential flow:      OnEvent listeners (parallel):

Message → Answer      OnEvent[formSubmit] → ShowHide[hide form]
  ├── A → ...         OnEvent[countdown:0] → ChangeText[timeout msg]
  └── B → ...         OnEvent[slideChange:3] → ChangeText[CTA]

Key architectural decisions

  1. OnEvent operators register listeners at flow initialization, not when "reached" in the sequential flow. They behave like global event handlers.
  2. Multiple OnEvent operators can listen to the same event type with different params (e.g., slideChange:1 and slideChange:3).
  3. OnEvent-triggered chains are one-shot by default — they fire once and unregister. A repeat flag can make them persistent.
  4. OnEvent operators participate in the condition system — an OnEvent can have a "when" condition, so it only fires if the condition is met at event time.

CE Implementation

Event bus

Add a simple event bus to the flow runtime (or reuse Vue's $emit/$on if available in CE's Vue 3 setup):

ts
// src/utils/flowEventBus.ts
type FlowEventHandler = (params: Record<string, any>) => void

class FlowEventBus {
  private listeners = new Map<string, Set<FlowEventHandler>>()

  on(eventType: string, handler: FlowEventHandler) {
    if (!this.listeners.has(eventType)) {
      this.listeners.set(eventType, new Set())
    }
    this.listeners.get(eventType)!.add(handler)
  }

  off(eventType: string, handler: FlowEventHandler) {
    this.listeners.get(eventType)?.delete(handler)
  }

  emit(eventType: string, params: Record<string, any> = {}) {
    this.listeners.get(eventType)?.forEach((handler) => handler(params))
  }

  clear() {
    this.listeners.clear()
  }
}

export const flowEventBus = new FlowEventBus()

Event registration in conversationFlow.ts

During flow initialization (after all operators are parsed), scan for onEvent operators and register listeners:

ts
// In processFlow or init logic:
Object.entries(operatorList).forEach(([key, op]) => {
  if (!key.startsWith('onEvent')) return

  const { eventType, params } = op.properties.body
  const handler = (eventParams) => {
    // Check param match (e.g., slideIndex === 3)
    if (!matchesParams(params, eventParams)) return

    // Check condition (reuse evaluateCondition)
    if (op.payload?.condition) {
      const conditionMet = evaluateCondition(op.payload.condition)
      if (!conditionMet) return
    }

    // Execute linked operators
    const nextStepId = op.properties.nextStepId
    if (nextStepId) {
      executeStep(nextStepId)
    }

    // Unregister if one-shot
    if (!op.properties.body.repeat) {
      flowEventBus.off(eventType, handler)
    }
  }

  flowEventBus.on(eventType, handler)
})

Block event emission

Each interactive block emits events through the bus. Add flowEventBus.emit() calls at the appropriate points:

Form block (formSubmit)

ts
// In form submit handler:
flowEventBus.emit('formSubmit', { formId: this.formId })

Countdown (countdown)

ts
// In countdown tick handler:
flowEventBus.emit('countdown', { remaining: secondsLeft })
// When countdown reaches 0:
flowEventBus.emit('countdownExpired', {})

Slider (slideChange, slideEnd) — future

ts
// In slide change handler:
flowEventBus.emit('slideChange', { slideIndex: currentSlide })
if (currentSlide === totalSlides - 1) {
  flowEventBus.emit('slideEnd', {})
}

Video (videoEnd, videoProgress) — future

ts
// In video progress handler:
flowEventBus.emit('videoProgress', { progress: percent })
if (ended) {
  flowEventBus.emit('videoEnd', {})
}

Builder UI

Operator definition

Add to _temp_buildercomponents.ts:

ts
{
  type: 'onEvent',
  title: 'On Event',
  group: 'event',
}

Add 'onEvent' to switchComponents (functional operators list).

OnEventOp.vue

Minimal UI:

  1. Event type dropdown<select> with available event types
  2. Params section — dynamic based on event type:
    • formSubmit: no params needed (or optional formId)
    • countdown: <input type="number"> for seconds threshold
    • slideChange: <input type="number"> for slide index
    • videoProgress: <input type="number"> for percentage
  3. Repeat checkbox — "Fire every time" vs "Fire once" (default: once)
┌────────────────────────┐
│ On Event               │
│ ┌────────────────────┐ │
│ │ Form Submit      ▾ │ │
│ └────────────────────┘ │
│ □ Repeat               │
│ ▸ Add condition        │
│     [delete] [ev] [⚙] │
└────────────────────────┘

Conditional event type visibility

Only show event types when matching blocks exist in the creative:

ts
// Follow the hasConversationBlock pattern from builder store
computed: {
  availableEventTypes() {
    const types = []
    types.push({ value: 'countdown', label: 'Countdown' }) // always available
    if (this.$store.getters['builder/hasFormBlock']) {
      types.push({ value: 'formSubmit', label: 'Form Submit' })
    }
    if (this.$store.getters['builder/hasSliderBlock']) {
      types.push({ value: 'slideChange', label: 'Slide Change' })
      types.push({ value: 'slideEnd', label: 'Slide End' })
    }
    // etc.
    return types
  }
}

Data Contract

Operator body (Builder JSON → Composer → CE)

ts
interface OnEventBody {
  eventType: 'formSubmit' | 'countdown' | 'slideChange' | 'slideEnd' | 'videoEnd' | 'videoProgress' | 'buttonClick'
  params?: {
    seconds?: number      // countdown threshold
    slideIndex?: number   // slideChange target
    progress?: number     // videoProgress threshold (0-100)
    targetId?: string     // buttonClick target
  }
  repeat?: boolean        // default false (one-shot)
}

Composer remapping

Generic passthrough in remapData.ts (same pattern as SetVariable):

ts
if (key.startsWith('onEvent')) {
  comp.payload = {
    eventType: body.eventType,
    params: body.params || {},
    repeat: body.repeat || false,
  }
  if (body.condition) comp.payload.condition = body.condition
}

Interaction with Variables + Conditions

OnEvent + SetVariable

OnEvent can trigger SetVariable operators in its chain:

OnEvent[formSubmit] → SetVariable[submitted = true] → ShowHide[hide form]

OnEvent + Conditions

OnEvent operators support the same "when" condition as other functional operators:

OnEvent[countdown:0, when score >= 3] → ChangeText["You won!"]
OnEvent[countdown:0, when score < 3]  → ChangeText["Try again!"]

OnEvent + System Variables

Event emission should also update system variables in the flowVariables store:

ts
// On form submit:
DataStore.flowVariables['form.submitted'] = 'true'

// On countdown tick:
DataStore.flowVariables['countdown.remaining'] = String(secondsLeft)

MVP Scope

Phase 1: formSubmit + countdown

  • These are the most requested event types
  • formSubmit: emit after form validation passes, trigger linked operators
  • countdown: emit at configurable threshold(s), countdownExpired at 0

Phase 2: Slider events

  • slideChange: emit on slide navigation with slideIndex
  • slideEnd: emit when reaching last slide

Phase 3: Video + button events

  • videoEnd: emit when video finishes
  • videoProgress: emit at configurable percentage threshold
  • buttonClick: emit when user clicks a specific button block

Effort Estimates

TaskEffort
FlowEventBus + CE registration logic1 day
formSubmit emission + handler0.5 day
countdown emission + handler0.5 day
Builder: OnEventOp.vue + operator definition1 day
Composer: remapping0.5 day
Total MVP (Phase 1)~3 days
Each additional event type~0.5-1 day

Internal documentation