Repository navigation
api hookmanager
Audience: external module developers.
This document covers the Hook Manager API for registering and managing FoundryVTT hooks through Coffee Pub Blacksmith. For general integration setup, see api-core.md.
Audience: Developers integrating with Blacksmith and using the Hook Manager for event handling.
The Hook Manager provides centralized hook management with:
- Priority-based execution (1=Critical, 2=High, 3=Normal, 4=Low, 5=Lowest)
- Context-based cleanup for batch operations
- Throttle/debounce support for performance optimization
- Dedupe protection to prevent duplicate registrations
- Automatic cleanup for "once" hooks
// Direct access - no await needed once Blacksmith is ready
const hookManager = BlacksmithHookManager;
BlacksmithHookManager.registerHook(...)const callbackId = BlacksmithHookManager.registerHook({
name: 'hookName', // Required: FoundryVTT hook name
description: 'Description', // Optional: Human-readable description
context: 'context-name', // Optional: Batch cleanup identifier
priority: 3, // Optional: 1-5, default: 3
key: 'uniqueKey', // Optional: Dedupe protection
options: { // Optional: Performance options — see caveats below.
once: true, // Auto-cleanup after first execution
throttleMs: 50 // Max once per 50ms
// debounceMs: 300 // Wait 300ms after last call — NOT with throttleMs or once
},
callback: (args) => { /* logic */ } // Required: Your callback function - ALWAYS LAST
});The three options are not freely combinable — do not set once, throttleMs, and debounceMs all at once. Two combinations misbehave silently:
-
throttleMs+debounceMs—throttleMswins anddebounceMsis silently discarded (the implementation isif (throttleMs) … else if (debounceMs)). -
once+debounceMs— your callback never runs. Debounce only schedules the work;oncethen removes the callback the moment the hook fires, and removal clears the pending timer before it elapses. You get a valid callback id, no error, and silence.
Pick at most one of throttleMs / debounceMs, and don't pair debounceMs with once. The code does not warn about either combination.
For readability and consistency, use this order:
-
name(required) -
description(optional) -
context(optional) -
priority(optional) -
key(optional) -
canCancel(optional) -
options(optional) -
callback(required) - ALWAYS LAST for readability
-
Required:
name,callback -
Optional:
description,priority,options,key,context,canCancel -
Options:
once,throttleMs,debounceMs - Performance: Throttling and debouncing work as documented
-
Cleanup:
once: trueauto-removes hooks after first execution -
Dedupe:
keyprevents duplicate registrations -
Batch cleanup:
contextenables group removal -
Cancellation: opt-in via
canCancel-- see below
A callback's return value is ignored unless the registration declared canCancel: true. Returning
false from an ordinary callback does nothing.
BlacksmithHookManager.registerHook({
name: 'preCreateItem',
description: 'Block items the vault refuses',
context: 'my-module',
canCancel: true, // top level, not inside `options`
callback: (item) => {
if (isForbidden(item)) return false; // cancels the creation
}
});This is opt-in because every callback registered against one hook name shares a single Foundry handler.
An undeclared false would speak for all of them, and the dangerous case is not a deliberate veto -- it
is an ordinary callback whose natural return value happens to be a boolean.
callback: (doc) => this.tracked.has(doc.id) is a normal thing to write, and on a pre* hook it would
block the operation world-wide, for every module, with nothing logged.
Three things to know before using it:
-
A cancelling callback stops the chain. Callbacks that would have run after it do not, which is what
Foundry does when a handler vetoes. Do not use
canCancelon a hook where other registrants must always see the event. -
It works on any hook name, not only
pre*. Whetherfalsemeans anything is Foundry's decision -- hooks fired withHooks.callhonour it, hooks fired withHooks.callAlldo not. -
canCancelinsideoptionsis not honoured, and logs a console warning saying so, because a registration that looks like it can veto and cannot is the worst failure available to this flag.
If you need cancellation on a hook where you would rather not depend on any of the above, registering a
native Hooks.on and tracking your own teardown is a legitimate choice. Squire does this deliberately for
the pre* family.
// Remove a specific callback
const removed = BlacksmithHookManager.removeCallback(callbackId);
// Cleanup by context
BlacksmithHookManager.disposeByContext('context-name');
// Get statistics and debugging info
BlacksmithHookManager.showHooks();
BlacksmithHookManager.showHookDetails();
BlacksmithHookManager.getStats();const hookId = BlacksmithHookManager.registerHook({
name: 'updateActor',
description: 'My module: Track actor changes',
context: 'my-module',
priority: 3,
callback: (actor, changes) => {
console.log(`Actor ${actor.name} updated:`, changes);
}
});const fullHookId = BlacksmithHookManager.registerHook({
name: 'updateActor',
description: 'My module: Track actor changes',
context: 'my-module',
priority: 3,
key: 'unique-actor-tracker', // Prevents duplicate registrations
options: {
once: false, // Don't auto-cleanup
throttleMs: 100 // Max once per 100ms
},
callback: (actor, changes) => {
console.log(`Actor ${actor.name} updated:`, changes);
}
});const combatHookId = BlacksmithHookManager.registerHook({
name: 'updateCombat',
description: 'My module: Track combat changes',
context: 'my-combat-tracker',
priority: 2, // High priority - core functionality
callback: (combat, changed) => {
if (changed.round !== undefined) {
console.log(`Round changed to ${changed.round}`);
}
}
});const uiHookId = BlacksmithHookManager.registerHook({
name: 'renderChatMessageHTML',
description: 'My module: Enhance chat messages',
context: 'my-chat-enhancer',
priority: 4, // Low priority - UI updates
callback: (message, html, data) => {
// `html` is a native HTMLElement on v13+, NOT jQuery. Use DOM methods.
html.querySelector('.message-content')?.classList.add('my-enhanced-style');
}
});On v13+, html is a native HTMLElement, not jQuery. Two things to know:
- The name: v13 core fires
renderChatMessageHTMLand passes a nativeHTMLElement. RegisteringrenderChatMessagestill works — HookManager silently rewrites it torenderChatMessageHTMLand warns once — but register the real name. - The call:
.addClass()is jQuery and throwsTypeError. Blacksmith bolts a small.find()shim onto the element for backward compatibility, but that shim returns a plain Array with only.clickattached —.find()survives,.addClass()does not. UsequerySelector/classListinstead.
const welcomeHookId = BlacksmithHookManager.registerHook({
name: 'userConnected',
description: 'My module: Welcome message',
context: 'my-welcome',
priority: 5,
options: { once: true }, // Auto-cleanup after first execution
callback: (user, connected) => {
if (connected) console.log(`Welcome back, ${user.name}!`);
}
});Note: there is no userLogin hook in Foundry — the real hook is userConnected(user, connected), used above.
// Throttle noisy hooks (e.g., updateToken)
const throttledHookId = BlacksmithHookManager.registerHook({
name: 'updateToken',
description: 'My module: Throttled token updates',
context: 'my-token-tracker',
priority: 4,
options: { throttleMs: 50 }, // Max once per 50ms
callback: (token, changes) => {
console.log('Token updated:', token.name);
}
});
// Debounce for final state — fire once after a burst of changes settles
const debouncedHookId = BlacksmithHookManager.registerHook({
name: 'updateActor',
description: 'My module: Debounced actor sync',
context: 'my-actor-sync',
priority: 4,
options: { debounceMs: 300 }, // Wait 300ms after the last update
callback: (actor, changes) => {
console.log('Actor settled:', actor.name);
}
});Note: there is no searchInput hook in Foundry. Debounce is best shown on a hook that genuinely bursts, like updateActor (above) or updateToken. Remember debounceMs cannot be combined with once or throttleMs — see the options caveat above.
| Priority | Level | Use For |
|---|---|---|
| 1 | CRITICAL | System cleanup, critical features, must-run-first operations |
| 2 | HIGH | Core functionality, data validation, early processing |
| 3 | NORMAL | Most hooks, standard functionality (default) |
| 4 | LOW | Nice-to-have features, UI updates |
| 5 | LOWEST | Cosmetic features, debug hooks |
// Priority 1 - System integrity
BlacksmithHookManager.registerHook({ name: 'canvasReady', priority: 1, ... });
// Priority 2 - Core features
BlacksmithHookManager.registerHook({ name: 'updateActor', priority: 2, ... });
// Priority 3 - Standard (default)
// NOTE: register 'renderChatMessageHTML' on v13+. Passing 'renderChatMessage' still works —
// HookManager silently rewrites it and warns — but see the caveat under UI Enhancement below.
BlacksmithHookManager.registerHook({ name: 'renderChatMessageHTML', priority: 3, ... });
// Priority 4 - UI enhancements
BlacksmithHookManager.registerHook({ name: 'renderApplication', priority: 4, ... });
// Priority 5 - Debug/cosmetic
BlacksmithHookManager.registerHook({ name: 'renderPlayers', priority: 5, ... });Note: renderPlayerList is a v12 name. Foundry emits render{ClassName} for an Application and each of its parent classes; in v13 the player-list class is Players, so the hook is renderPlayers. renderPlayerList never fires on v13.
Limit execution frequency for noisy hooks:
BlacksmithHookManager.registerHook({
name: 'updateToken',
options: { throttleMs: 50 }, // Max once per 50ms
callback: (token, changes) => { /* ... */ }
});Wait for activity to settle before running:
BlacksmithHookManager.registerHook({
name: 'updateActor',
options: { debounceMs: 300 }, // Fire once, 300ms after a burst of updates settles
callback: (actor, changes) => { /* ... */ }
});Auto-remove after first execution:
BlacksmithHookManager.registerHook({
name: 'userConnected',
options: { once: true },
callback: (user, connected) => { /* ... */ }
});Use these to debug and inspect hook registrations:
// Show hook summary (count, names)
BlacksmithAPIHooks()
// Detailed hook information by priority
BlacksmithAPIHookDetails()
// Raw hook statistics
BlacksmithAPIHookStats()
// Expanded details with full formatting
BlacksmithAPIHookExpandedDetails()| Command | Returns | Description |
|---|---|---|
BlacksmithAPIHooks() |
Object | Hook count, names, and summary data |
BlacksmithAPIHookDetails() |
Object | Hooks organized by priority levels |
BlacksmithAPIHookStats() |
Object | Statistical breakdown by priority/context |
BlacksmithAPIHookExpandedDetails() |
Console Output | Detailed hook information with full formatting |
// GOOD: Use global objects directly
BlacksmithHookManager.registerHook(...)
// ALTERNATIVE: Store reference if preferred
const hookManager = BlacksmithHookManager;// GOOD: Descriptive context for cleanup
BlacksmithHookManager.registerHook({
name: 'updateActor',
context: 'my-module-actor-tracking',
callback: () => { /* ... */ }
});
// BAD: No context makes cleanup difficult
BlacksmithHookManager.registerHook({
name: 'updateActor',
callback: () => { /* ... */ }
});// GOOD: Clear, descriptive
BlacksmithHookManager.registerHook({
name: 'updateActor',
description: 'My Module: Track actor HP changes for health panel updates',
callback: () => { /* ... */ }
});
// BAD: Vague
BlacksmithHookManager.registerHook({
name: 'updateActor',
description: 'Updates stuff',
callback: () => { /* ... */ }
});- Use priority 3 (NORMAL) for most hooks
- Only use 1 or 2 for critical/core functionality
- Use 4 or 5 for cosmetic/debug features
const myHookIds = [];
myHookIds.push(BlacksmithHookManager.registerHook({
name: 'updateActor',
context: 'my-module',
callback: () => { /* ... */ }
}));
// Tie cleanup to something that actually fires — see the warning below.
// If you hold a context, one call removes every callback registered under it:
BlacksmithHookManager.disposeByContext('my-module');Neither closeGame nor unloadModule is a Foundry hook — both appear zero times in Foundry v13, and nothing (Blacksmith included) ever calls Hooks.call('unloadModule'). Registering either succeeds silently and the callback never runs, so any cleanup written against them never executes.
Foundry does not unload modules at runtime: enabling or disabling a module forces a world reload, which tears down every hook, timer, and listener anyway. So there is no moment at which an "unregister my hooks" callback both fires and matters.
What to do:
- Use
contexton everyregisterHook(see above). It costs nothing and makes bulk cleanup a single call if you ever have a real trigger — your own settings toggle, a window close, a feature being switched off at runtime. - Call
disposeByContext()from your lifecycle, not from a hook you hope exists. - Do not add a
closeGame/unloadModulelistener and consider cleanup handled. It is not.
Issue: "HookManager is not defined"
// Solution: Use global objects directly
BlacksmithHookManager.registerHook(...)Issue: Hook not executing
// Check if hook is registered
const stats = BlacksmithHookManager.getStats();
console.log('Registered hooks:', stats.hooks);
// Verify hook name is correct (FoundryVTT hook names are case-sensitive)Issue: Performance problems
// Use throttling for noisy hooks
BlacksmithHookManager.registerHook({
name: 'updateToken',
options: { throttleMs: 100 },
callback: () => { /* ... */ }
});
// Use debouncing for user input
BlacksmithHookManager.registerHook({
name: 'updateActor',
options: { debounceMs: 500 },
callback: () => { /* ... */ }
});try {
if (!BlacksmithHookManager) {
throw new Error('HookManager not available');
}
const hookId = BlacksmithHookManager.registerHook({
name: 'updateActor',
description: 'My module: Actor update handler',
context: 'my-module',
priority: 3,
callback: (actor, changes) => { /* ... */ }
});
console.log('Hook registered successfully:', hookId);
} catch (error) {
console.error('Failed to register hook:', error);
// Fallback to direct FoundryVTT hooks
Hooks.on('updateActor', (actor, changes) => {
// My logic here
});
}From the integration test suite in api-core.md:
const hookName = 'createActor';
const hookContext = 'my-module-id';
const hookResult = BlacksmithHookManager.registerHook({
name: hookName,
description: 'API Test Hook',
context: hookContext,
// Priority is 1-5. This example used to pass 50: it is accepted without complaint (there is no
// validation), sorts dead last, and then vanishes from the debug tools — showHookDetails() only
// iterates priorities 1..5, so a priority-50 hook is invisible to BlacksmithAPIHookExpandedDetails().
priority: 3,
key: `${hookContext}-${hookName}`,
options: {},
callback: async (actor) => {
BlacksmithUtils.postConsoleAndNotification(
'my-module-id',
'Hook triggered!',
{ actorId: actor.id, name: actor.name },
false,
false
);
}
});
console.log('Hook registration result:', hookResult);- API Core - General integration setup, module registration, and other APIs
- Architecture Hook Manager - Internal architecture (for Blacksmith developers)
- Ai assistance
- Applicationv2 traps
- CSS traps
- dnd5e conditions
- Documentation standard
- Token actor identity
- Campaign
- Canvas
- Chatcards
- Compendiums
- Contextmenu
- Core
- Create journal entry
- Dialog
- Active Effects
- Entity list
- Equiplocations
- Geography
- GM request
- Gmnotes
- Health
- Hookmanager
- Importer
- Inventory
- Menubar
- Notes
- Party
- Pins
- Quantity split
- Requestroll
- Rolls
- Scene config
- Sockets
- Stats
- Tags
- Toast
- Tokens
- Toolbar
- Window
- Worldclock