Back to blog
director-appextensionsarchitectureintents

The Director Extension System

How the Director App's extension architecture enables modular integration with iRacing, OBS, Discord, and YouTube through a two-tier registry, intent handlers, and a sandboxed utility process.

·Sim RaceCenter Team·7 min read
On this page

The Director App uses an extension-based architecture to integrate with external systems like iRacing, OBS Studio, Discord, and YouTube. Rather than hardcoding integrations into the core, each integration is a self-contained extension that registers intent handlers, emits events, and runs in an isolated process. This post explains how the extension system works — from scanning and loading through intent dispatch and the capability handshake with Race Control.

Architecture: Main Process + Utility Process

The extension system spans two Electron processes:

┌─────────────────────────────────────────────────┐
│  Main Process (ExtensionHostService)             │
│    ├─ ExtensionScanner → scans extensions/       │
│    ├─ CapabilityCatalog (Static Tier)            │
│    ├─ IntentRegistry (Dynamic Tier)              │
│    ├─ ViewRegistry                               │
│    ├─ ExtensionEventBus                          │
│    └─ ConnectionHealth tracking                  │
│                                                  │
│       IPC (MessageChannel)                       │
│            ↕                                     │
│  Utility Process (ExtensionProcess)              │
│    ├─ ExtensionApiImpl per loaded extension       │
│    ├─ Intent handler map                         │
│    └─ Extension module instances                 │
└─────────────────────────────────────────────────┘

ExtensionHostService lives in the main Electron process. It manages the lifecycle: scanning extension directories, building the capability catalog, forwarding intent execution requests, and tracking connection health. It does not run extension code directly.

ExtensionProcess runs in an Electron utilityProcess — a sandboxed child process. Each loaded extension's activate() function executes here. This isolates extension code from the main process, preventing a misbehaving extension from crashing the application.

Extension Contract

Every extension exports two functions:

export async function activate(director: ExtensionAPI) {
  // Register intent handlers, start polling, etc.
}
 
export async function deactivate() {
  // Clean up timers, connections, etc.
}

The ExtensionAPI interface is the only surface available to extension code:

interface ExtensionAPI {
  settings: Record<string, any>;
  registerIntentHandler(intent: string, handler: (payload: any) => Promise<void>): void;
  emitEvent(event: string, payload: any): void;
  log(level: 'info' | 'warn' | 'error', message: string): void;
  invoke(method: string, ...args: any[]): Promise<any>;
  getAuthToken(): Promise<string | null>;
  openExternal(url: string): Promise<void>;
  updateSetting(key: string, value: any): Promise<void>;
  // Overlay methods
  updateOverlay(overlayId: string, data: Record<string, unknown>): void;
  showOverlay(overlayId: string): void;
  hideOverlay(overlayId: string): void;
}

Extensions register intent handlers to declare what broadcast commands they can execute. They emit events to notify the system of state changes. They use invoke() to call services in the main process (e.g., Discord TTS playback, authentication). They never directly access Electron APIs, IPC, or other extensions.

Two-Tier Intent Registry

The extension system uses a two-tier registry to separate "what's installed" from "what's running":

Static Tier: CapabilityCatalog

Built at startup by scanning ALL extension manifests — regardless of whether the extension is enabled or disabled. Each extension's package.json declares its intents:

{
  "name": "director-iracing",
  "contributes": {
    "intents": [
      { "intent": "broadcast.showLiveCam", "title": "Show Live Camera", "schema": { ... } },
      { "intent": "broadcast.replayFromTo", "title": "Replay From/To" }
    ],
    "events": [
      { "event": "iracing.raceStateChanged", "title": "Race State Changed" }
    ]
  }
}

The catalog stores every declared intent with its extensionId, human-readable title, optional JSON Schema, and an enabled flag. This serves two purposes:

  1. Sequence Editor UI — Shows all available intents even if an extension is disabled, so operators can build sequences in advance.
  2. Cloud Capabilities Handshake — Sent to Race Control during session check-in so the AI knows what the Director can execute.
// CapabilityCatalog entries
interface CatalogEntry {
  extensionId: string;
  extensionName: string;
  intent: IntentContribution;
  enabled: boolean;
}

Dynamic Tier: IntentRegistry + Handler Map

Populated at runtime when extensions are loaded. When activate() calls registerIntentHandler(), the handler function is stored in the utility process's handler map, and the main process's IntentRegistry records that the intent is active.

Manifest scan → CapabilityCatalog (static, all extensions)
Extension load → IntentRegistry (dynamic, active extensions only)
activate() call → Handler map in utility process (actual functions)

Intent Dispatch Flow

When the SequenceExecutor needs to execute broadcast.showLiveCam:

  1. SequenceExecutor calls extensionHost.executeIntent('broadcast.showLiveCam', payload)
  2. ExtensionHostService checks the dynamic IntentRegistry:
    • Handler active → Sends EXECUTE_INTENT IPC message to utility process
    • Handler not active, in catalog → Logs warning: "Intent exists but extension not active. Skipping."
    • Not in catalog at all → Logs warning: "Unknown intent. Skipping."
  3. ExtensionProcess receives the IPC message, looks up the handler, and executes it
  4. Errors are caught and logged — they don't propagate up to crash the sequence

This is the soft failure model: a missing handler skips the step, not the sequence. If OBS disconnects mid-sequence, camera intent steps are skipped, but announcements and wait steps still execute.

Live Extensions

iRacing Extension (director-iracing)

The largest extension. Uses koffi FFI to interface with iRacing's shared memory and Windows APIs.

Registered Intents:

IntentPayloadAction
broadcast.showLiveCam{ carNum, camGroup?, camNum? }Switches iRacing camera to a specific driver and camera group
broadcast.replayFromTo{ startFrame, endFrame, speed? }Plays a replay segment
broadcast.setReplaySpeed{ speed }Changes replay playback speed

Emitted Events:

EventPayloadTrigger
iracing.connectionStateChanged{ connected }iRacing connects or disconnects
iracing.cameraGroupsChanged{ groups }Camera groups updated from session YAML
iracing.driversChanged{ drivers }Driver roster changes
iracing.raceStateChangedRaceStateEvery telemetry poll (4Hz)

OBS Extension (director-obs)

Connects to OBS Studio via obs-websocket-js.

Registered Intents:

IntentPayloadAction
obs.switchScene{ sceneName }Transitions to the named OBS scene

Emitted Events:

EventPayloadTrigger
obs.connectionStateChanged{ connected }WebSocket connects or disconnects

Discord Extension (director-discord)

Handles text-to-speech announcements. The Discord bot client lives in the main process (DiscordService), not the extension. The extension delegates via invoke():

export async function activate(director: ExtensionAPI) {
  director.registerIntentHandler('communication.announce',
    async (payload: { message: string; context?: { type?: string; urgency?: string }; voice?: string }) => {
      await director.invoke('discordPlayTts', payload.message, payload.context, payload.voice);
    }
  );
}

Registered Intents:

IntentPayloadAction
communication.announce{ message, context?, voice? }Text-to-speech via Discord bot

This is a good example of the invoke() bridge: the extension registers an intent handler (so the executor can dispatch to it), but the actual work happens in the main process where the Discord client lives.

YouTube Extension (director-youtube)

Monitors YouTube Live chat using the YouTube Data API. Uses OAuth2 for authentication and the scraper system for chat monitoring.

Connection Health

Each extension reports its connection state. The ExtensionHostService aggregates this into a ConnectionHealth map:

interface ConnectionHealth {
  connected: boolean;
  connectedSince?: string;   // ISO8601 timestamp
  metadata?: Record<string, unknown>;
}

Extensions report state via events:

// OBS emits:
director.emitEvent('obs.connectionStateChanged', { connected: true });
 
// iRacing emits:
director.emitEvent('iracing.connectionStateChanged', { connected: true });

The ExtensionHostService listens for these events and updates its health map:

this.eventBus.on('obs.connectionStateChanged', (data) => {
  this.setConnectionHealth('director-obs', data.payload.connected);
});
 
this.eventBus.on('iracing.connectionStateChanged', (data) => {
  this.setConnectionHealth('director-iracing', data.payload.connected);
});

Connection health is sent to Race Control during check-in. This tells the AI not just which intents are registered, but whether the underlying hardware is actually reachable. An intent can be "active" (handler registered) but the hardware "disconnected" (OBS WebSocket dropped). Race Control uses this distinction to decide whether to include certain steps in generated sequences.

Capabilities Handshake

During session check-in, the Director builds a capabilities payload from both tiers:

function buildCapabilities(): DirectorCapabilities {
  const catalog = extensionHost.getCapabilityCatalog();
  const allIntents = catalog.getAllIntents();
 
  return {
    intents: [
      ...allIntents.map(entry => ({
        intent: entry.intent.intent,
        extensionId: entry.extensionId,
        active: entry.enabled && extensionHost.hasActiveHandler(entry.intent.intent),
        schema: entry.intent.schema,
      })),
      // Built-in intents are always available
      { intent: 'system.wait', extensionId: 'built-in', active: true },
      { intent: 'system.log', extensionId: 'built-in', active: true },
    ],
    connections: extensionHost.getConnectionHealth(),
  };
}

This payload tells Race Control:

  • Which intents exist (from the static catalog)
  • Which are currently active (handler registered AND extension enabled)
  • Which hardware is connected (from connection health)

The AI Planner uses this to generate only sequences the Director can actually execute. If OBS is disconnected, the Planner avoids obs.switchScene steps. If iRacing is connected but the Discord bot isn't, sequences will include camera work but skip announcements.

Extension Lifecycle

The full lifecycle from scan to execution:

  1. ScanExtensionScanner reads extensions/ directory, finds package.json manifests
  2. Catalog — Every manifest's intents and events are added to CapabilityCatalog (static tier)
  3. Load — Enabled extensions are loaded: IntentRegistry records their intents, LOAD_EXTENSION IPC sent to utility process
  4. Activate — Utility process calls require(entryPoint) and invokes activate(api), extension registers handlers
  5. Execute — During sequence playback, intents are dispatched via IPC to the utility process handler map
  6. Connection events — Extensions emit connection state changes, main process updates health map
  7. Re-check-in — When connection state changes, the DirectorOrchestrator triggers a re-check-in so Race Control gets updated capabilities
  8. DisablesetExtensionEnabled(id, false) unloads the extension: handlers removed from IntentRegistry, UNLOAD_EXTENSION sent to child, deactivate() called
  9. EnablesetExtensionEnabled(id, true) re-loads: handlers re-registered, extension re-activated

Extensions can be enabled/disabled at runtime without restarting the application. The CapabilityCatalog always retains the manifest data (static tier), so the UI can show disabled extensions and their available intents.

Why This Matters for AI Context

When the AI generates sequences, it needs to know:

  • What intents can the Director execute right now?
  • What's the payload schema for each intent?
  • Is the hardware actually connected?

The two-tier registry provides a clear answer. The static catalog tells the Planner what's possible. The dynamic registry and connection health tell the Executor what's available right now. The result is sequences that are executable on arrival — no guessing about what the Director can handle.