Skip to content

Hooks & Events Guide

Alma provides a powerful hooks system that allows plugins to react to and modify application behavior.

Overview

Hooks are triggered at specific points in the application lifecycle. Plugins can:

  • Observe events (read-only)
  • Modify data (transform hooks)
  • Cancel operations (cancellable hooks)

Available Hooks

Chat Lifecycle Hooks

HookTypeDescription
chat.message.willSendModifiable, CancellableBefore sending a user message
chat.message.didSendObservableAfter a message is sent
chat.message.didReceiveObservableAfter receiving an AI response
chat.thread.createdObservableWhen a new thread is created
chat.thread.deletedObservableWhen a thread is deleted
thread.activatedObservableWhen switching to a thread

Tool Lifecycle Hooks

HookTypeDescription
tool.willExecuteModifiable, CancellableBefore a tool executes
tool.didExecuteModifiableAfter a tool completes
tool.onErrorModifiableWhen a tool throws an error

Application Lifecycle Hooks

HookTypeDescription
app.readyObservableWhen Alma finishes loading
app.willQuitCancellableBefore Alma closes

Using Hooks

Basic Subscription

typescript
const { events, logger } = context;

// Subscribe to an event
const disposable = events.on('chat.message.didReceive', (input, output) => {
    logger.info(`Received: ${input.response.content}`);
});

// One-time subscription
events.once('app.ready', (input) => {
    logger.info('Alma is ready!');
});

// Don't forget to dispose
disposable.dispose();

Priority

Higher priority handlers run first:

typescript
events.on('chat.message.willSend', handler, { priority: 100 }); // Runs first
events.on('chat.message.willSend', handler, { priority: 0 });   // Default
events.on('chat.message.willSend', handler, { priority: -100 }); // Runs last

Hook Reference

chat.message.willSend

Triggered before sending a user message. Can modify content or cancel.

Input:

typescript
interface ChatMessageWillSendInput {
    threadId: string;
    content: string;
    model: string;
    providerId: string;
}

Output (modifiable):

typescript
interface ChatMessageWillSendOutput {
    content?: string;   // Modified message content
    cancel?: boolean;   // Set to true to cancel sending
}

Example: Prompt Enhancement

typescript
events.on('chat.message.willSend', (input, output) => {
    // Add a prefix to all messages
    output.content = `[Enhanced] ${input.content}`;
});

Example: Content Filter

typescript
events.on('chat.message.willSend', (input, output) => {
    if (input.content.includes('forbidden')) {
        output.cancel = true;
        ui.showError('Message contains forbidden content');
    }
});

chat.message.didReceive

Triggered after receiving an AI response. Provides token usage and pricing.

Input:

typescript
interface ChatMessageDidReceiveInput {
    threadId: string;
    model: string;
    providerId: string;
    response: {
        content: string;
        usage?: TokenUsage;
    };
    pricing?: ModelPricing;
}

interface TokenUsage {
    promptTokens: number;
    completionTokens: number;
    totalTokens: number;
    cachedInputTokens?: number;
}

interface ModelPricing {
    input?: number;   // $ per million input tokens
    output?: number;  // $ per million output tokens
    cacheRead?: number; // $ per million cached tokens
}

Example: Token Tracking

typescript
let totalTokens = 0;
let totalCost = 0;

events.on('chat.message.didReceive', (input) => {
    const { usage, pricing } = input;

    if (usage) {
        totalTokens += usage.totalTokens;

        if (pricing) {
            const inputCost = (usage.promptTokens / 1_000_000) * (pricing.input ?? 0);
            const outputCost = (usage.completionTokens / 1_000_000) * (pricing.output ?? 0);
            totalCost += inputCost + outputCost;
        }

        logger.info(`Total: ${totalTokens} tokens, $${totalCost.toFixed(4)}`);
    }
});

thread.activated

Triggered when switching to a thread. Provides historical usage data.

Input:

typescript
interface ThreadActivatedInput {
    threadId: string;
    title?: string;
    model?: string;
    providerId?: string;
    usage?: TokenUsage;     // Historical usage for this thread
    pricing?: ModelPricing; // Current model pricing
}

Example: Status Bar Update

typescript
events.on('thread.activated', (input) => {
    const { threadId, title, usage } = input;

    if (usage) {
        statusBarItem.text = `Tokens: ${usage.totalTokens}`;
    } else {
        statusBarItem.text = 'Tokens: 0';
    }

    logger.info(`Switched to thread: ${title || threadId}`);
});

tool.willExecute

Triggered before a tool executes. Can modify arguments or cancel.

Input:

typescript
interface ToolWillExecuteInput {
    tool: string;
    args: Record<string, unknown>;
    context: ToolExecutionContext;
}

interface ToolExecutionContext {
    threadId: string;
    messageId: string;
    sessionId?: string;
}

Output (modifiable):

typescript
interface ToolWillExecuteOutput {
    args?: Record<string, unknown>;  // Modified arguments
    cancel?: boolean;                // Cancel execution
}

Example: Logging Tool Calls

typescript
events.on('tool.willExecute', (input) => {
    logger.info(`Tool ${input.tool} called with:`, input.args);
    logger.debug(`Thread: ${input.context.threadId}`);
});

Example: Argument Validation

typescript
events.on('tool.willExecute', (input, output) => {
    if (input.tool === 'executeCode' && !input.args.sandbox) {
        output.args = { ...input.args, sandbox: true };
        logger.warn('Forced sandbox mode for code execution');
    }
});

tool.didExecute

Triggered after a tool completes successfully. Can modify the result.

Input:

typescript
interface ToolDidExecuteInput {
    tool: string;
    args: Record<string, unknown>;
    result: unknown;
    duration: number;  // Execution time in milliseconds
    context: ToolExecutionContext;
}

Output (modifiable):

typescript
interface ToolDidExecuteOutput {
    result?: unknown;  // Modified result
}

Example: Performance Tracking

typescript
const toolStats = new Map<string, { count: number; totalTime: number }>();

events.on('tool.didExecute', (input) => {
    const stats = toolStats.get(input.tool) || { count: 0, totalTime: 0 };
    stats.count++;
    stats.totalTime += input.duration;
    toolStats.set(input.tool, stats);

    const avgTime = stats.totalTime / stats.count;
    logger.info(`${input.tool}: ${input.duration}ms (avg: ${avgTime.toFixed(0)}ms)`);
});

Example: Result Transformation

typescript
events.on('tool.didExecute', (input, output) => {
    if (input.tool === 'readFile' && typeof input.result === 'string') {
        // Truncate long results
        if (input.result.length > 10000) {
            output.result = input.result.slice(0, 10000) + '\n... (truncated)';
        }
    }
});

tool.onError

Triggered when a tool throws an error. Can provide a fallback result.

Input:

typescript
interface ToolOnErrorInput {
    tool: string;
    args: Record<string, unknown>;
    error: Error;
    duration: number;
    context: ToolExecutionContext;
}

Output (modifiable):

typescript
interface ToolOnErrorOutput {
    result?: unknown;   // Fallback result
    rethrow?: boolean;  // Set to false to suppress the error
}

Example: Error Recovery

typescript
events.on('tool.onError', (input, output) => {
    logger.error(`Tool ${input.tool} failed:`, input.error);

    // Provide fallback for specific tools
    if (input.tool === 'fetchUrl') {
        output.rethrow = false;
        output.result = {
            error: true,
            message: `Failed to fetch: ${input.error.message}`,
        };
    }
});

app.ready

Triggered when Alma finishes loading.

Input:

typescript
interface AppReadyInput {
    version: string;
}

Example:

typescript
events.once('app.ready', (input) => {
    logger.info(`Alma ${input.version} is ready`);
    ui.showNotification('Plugin loaded successfully!');
});

app.willQuit

Triggered before Alma closes. Can cancel quitting.

Input:

typescript
interface AppWillQuitInput {
    // empty
}

Output:

typescript
interface AppWillQuitOutput {
    cancel?: boolean;
}

Example: Unsaved Changes Warning

typescript
events.on('app.willQuit', async (input, output) => {
    if (hasUnsavedChanges) {
        const confirmed = await ui.showConfirmDialog(
            'You have unsaved changes. Are you sure you want to quit?',
            { type: 'warning' }
        );
        if (!confirmed) {
            output.cancel = true;
        }
    }
});

Best Practices

1. Always Dispose Subscriptions

typescript
const disposables: Disposable[] = [];

disposables.push(
    events.on('chat.message.didReceive', handler1),
    events.on('tool.didExecute', handler2)
);

return {
    dispose: () => disposables.forEach(d => d.dispose()),
};

2. Handle Errors in Handlers

typescript
events.on('chat.message.didReceive', async (input) => {
    try {
        await processMessage(input);
    } catch (error) {
        logger.error('Handler error:', error);
        // Don't throw - it would disrupt other handlers
    }
});

3. Use Priority Wisely

  • Use high priority (> 0) for validation/filtering
  • Use default priority (0) for normal processing
  • Use low priority (< 0) for logging/analytics

4. Keep Handlers Fast

Hooks run synchronously in sequence. Long-running handlers will block other plugins:

typescript
// Bad: Blocking operation
events.on('chat.message.didReceive', async (input) => {
    await fetch('https://api.example.com/log', { body: JSON.stringify(input) });
});

// Good: Fire and forget
events.on('chat.message.didReceive', (input) => {
    fetch('https://api.example.com/log', { body: JSON.stringify(input) })
        .catch(err => logger.error('Failed to log:', err));
});

5. Be Careful with Modifications

When modifying output, consider other plugins:

typescript
events.on('chat.message.willSend', (input, output) => {
    // Preserve existing modifications
    const content = output.content ?? input.content;
    output.content = content + '\n\n[Signed by MyPlugin]';
});