Files
OpenNoodl/dev-docs/tasks/phase-3.5-realtime-agentic-ui/AGENT-005-action-dispatcher-task.md
2025-12-30 11:55:30 +01:00

23 KiB

AGENT-005: Action Dispatcher

Overview

Create a system for backend-to-frontend action dispatching, allowing the server (AI agent) to trigger UI actions directly. This enables agentic UIs where the backend can open views, navigate pages, highlight elements, and control the application flow.

Phase: 3.5 (Real-Time Agentic UI)
Priority: HIGH
Effort: 2-4 days
Risk: Medium


Problem Statement

Current Limitation

Backends can only send data, not commands:

Backend sends: { sessions: [...], attendees: [...] }
Frontend must: Parse data → Decide what to do → Update UI manually

The AI agent can't directly control the UI.

Desired Pattern: Action-Based Control

Backend sends: { 
  type: "OPEN_VIEW",
  view: "agenda",
  data: { sessionId: "123" }
}

Frontend: Automatically opens agenda view with session 123

Real-World Use Cases (Erleah)

  1. AI Agent Navigation - "Let me open that session for you"
  2. Guided Tours - AI walks user through interface
  3. Smart Responses - Agent opens relevant views based on query
  4. Proactive Suggestions - "I see you're looking at this, let me show you related items"
  5. Workflow Automation - Multi-step UI flows triggered by backend

Goals

  1. Define action vocabulary (open view, navigate, highlight, etc.)
  2. Dispatch actions from backend messages
  3. Execute actions safely in sandboxed context
  4. Queue actions when dependencies not ready
  5. Register custom action handlers
  6. Integrate with SSE/WebSocket for real-time actions
  7. Provide action history/logging

Technical Design

Node Specifications

We'll create TWO nodes:

  1. Action Dispatcher - Receives and executes actions
  2. Register Action Handler - Defines custom action types

Action Vocabulary

Built-in actions:

// Navigation Actions
{
  type: "OPEN_VIEW",
  view: "agenda" | "chat" | "parkingLot",
  params: { id?: string }
}

{
  type: "NAVIGATE",
  component: "SessionDetail",
  params: { sessionId: "123" }
}

{
  type: "NAVIGATE_BACK"
}

// State Actions
{
  type: "SET_STORE",
  storeName: "app",
  key: "currentView",
  value: "agenda"
}

{
  type: "SET_VARIABLE",
  variable: "selectedSession",
  value: { id: "123", name: "AI Session" }
}

// UI Actions
{
  type: "SHOW_TOAST",
  message: "Session added to timeline",
  variant: "success" | "error" | "info"
}

{
  type: "HIGHLIGHT_ELEMENT",
  elementId: "session-123",
  duration: 3000,
  message: "This is the session I found"
}

{
  type: "SCROLL_TO",
  elementId: "timeline-item-5"
}

// Data Actions
{
  type: "FETCH",
  url: "/api/sessions/123",
  method: "GET"
}

{
  type: "TRIGGER_SIGNAL",
  signalName: "refreshData"
}

// Custom Actions
{
  type: "CUSTOM",
  handler: "myCustomAction",
  data: { ... }
}

Action Dispatcher Node

{
  name: 'net.noodl.ActionDispatcher',
  displayNodeName: 'Action Dispatcher',
  category: 'Events',
  color: 'purple',
  docs: 'https://docs.noodl.net/nodes/events/action-dispatcher'
}

Ports: Action Dispatcher

Port Name Type Group Description
Inputs
action object Action Action object to dispatch
dispatch signal Actions Trigger dispatch
queueWhenBusy boolean Config Queue if action in progress (default: true)
timeout number Config Max execution time (ms, default: 30000)
Outputs
actionType string Data Type of current action
actionData object Data Data from current action
dispatched signal Events Fires when action starts
completed signal Events Fires when action completes
failed signal Events Fires if action fails
queueSize number Status Actions waiting in queue
isExecuting boolean Status Action currently running
error string Events Error message
result * Data Result from action execution

Register Action Handler Node

{
  name: 'net.noodl.ActionDispatcher.RegisterHandler',
  displayNodeName: 'Register Action Handler',
  category: 'Events',
  color: 'purple'
}

Ports: Register Action Handler

Port Name Type Group Description
Inputs
actionType string Handler Action type to handle (e.g., "OPEN_DASHBOARD")
register signal Actions Register this handler
Outputs
trigger signal Events Fires when this action type dispatched
actionData object Data Data from the action
complete signal Actions Signal back to dispatcher when done

Implementation Details

File Structure

packages/noodl-runtime/src/nodes/std-library/events/
├── actiondispatcher.js           # Core dispatcher singleton
├── actiondispatchernode.js       # Dispatcher node
├── registeractionhandlernode.js  # Handler registration node
└── actiondispatcher.test.js      # Tests

Core Dispatcher Implementation

// actiondispatcher.js

const { globalStoreManager } = require('../data/globalstore');

class ActionDispatcherManager {
  constructor() {
    this.handlers = new Map();
    this.queue = [];
    this.isExecuting = false;
    this.actionHistory = [];
  }
  
  /**
   * Register a handler for an action type
   */
  registerHandler(actionType, handler) {
    if (!this.handlers.has(actionType)) {
      this.handlers.set(actionType, []);
    }
    
    this.handlers.get(actionType).push(handler);
    
    console.log(`[ActionDispatcher] Registered handler for: ${actionType}`);
    
    // Return unregister function
    return () => {
      const handlers = this.handlers.get(actionType);
      const index = handlers.indexOf(handler);
      if (index > -1) {
        handlers.splice(index, 1);
      }
    };
  }
  
  /**
   * Dispatch an action
   */
  async dispatch(action, options = {}) {
    if (!action || !action.type) {
      throw new Error('Action must have a type');
    }
    
    // Queue if busy and queueing enabled
    if (this.isExecuting && options.queueWhenBusy) {
      this.queue.push(action);
      console.log(`[ActionDispatcher] Queued: ${action.type}`);
      return { queued: true, queueSize: this.queue.length };
    }
    
    this.isExecuting = true;
    
    try {
      console.log(`[ActionDispatcher] Dispatching: ${action.type}`, action);
      
      // Add to history
      this.actionHistory.push({
        action,
        timestamp: Date.now(),
        status: 'executing'
      });
      
      // Try built-in handlers first
      let result;
      if (this.builtInHandlers[action.type]) {
        result = await this.builtInHandlers[action.type](action);
      }
      
      // Then custom handlers
      const handlers = this.handlers.get(action.type);
      if (handlers && handlers.length > 0) {
        for (const handler of handlers) {
          await handler(action.data || action);
        }
      }
      
      // Update history
      const historyEntry = this.actionHistory[this.actionHistory.length - 1];
      historyEntry.status = 'completed';
      historyEntry.result = result;
      
      this.isExecuting = false;
      
      // Process queue
      this.processQueue();
      
      return { completed: true, result };
      
    } catch (error) {
      console.error(`[ActionDispatcher] Failed: ${action.type}`, error);
      
      // Update history
      const historyEntry = this.actionHistory[this.actionHistory.length - 1];
      historyEntry.status = 'failed';
      historyEntry.error = error.message;
      
      this.isExecuting = false;
      
      // Process queue
      this.processQueue();
      
      return { failed: true, error: error.message };
    }
  }
  
  /**
   * Process queued actions
   */
  async processQueue() {
    if (this.queue.length === 0) return;
    
    const action = this.queue.shift();
    await this.dispatch(action, { queueWhenBusy: true });
  }
  
  /**
   * Built-in action handlers
   */
  builtInHandlers = {
    'SET_STORE': async (action) => {
      const { storeName, key, value } = action;
      if (!storeName || !key) {
        throw new Error('SET_STORE requires storeName and key');
      }
      globalStoreManager.setKey(storeName, key, value);
      return { success: true };
    },
    
    'SHOW_TOAST': async (action) => {
      const { message, variant = 'info' } = action;
      // Emit event that toast component can listen to
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent('noodl:toast', {
          detail: { message, variant }
        }));
      }
      return { success: true };
    },
    
    'TRIGGER_SIGNAL': async (action) => {
      const { signalName } = action;
      // Emit as custom event
      if (typeof window !== 'undefined') {
        window.dispatchEvent(new CustomEvent(`noodl:signal:${signalName}`));
      }
      return { success: true };
    },
    
    'FETCH': async (action) => {
      const { url, method = 'GET', body, headers = {} } = action;
      const response = await fetch(url, {
        method,
        headers: {
          'Content-Type': 'application/json',
          ...headers
        },
        body: body ? JSON.stringify(body) : undefined
      });
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      
      const data = await response.json();
      return { success: true, data };
    }
  };
  
  /**
   * Get action history
   */
  getHistory(limit = 10) {
    return this.actionHistory.slice(-limit);
  }
  
  /**
   * Clear action queue
   */
  clearQueue() {
    this.queue = [];
  }
  
  /**
   * Get queue size
   */
  getQueueSize() {
    return this.queue.length;
  }
}

const actionDispatcherManager = new ActionDispatcherManager();

module.exports = { actionDispatcherManager };

Action Dispatcher Node Implementation

// actiondispatchernode.js

const { actionDispatcherManager } = require('./actiondispatcher');

var ActionDispatcherNode = {
  name: 'net.noodl.ActionDispatcher',
  displayNodeName: 'Action Dispatcher',
  category: 'Events',
  color: 'purple',
  
  initialize: function() {
    this._internal.isExecuting = false;
    this._internal.queueSize = 0;
  },
  
  inputs: {
    action: {
      type: 'object',
      displayName: 'Action',
      group: 'Action',
      set: function(value) {
        this._internal.action = value;
      }
    },
    
    dispatch: {
      type: 'signal',
      displayName: 'Dispatch',
      group: 'Actions',
      valueChangedToTrue: function() {
        this.doDispatch();
      }
    },
    
    queueWhenBusy: {
      type: 'boolean',
      displayName: 'Queue When Busy',
      group: 'Config',
      default: true,
      set: function(value) {
        this._internal.queueWhenBusy = value;
      }
    },
    
    timeout: {
      type: 'number',
      displayName: 'Timeout (ms)',
      group: 'Config',
      default: 30000,
      set: function(value) {
        this._internal.timeout = value;
      }
    }
  },
  
  outputs: {
    actionType: {
      type: 'string',
      displayName: 'Action Type',
      group: 'Data',
      getter: function() {
        return this._internal.actionType;
      }
    },
    
    actionData: {
      type: 'object',
      displayName: 'Action Data',
      group: 'Data',
      getter: function() {
        return this._internal.actionData;
      }
    },
    
    dispatched: {
      type: 'signal',
      displayName: 'Dispatched',
      group: 'Events'
    },
    
    completed: {
      type: 'signal',
      displayName: 'Completed',
      group: 'Events'
    },
    
    failed: {
      type: 'signal',
      displayName: 'Failed',
      group: 'Events'
    },
    
    queueSize: {
      type: 'number',
      displayName: 'Queue Size',
      group: 'Status',
      getter: function() {
        return actionDispatcherManager.getQueueSize();
      }
    },
    
    isExecuting: {
      type: 'boolean',
      displayName: 'Is Executing',
      group: 'Status',
      getter: function() {
        return this._internal.isExecuting;
      }
    },
    
    error: {
      type: 'string',
      displayName: 'Error',
      group: 'Events',
      getter: function() {
        return this._internal.error;
      }
    },
    
    result: {
      type: '*',
      displayName: 'Result',
      group: 'Data',
      getter: function() {
        return this._internal.result;
      }
    }
  },
  
  methods: {
    async doDispatch() {
      const action = this._internal.action;
      
      if (!action) {
        this.setError('No action provided');
        return;
      }
      
      this._internal.actionType = action.type;
      this._internal.actionData = action;
      this._internal.isExecuting = true;
      this._internal.error = null;
      this._internal.result = null;
      
      this.flagOutputDirty('actionType');
      this.flagOutputDirty('actionData');
      this.flagOutputDirty('isExecuting');
      this.sendSignalOnOutput('dispatched');
      
      try {
        const result = await actionDispatcherManager.dispatch(action, {
          queueWhenBusy: this._internal.queueWhenBusy,
          timeout: this._internal.timeout
        });
        
        if (result.queued) {
          // Action was queued
          this.flagOutputDirty('queueSize');
          return;
        }
        
        if (result.completed) {
          this._internal.result = result.result;
          this._internal.isExecuting = false;
          
          this.flagOutputDirty('result');
          this.flagOutputDirty('isExecuting');
          this.sendSignalOnOutput('completed');
        } else if (result.failed) {
          this.setError(result.error);
          this._internal.isExecuting = false;
          this.flagOutputDirty('isExecuting');
          this.sendSignalOnOutput('failed');
        }
        
      } catch (e) {
        this.setError(e.message);
        this._internal.isExecuting = false;
        this.flagOutputDirty('isExecuting');
        this.sendSignalOnOutput('failed');
      }
    },
    
    setError: function(message) {
      this._internal.error = message;
      this.flagOutputDirty('error');
    }
  },
  
  getInspectInfo: function() {
    return {
      type: 'value',
      value: {
        lastAction: this._internal.actionType,
        isExecuting: this._internal.isExecuting,
        queueSize: actionDispatcherManager.getQueueSize()
      }
    };
  }
};

module.exports = {
  node: ActionDispatcherNode
};

Register Action Handler Node

// registeractionhandlernode.js

const { actionDispatcherManager } = require('./actiondispatcher');

var RegisterActionHandlerNode = {
  name: 'net.noodl.ActionDispatcher.RegisterHandler',
  displayNodeName: 'Register Action Handler',
  category: 'Events',
  color: 'purple',
  
  initialize: function() {
    this._internal.unregister = null;
    this._internal.pendingComplete = null;
  },
  
  inputs: {
    actionType: {
      type: 'string',
      displayName: 'Action Type',
      group: 'Handler',
      set: function(value) {
        // Re-register if type changes
        if (this._internal.unregister) {
          this._internal.unregister();
        }
        this._internal.actionType = value;
        this.registerHandler();
      }
    },
    
    register: {
      type: 'signal',
      displayName: 'Register',
      group: 'Actions',
      valueChangedToTrue: function() {
        this.registerHandler();
      }
    },
    
    complete: {
      type: 'signal',
      displayName: 'Complete',
      group: 'Actions',
      valueChangedToTrue: function() {
        if (this._internal.pendingComplete) {
          this._internal.pendingComplete();
          this._internal.pendingComplete = null;
        }
      }
    }
  },
  
  outputs: {
    trigger: {
      type: 'signal',
      displayName: 'Trigger',
      group: 'Events'
    },
    
    actionData: {
      type: 'object',
      displayName: 'Action Data',
      group: 'Data',
      getter: function() {
        return this._internal.actionData;
      }
    }
  },
  
  methods: {
    registerHandler: function() {
      const actionType = this._internal.actionType;
      if (!actionType) return;
      
      // Unregister previous
      if (this._internal.unregister) {
        this._internal.unregister();
      }
      
      // Register handler
      this._internal.unregister = actionDispatcherManager.registerHandler(
        actionType,
        (data) => {
          return new Promise((resolve) => {
            // Store data
            this._internal.actionData = data;
            this.flagOutputDirty('actionData');
            
            // Trigger handler
            this.sendSignalOnOutput('trigger');
            
            // Wait for complete signal
            this._internal.pendingComplete = resolve;
          });
        }
      );
      
      console.log(`[RegisterActionHandler] Registered: ${actionType}`);
    },
    
    _onNodeDeleted: function() {
      if (this._internal.unregister) {
        this._internal.unregister();
      }
    }
  }
};

module.exports = {
  node: RegisterActionHandlerNode
};

Usage Examples

Example 1: SSE Actions (Erleah AI Agent)

[SSE] connected to "/ai/stream"
  ↓
[SSE] data → { type: "OPEN_VIEW", view: "agenda" }
  ↓
[Action Dispatcher] action
  ↓
[Action Dispatcher] dispatch

// Built-in handler automatically opens agenda view!

Example 2: Custom Action Handler

// Register handler for custom action
[Component Mounted]
  ↓
[Register Action Handler]
  actionType: "OPEN_SESSION_DETAIL"
  register
  
[Register Action Handler] trigger
  ↓
[Register Action Handler] actionData → { sessionId }
  ↓
[Navigate to Component] "SessionDetail"
  ↓
[Set Variable] selectedSession = actionData
  ↓
[Register Action Handler] complete signal

// Elsewhere, dispatch the action
[Action Dispatcher]
  action: { type: "OPEN_SESSION_DETAIL", sessionId: "123" }
  dispatch

Example 3: Multi-Step Flow

// AI agent triggers workflow
[SSE] data → [
  { type: "SET_STORE", storeName: "app", key: "view", value: "timeline" },
  { type: "HIGHLIGHT_ELEMENT", elementId: "session-123" },
  { type: "SHOW_TOAST", message: "Here's the session I found" }
]
  ↓
[For Each] action in array
  ↓
[Action Dispatcher] action
[Action Dispatcher] dispatch
  ↓
[Action Dispatcher] completed
  → continue to next action

Example 4: Guided Tour

// Backend provides tour steps
tourSteps = [
  { type: "HIGHLIGHT_ELEMENT", elementId: "search-bar", message: "Start by searching" },
  { type: "HIGHLIGHT_ELEMENT", elementId: "add-button", message: "Then add to timeline" },
  { type: "SHOW_TOAST", message: "Tour complete!" }
]

[Button: "Start Tour"] clicked
  ↓
[For Each Step]
  → [Delay] 2000ms
  → [Action Dispatcher] dispatch
  → wait for completed
  → next step

Testing Checklist

Functional Tests

  • Built-in actions execute correctly
  • Custom handlers register and trigger
  • Actions queue when busy
  • Queue processes in order
  • Timeout works for slow actions
  • Multiple handlers for same action type
  • Handler unregistration works
  • Action history records events
  • Error handling works

Edge Cases

  • Dispatch without action object
  • Action without type field
  • Handler throws error
  • Register duplicate action type
  • Unregister non-existent handler
  • Very long queue (100+ actions)
  • Rapid dispatch (stress test)

Performance

  • No memory leaks with many actions
  • Queue doesn't grow unbounded
  • Handler execution is async-safe

Security Considerations

1. Action Validation

Always validate actions from untrusted sources:

// In dispatcher
if (!isValidActionType(action.type)) {
  throw new Error('Invalid action type');
}

if (!hasPermission(user, action.type)) {
  throw new Error('Unauthorized action');
}

2. Rate Limiting

Prevent action flooding:

// Max 100 actions per minute per user
if (rateLimiter.check(userId) > 100) {
  throw new Error('Rate limit exceeded');
}

3. Sandboxing

Custom handlers run in controlled context - they can't access arbitrary code.


Documentation Requirements

User-Facing Docs

Create: docs/nodes/events/action-dispatcher.md

# Action Dispatcher

Let your backend control your frontend. Perfect for AI agents, guided tours, and automated workflows.

## The Pattern

Instead of:

Backend: "Here's data" Frontend: "What should I do with it?"


Do this:

Backend: "Open this view with this data" Frontend: "Done!"


## Built-in Actions

- `OPEN_VIEW` - Navigate to view
- `SET_STORE` - Update global store
- `SHOW_TOAST` - Display notification
- `HIGHLIGHT_ELEMENT` - Draw attention
- `TRIGGER_SIGNAL` - Fire signal
- `FETCH` - Make HTTP request

## Custom Actions

Create your own:

[Register Action Handler] actionType: "MY_ACTION" → trigger → [Your logic here] → complete signal


## Example: AI Agent Navigation

[Full example with SSE + Action Dispatcher]

## Security

Actions from untrusted sources should be validated server-side before sending to frontend.

Success Criteria

  1. Built-in actions work out of box
  2. Custom handlers easy to register
  3. Queue prevents action conflicts
  4. Integrates with SSE/WebSocket
  5. No security vulnerabilities
  6. Clear documentation
  7. Works in Erleah for AI agent control

Future Enhancements

  1. Action Middleware - Intercept/transform actions
  2. Conditional Actions - If/then logic in actions
  3. Action Composition - Combine multiple actions
  4. Undo/Redo - Reversible actions (with AGENT-006)
  5. Action Recording - Record/replay user flows
  6. Permissions System - Role-based action control

References


Dependencies

  • AGENT-003 (Global State Store) - for SET_STORE action

Blocked By

  • AGENT-001 or AGENT-002 (for SSE/WebSocket integration)
  • AGENT-003 (for store actions)

Blocks

  • None (enhances Erleah experience)

Estimated Effort Breakdown

Phase Estimate Description
Core Dispatcher 1 day Manager with built-in actions
Dispatcher Node 0.5 day Node implementation
Handler Node 0.5 day Registration system
Testing 0.5 day Unit tests, security tests
Documentation 0.5 day User docs, examples
Polish 0.5 day Edge cases, error handling

Total: 3.5 days

Buffer: +0.5 day for security review = 4 days

Final: 2-4 days (depending on built-in actions scope)