Appearance
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
- OnEvent operators register listeners at flow initialization, not when "reached" in the sequential flow. They behave like global event handlers.
- Multiple OnEvent operators can listen to the same event type with different params (e.g., slideChange:1 and slideChange:3).
- OnEvent-triggered chains are one-shot by default — they fire once and unregister. A
repeatflag can make them persistent. - 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:
- Event type dropdown —
<select>with available event types - Params section — dynamic based on event type:
formSubmit: no params needed (or optional formId)countdown:<input type="number">for seconds thresholdslideChange:<input type="number">for slide indexvideoProgress:<input type="number">for percentage
- 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 operatorscountdown: emit at configurable threshold(s), countdownExpired at 0
Phase 2: Slider events
slideChange: emit on slide navigation with slideIndexslideEnd: emit when reaching last slide
Phase 3: Video + button events
videoEnd: emit when video finishesvideoProgress: emit at configurable percentage thresholdbuttonClick: emit when user clicks a specific button block
Effort Estimates
| Task | Effort |
|---|---|
| FlowEventBus + CE registration logic | 1 day |
| formSubmit emission + handler | 0.5 day |
| countdown emission + handler | 0.5 day |
| Builder: OnEventOp.vue + operator definition | 1 day |
| Composer: remapping | 0.5 day |
| Total MVP (Phase 1) | ~3 days |
| Each additional event type | ~0.5-1 day |