Skip to content

FeaturePlugin API reference

This document describes the FeaturePlugin base class API for creating Level 2 plugins (feature plugins with slash commands and multi-step workflows).

Class: FeaturePlugin

Base class for all feature plugins. Provides dependency injection via AppContext and defines the plugin lifecycle.

Constructor

constructor(context: AppContext)

Parameters:

  • context - AppContext instance providing access to Canvas-Chat APIs

Usage:

class MyFeature extends FeaturePlugin {
    constructor(context) {
        super(context);

        // Access APIs
        this.graph = context.graph;
        this.canvas = context.canvas;
        this.chat = context.chat;

        // Initialize feature state
        this.myState = {};
    }
}

Note: Always call super(context) before accessing context properties.

Lifecycle hooks

onLoad()

async onLoad(): Promise<void>

Called when the plugin is registered and loaded. Use for initialization.

When it's called:

  • After plugin registration via FeatureRegistry.register()
  • Before any slash commands are routed to the plugin
  • After event subscriptions are registered

Common uses:

  • Load saved state from storage
  • Initialize resources
  • Set up timers or intervals
  • Log plugin loading

Example:

async onLoad() {
    console.log('[MyFeature] Loaded');

    // Load saved state
    const savedData = this.storage.getItem('my-feature-state');
    if (savedData) {
        this.state = JSON.parse(savedData);
    }

    // Initialize resources
    this.workers = new Map();

    // Log to user
    this.showToast?.('MyFeature loaded', 'info');
}

onUnload()

async onUnload(): Promise<void>

Called when the plugin is unregistered. Use for cleanup.

When it's called:

  • Via FeatureRegistry.unregister(pluginId)
  • Before plugin removal from registry
  • After event subscriptions are removed

Common uses:

  • Save state to storage
  • Abort ongoing operations
  • Clear timers/intervals
  • Release resources

Example:

async onUnload() {
    console.log('[MyFeature] Unloaded');

    // Save state
    this.storage.setItem('my-feature-state', JSON.stringify(this.state));

    // Abort operations
    for (const [id, worker] of this.workers.entries()) {
        worker.abort();
    }
    this.workers.clear();

    // Log to user
    this.showToast?.('MyFeature unloaded', 'info');
}

Event subscriptions

getEventSubscriptions()

getEventSubscriptions(): Object<string, Function>

Returns an object mapping event names to handler functions for feature registry events.

Returns:

Object where:

  • Key: Event name (string)
  • Value: Handler function (receives CanvasEvent or CancellableEvent)

Example:

getEventSubscriptions() {
    return {
        'node:created': this.onNodeCreated.bind(this),
        'node:deleted': this.onNodeDeleted.bind(this),
        'selfheal:before': this.onSelfHealBefore.bind(this),
    };
}

getCanvasEventHandlers()

getCanvasEventHandlers(): Object<string, Function>

Returns an object mapping canvas event names to handler functions. These handlers are automatically registered on the canvas when the plugin loads and unregistered when the plugin unloads.

When to use:

  • Your plugin creates custom node types that emit canvas events (e.g., canvas.emit('myCustomEvent', nodeId, ...args))
  • You need to handle interactions with custom nodes (clicks, votes, etc.)
  • You want to keep event handling logic within your plugin (self-contained)

Returns:

Object where:

  • Key: Canvas event name (string)
  • Value: Handler function (receives event arguments directly, not wrapped in an event object)

Example:

getCanvasEventHandlers() {
    return {
        'pollVote': this.handlePollVote.bind(this),
        'pollAddOption': this.handlePollAddOption.bind(this),
        'pollResetVotes': this.handlePollResetVotes.bind(this),
    };
}

handlePollVote(nodeId, optionIndex) {
    const node = this.graph.getNode(nodeId);
    // Update votes...
    this.graph.updateNode(nodeId, { votes: node.votes });
    this.canvas.renderNode(node);
}

Note: Canvas event handlers receive arguments directly from canvas.emit(), unlike feature registry events which wrap data in event objects.

onNodeCreated(event) {
    const { nodeId, nodeType } = event.data;
    console.log('Node created:', nodeId, nodeType);
}

onSelfHealBefore(event) {
    // Can prevent self-healing
    if (event.data.attemptNum > 2) {
        event.preventDefault();
        console.log('Prevented self-healing after 2 attempts');
    }
}

Available events:

See Extension Hooks Reference for complete event listing.

Event ordering:

  • Events fire in plugin registration order (first registered, first called)
  • Multiple plugins can subscribe to the same event
  • First plugin to call preventDefault() wins (on CancellableEvent)

Helper methods

showToast()

showToast(message: string, type: string): void

Display a toast notification to the user.

Parameters:

  • message - Text to display
  • type - One of: 'info', 'success', 'warning', 'error'

Example:

this.showToast('Operation completed', 'success');
this.showToast('Node not found', 'error');
this.showToast('Processing...', 'info');

Note: showToast may be undefined in test environments. Use optional chaining:

this.showToast?.('Message', 'info');

emit()

emit(eventName: string, event: CanvasEvent): void

Emit a custom event to the event bus.

Parameters:

  • eventName - Unique event identifier
  • event - CanvasEvent or CancellableEvent instance

Example:

import { CanvasEvent } from '/static/js/plugin-events.js';

// Emit simple event
this.emit(
    'myfeature:started',
    new CanvasEvent('myfeature:started', {
        featureId: this.id,
        timestamp: Date.now(),
    })
);

// Emit cancellable event
import { CancellableEvent } from '/static/js/plugin-events.js';

const event = new CancellableEvent('myfeature:before-action', {
    action: 'delete',
    nodeId: 'node-123',
});

this.emit('myfeature:before-action', event);

if (event.defaultPrevented) {
    console.log('Action cancelled by another plugin');
    return;
}

Inherited properties (via AppContext)

All properties from AppContext are available via this.context or directly:

Graph API

this.graph: CRDTGraph

Graph data structure managing nodes and edges.

Common methods:

// Nodes
this.graph.addNode(node);
this.graph.getNode(nodeId);
this.graph.updateNode(nodeId, updates);
this.graph.deleteNode(nodeId);
this.graph.getAllNodes();
this.graph.getLeafNodes();

// Edges
this.graph.addEdge(edge);
this.graph.getEdge(edgeId);
this.graph.deleteEdge(edgeId);
this.graph.getEdgesForNode(nodeId);

// Traversal
this.graph.resolveContext(nodeIds); // Get conversation context
this.graph.getVisibleSubtree(nodeId); // Get expanded descendants

// Positioning
this.graph.autoPosition(parentIds); // Calculate position for new node

Canvas API

this.canvas: Canvas

Visual canvas for rendering and interaction.

Common methods:

// Rendering
this.canvas.renderNode(node);
this.canvas.removeNode(nodeId);
this.canvas.updateNodeContent(nodeId, content, isStreaming);

// Selection
this.canvas.getSelectedNodeIds(); // Returns: string[]
this.canvas.clearSelection();

// Viewport
this.canvas.centerOnAnimated(x, y, duration);
this.canvas.panToNodeAnimated(nodeId);

// Streaming controls
this.canvas.showStopButton(nodeId);
this.canvas.hideStopButton(nodeId);
this.canvas.showContinueButton(nodeId);
this.canvas.hideContinueButton(nodeId);

// Edges
this.canvas.renderEdge(edge, fromPosition, toPosition);
this.canvas.removeEdge(edgeId);

Chat API

this.chat: Chat

LLM communication interface.

Common methods:

// Get API credentials
const apiKey = this.chat.getApiKeyForModel(model);
const baseUrl = this.chat.getBaseUrlForModel(model);

// Send message (streaming)
await this.chat.sendMessage(
    messages, // Array of {role, content}
    model, // Model ID
    onChunk, // (chunk, fullContent) => void
    onDone, // () => void
    onError, // (error) => void
    signal // AbortSignal (optional)
);

// Summarize (non-streaming)
const summary = await this.chat.summarize(messages, model);

// Token estimation
const tokens = this.chat.estimateTokens(text, model);

Storage API

this.storage: Storage

LocalStorage wrapper for persistence.

Common methods:

// Key-value storage
this.storage.setItem(key, value);
const value = this.storage.getItem(key);
this.storage.removeItem(key);

// Session management
this.storage.saveSession(session);
const session = this.storage.getSession(sessionId);

// API keys (read-only)
const apiKeys = this.storage.getApiKeys();
const exaKey = this.storage.getExaApiKey();

Model picker

this.modelPicker: HTMLSelectElement

UI element for model selection.

Usage:

const model = this.modelPicker.value; // Current selected model
console.log('Using model:', model); // e.g., 'openai/gpt-4'

Feature registry

this.featureRegistry: FeatureRegistry

Plugin registry for accessing other plugins.

Common methods:

// Get other plugins
const otherPlugin = this.featureRegistry.getFeature('other-plugin-id');

// Emit events
this.featureRegistry.emit('my-event', event);

// Get registered commands
const commands = this.featureRegistry.getSlashCommands();

App instance

this.app: App

Main application instance (use sparingly, prefer specific APIs).

Common methods:

// Session management
this.app.saveSession();

// Modals (prefer custom modals in your plugin)
this.app.modalManager.showModal(modalId);

Slash command handlers

Slash command handlers are registered via FeatureRegistry and called when users type the command.

Handler signature

async handlerMethod(command: string, args: string, context: Object): Promise<void>

Parameters:

  • command - Full command string (e.g., '/mycommand')
  • args - Everything after the command (e.g., 'arg1 arg2')
  • context - Object with:
  • text - Selected text or node content (string)

Example:

async handleMyCommand(command, args, context) {
    console.log('Command:', command);  // '/mycommand'
    console.log('Args:', args);        // 'arg1 arg2'
    console.log('Context:', context.text); // Selected text or ''

    // Parse args
    const parts = args.split(' ');
    const firstArg = parts[0];

    // Use context
    if (context.text) {
        console.log('User selected:', context.text);
    }

    // Implement command logic
    await this.doSomething(firstArg);
}

Best practices

State management

Do:

constructor(context) {
    super(context);

    // Per-instance state for concurrent operations
    this.operations = new Map();

    // Feature-wide state
    this.config = {};
}

Don't:

constructor(context) {
    super(context);

    // Single variables for concurrent operations
    this.currentOperation = null; // BAD: only one operation can run
}

Resource cleanup

Always clean up in onUnload():

async onUnload() {
    // Abort ongoing operations
    for (const [id, op] of this.operations.entries()) {
        op.abortController.abort();
    }
    this.operations.clear();

    // Clear intervals
    if (this.intervalId) {
        clearInterval(this.intervalId);
    }

    // Save state
    this.storage.setItem('state', JSON.stringify(this.state));
}

Error handling

Always handle errors in async operations:

async handleCommand(command, args, context) {
    try {
        await this.doAsyncWork(args);
        this.showToast('Success!', 'success');
    } catch (error) {
        console.error('Command failed:', error);
        this.showToast(`Error: ${error.message}`, 'error');
    }
}

Binding event handlers

Bind methods correctly:

getEventSubscriptions() {
    return {
        // Correct: bind this
        'my-event': this.onMyEvent.bind(this),

        // Wrong: loses 'this' context
        // 'my-event': this.onMyEvent,
    };
}

Testing

Test plugins with PluginTestHarness:

import { PluginTestHarness } from '/static/js/plugin-test-harness.js';

const harness = new PluginTestHarness();

await harness.loadPlugin({
    id: 'my-feature',
    feature: MyFeature,
    slashCommands: [{ command: '/mycommand', handler: 'handleMyCommand' }],
});

// Test command
await harness.executeCommand('/mycommand', 'arg', { text: 'context' });

// Test event
const event = new CanvasEvent('test-event', { data: 'value' });
await harness.emitEvent('test-event', event);

// Verify
const nodes = harness.mockCanvas.getRenderedNodes();
console.assert(nodes.length === 1);

// Cleanup
await harness.unloadPlugin('my-feature');

See also