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
| Hook | Type | Description |
|---|---|---|
chat.message.willSend | Modifiable, Cancellable | Before sending a user message |
chat.message.didSend | Observable | After a message is sent |
chat.message.didReceive | Observable | After receiving an AI response |
chat.thread.created | Observable | When a new thread is created |
chat.thread.deleted | Observable | When a thread is deleted |
thread.activated | Observable | When switching to a thread |
Tool Lifecycle Hooks
| Hook | Type | Description |
|---|---|---|
tool.willExecute | Modifiable, Cancellable | Before a tool executes |
tool.didExecute | Modifiable | After a tool completes |
tool.onError | Modifiable | When a tool throws an error |
Application Lifecycle Hooks
| Hook | Type | Description |
|---|---|---|
app.ready | Observable | When Alma finishes loading |
app.willQuit | Cancellable | Before Alma closes |
Using Hooks
Basic Subscription
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:
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 lastHook Reference
chat.message.willSend
Triggered before sending a user message. Can modify content or cancel.
Input:
interface ChatMessageWillSendInput {
threadId: string;
content: string;
model: string;
providerId: string;
}Output (modifiable):
interface ChatMessageWillSendOutput {
content?: string; // Modified message content
cancel?: boolean; // Set to true to cancel sending
}Example: Prompt Enhancement
events.on('chat.message.willSend', (input, output) => {
// Add a prefix to all messages
output.content = `[Enhanced] ${input.content}`;
});Example: Content Filter
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:
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
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:
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
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:
interface ToolWillExecuteInput {
tool: string;
args: Record<string, unknown>;
context: ToolExecutionContext;
}
interface ToolExecutionContext {
threadId: string;
messageId: string;
sessionId?: string;
}Output (modifiable):
interface ToolWillExecuteOutput {
args?: Record<string, unknown>; // Modified arguments
cancel?: boolean; // Cancel execution
}Example: Logging Tool Calls
events.on('tool.willExecute', (input) => {
logger.info(`Tool ${input.tool} called with:`, input.args);
logger.debug(`Thread: ${input.context.threadId}`);
});Example: Argument Validation
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:
interface ToolDidExecuteInput {
tool: string;
args: Record<string, unknown>;
result: unknown;
duration: number; // Execution time in milliseconds
context: ToolExecutionContext;
}Output (modifiable):
interface ToolDidExecuteOutput {
result?: unknown; // Modified result
}Example: Performance Tracking
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
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:
interface ToolOnErrorInput {
tool: string;
args: Record<string, unknown>;
error: Error;
duration: number;
context: ToolExecutionContext;
}Output (modifiable):
interface ToolOnErrorOutput {
result?: unknown; // Fallback result
rethrow?: boolean; // Set to false to suppress the error
}Example: Error Recovery
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:
interface AppReadyInput {
version: string;
}Example:
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:
interface AppWillQuitInput {
// empty
}Output:
interface AppWillQuitOutput {
cancel?: boolean;
}Example: Unsaved Changes Warning
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
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
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:
// 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:
events.on('chat.message.willSend', (input, output) => {
// Preserve existing modifications
const content = output.content ?? input.content;
output.content = content + '\n\n[Signed by MyPlugin]';
});