This skill should be used when moving data between storage locations, changing data structures, renaming fields, or removing deprecated data. Covers schema versioning, safe migration methods, the Foundry unset operator, and idempotent migrations.
Implements safe, version-controlled data migrations for Foundry VTT modules when changing data structures. Claude will use this whenever moving data between storage locations, renaming fields, or removing deprecated data.
/plugin marketplace add ImproperSubset/hh-agentics/plugin install fvtt-dev@hh-agenticsThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Implement safe, version-controlled data migrations for Foundry VTT modules when changing data structures or storage locations.
Invoke this skill when implementing changes that require migrating existing user data:
Moving data between storage locations:
actor.system.field → actor.flags.myModule.fieldactor.system.field → embedded itemChanging data structures:
Changing data types:
Removing deprecated data:
Foundry version compatibility:
Additive changes only:
Non-breaking changes:
Foundry modules track data structure versions using a schemaVersion setting:
// In settings.js - register the version tracker
game.settings.register("my-module", "schemaVersion", {
name: "Schema Version",
scope: "world", // World-level (not per-client)
config: false, // Hidden from UI
type: Number,
default: 0, // 0 = never migrated
});
How it works:
ready hookcurrentVersion >= targetVersion, migration is skippedWorld Created (v0)
↓
First Load → ready hook
↓
Migration.migrate() called
↓
Check: currentVersion (0) < targetVersion (1)?
↓ YES
Run migration steps
↓
Set schemaVersion = 1
↓
Show completion notification
↓
Future loads: currentVersion (1) >= targetVersion (1) → SKIP
Ask yourself:
Example scenarios:
// Scenario A: Moving system field to flag
OLD: actor.system["background-details"]
NEW: actor.flags["bitd-alternate-sheets"].background_details
// Scenario B: Changing structure
OLD: actor.flags.myModule["equipped-items"] = [{ id: "abc" }, { id: "def" }]
NEW: actor.flags.myModule["equipped-items"] = { "abc": {...}, "def": {...} }
// Scenario C: Cleaning up orphaned data
OLD: actor.flags.myModule.abilityProgress = { "Reflexes": 2, "abc123": 1 }
NEW: actor.flags.myModule.abilityProgress = { "abc123": 1 } // Remove name-based keys
In scripts/migration.js:
export class Migration {
static async migrate() {
const currentVersion = game.settings.get(MODULE_ID, "schemaVersion") || 0;
const targetVersion = 2; // ← INCREMENT THIS (was 1, now 2)
if (currentVersion < targetVersion) {
// ... migration logic
}
}
}
Version numbering:
targetVersion = 1 (first migration)Add a new static method to Migration class:
static async migrateFieldName(actor) {
const oldValue = foundry.utils.getProperty(actor, "system.old-field");
const newValue = actor.getFlag(MODULE_ID, "new_field");
// Check if migration needed
if (!oldValue) return; // No old data to migrate
if (newValue) {
// New data already exists - just clean up old
await actor.update({ "system.-=old-field": null });
return;
}
// Perform migration
const updates = {};
updates[`flags.${MODULE_ID}.new_field`] = oldValue;
updates["system.-=old-field"] = null;
await actor.update(updates);
console.log(`[MyModule] Migrated old-field for ${actor.name}`);
}
Migration method naming:
migrate{FeatureName} - Descriptive of what's being migratedmigrateEquippedItems, migrateAbilityProgress, migrateLegacyFieldsAdd to the migration sequence:
static async migrate() {
const currentVersion = game.settings.get(MODULE_ID, "schemaVersion") || 0;
const targetVersion = 2;
if (currentVersion < targetVersion) {
ui.notifications.info(
"My Module: Migrating data, please wait..."
);
for (const actor of game.actors) {
if (actor.type !== "character") continue;
// Step 1: Existing migration
await this.migrateEquippedItems(actor);
// Step 2: NEW migration (add here)
await this.migrateFieldName(actor);
}
await game.settings.set(MODULE_ID, "schemaVersion", targetVersion);
ui.notifications.info("My Module: Migration complete.");
}
}
Order considerations:
Testing checklist:
Create test world with OLD data structure:
game.actors.getName("Test").update({ "system.old-field": "test" })Trigger migration:
ready hook)Verify migration results:
game.actors.getName("Test").getFlag("my-module", "new_field")game.actors.getName("Test").system["old-field"] (should be undefined)Verify migration runs ONCE:
Test with multiple actors:
static async migrateLegacyFields(actor) {
const updates = {};
let changed = false;
// Get old and new locations
const oldValue = foundry.utils.getProperty(actor, "system.old-field");
const newValue = actor.getFlag(MODULE_ID, "new_field");
if (oldValue && !newValue) {
// Old exists, new doesn't - migrate
updates[`flags.${MODULE_ID}.new_field`] = oldValue;
updates["system.-=old-field"] = null;
changed = true;
} else if (oldValue && newValue) {
// Both exist - favor new, clean up old
updates["system.-=old-field"] = null;
changed = true;
}
if (changed) {
await actor.update(updates);
console.log(`[MyModule] Migrated old-field for ${actor.name}`);
}
}
Key points:
actor.update() callstatic async migrateEquippedItems(actor) {
const equipped = actor.getFlag(MODULE_ID, "equipped-items");
// Check if still in old format (array)
if (Array.isArray(equipped)) {
const newMap = {};
for (const item of equipped) {
if (item && item.id) {
newMap[item.id] = item;
}
}
await actor.setFlag(MODULE_ID, "equipped-items", newMap);
console.log(`[MyModule] Migrated equipped items for ${actor.name}`);
}
}
Key points:
Array.isArray() to detect old formatsetFlag() to replace entire structurestatic async migrateAbilityProgress(actor) {
const progressMap = actor.getFlag(MODULE_ID, "abilityProgress") || {};
if (foundry.utils.isEmpty(progressMap)) return;
let changed = false;
const updates = {};
for (const [key, value] of Object.entries(progressMap)) {
// Foundry IDs are always 16 characters
if (key.length !== 16) {
// This is likely a name-based key (orphaned) - remove it
updates[`flags.${MODULE_ID}.abilityProgress.-=${key}`] = null;
changed = true;
}
}
if (changed) {
await actor.update(updates);
console.log(`[MyModule] Cleaned orphaned flags for ${actor.name}`);
}
}
Key points:
-= syntax to remove specific keys: "flags.module.field.-=keyToRemove"static async migrateLegacyFields(actor) {
const updates = {};
let changed = false;
// Migration 1: background-details
const oldDetails = foundry.utils.getProperty(actor, "system.background-details");
const newDetails = actor.getFlag(MODULE_ID, "background_details");
if (oldDetails && !newDetails) {
updates[`flags.${MODULE_ID}.background_details`] = oldDetails;
updates["system.-=background-details"] = null;
changed = true;
}
// Migration 2: vice-purveyor
const oldPurveyor = foundry.utils.getProperty(actor, "system.vice-purveyor");
const newPurveyor = actor.getFlag(MODULE_ID, "vice_purveyor");
if (oldPurveyor && !newPurveyor) {
updates[`flags.${MODULE_ID}.vice_purveyor`] = oldPurveyor;
updates["system.-=vice-purveyor"] = null;
changed = true;
}
// Single batched update for all changes
if (changed) {
await actor.update(updates);
console.log(`[MyModule] Migrated legacy fields for ${actor.name}`);
}
}
Key points:
updates objectactor.update() call at the endupdates["system.field"] = newValue; // Set system field
updates[`flags.${MODULE_ID}.field`] = newValue; // Set flag
updates["system.nested.deep.field"] = newValue; // Set nested field
updates["system.-=oldField"] = null; // Remove system field
updates[`flags.${MODULE_ID}.-=oldFlag`] = null; // Remove flag
updates["flags.module.map.-=keyName"] = null; // Remove map key
The -= syntax:
null or undefined (which leaves the key)// Safe nested property access (returns undefined if any level missing)
const value = foundry.utils.getProperty(actor, "system.deep.nested.field");
// vs direct access (throws if intermediate property missing)
const value = actor.system.deep.nested.field; // Error if 'deep' undefined!
for (const actor of game.actors) {
if (actor.type !== "character") continue; // Only migrate characters
await this.migrateCharacterFields(actor);
}
for (const actor of game.actors) {
// Migrate all actors regardless of type
await this.migrateCommonFields(actor);
}
for (const actor of game.actors) {
if (actor.type === "character") {
await this.migrateCharacterFields(actor);
} else if (actor.type === "crew") {
await this.migrateCrewFields(actor);
}
}
// Start notification
ui.notifications.info("My Module: Migrating data, please wait...");
// After migration completes
ui.notifications.info("My Module: Migration complete.");
// Error notification (if migration fails)
ui.notifications.error("My Module: Migration failed. See console for details.");
When to notify:
// Per-actor migration success
console.log(`[MyModule] Migrated equipped items for ${actor.name}`);
// Overall migration start
console.log("[MyModule] Starting migration to schema v2");
// Debug information
console.log(`[MyModule] Found ${Object.keys(progressMap).length} progress entries`);
Logging best practices:
[MyModule]console.log for success (not console.error)static async renameFlag(actor) {
const oldValue = actor.getFlag(MODULE_ID, "oldName");
const newValue = actor.getFlag(MODULE_ID, "newName");
if (oldValue && !newValue) {
await actor.setFlag(MODULE_ID, "newName", oldValue);
await actor.unsetFlag(MODULE_ID, "oldName");
console.log(`[MyModule] Renamed flag for ${actor.name}`);
}
}
static async combineFlags(actor) {
const flag1 = actor.getFlag(MODULE_ID, "flag1");
const flag2 = actor.getFlag(MODULE_ID, "flag2");
const combined = actor.getFlag(MODULE_ID, "combined");
if ((flag1 || flag2) && !combined) {
const newValue = {
part1: flag1 || {},
part2: flag2 || {}
};
const updates = {};
updates[`flags.${MODULE_ID}.combined`] = newValue;
updates[`flags.${MODULE_ID}.-=flag1`] = null;
updates[`flags.${MODULE_ID}.-=flag2`] = null;
await actor.update(updates);
console.log(`[MyModule] Combined flags for ${actor.name}`);
}
}
static async migrateItemData(actor) {
const updates = [];
for (const item of actor.items) {
if (item.type === "ability") {
const oldField = item.system.oldField;
if (oldField) {
updates.push({
_id: item.id,
"system.newField": oldField,
"system.-=oldField": null
});
}
}
}
if (updates.length > 0) {
await actor.updateEmbeddedDocuments("Item", updates);
console.log(`[MyModule] Migrated ${updates.length} items for ${actor.name}`);
}
}
static async migrate() {
const currentVersion = game.settings.get(MODULE_ID, "schemaVersion") || 0;
const targetVersion = 2;
if (currentVersion < targetVersion) {
ui.notifications.info("My Module: Migrating data, please wait...");
try {
for (const actor of game.actors) {
if (actor.type !== "character") continue;
// Wrap each migration in try-catch to prevent one failure from blocking others
try {
await this.migrateEquippedItems(actor);
await this.migrateAbilityProgress(actor);
} catch (err) {
console.error(`[MyModule] Migration failed for ${actor.name}:`, err);
ui.notifications.warn(`Migration failed for ${actor.name} - see console`);
}
}
// Update version even if some actors failed
await game.settings.set(MODULE_ID, "schemaVersion", targetVersion);
ui.notifications.info("My Module: Migration complete.");
} catch (err) {
console.error("[MyModule] Critical migration error:", err);
ui.notifications.error("My Module: Migration failed. See console for details.");
}
}
}
Error handling strategies:
// In browser console
const actor = game.actors.getName("Test Character");
// Set old system field
await actor.update({ "system.old-field": "test value" });
// Set old array-based flag
await actor.setFlag("my-module", "equipped-items", [{ id: "abc123" }]);
// Verify old data
console.log(actor.system["old-field"]); // "test value"
console.log(actor.getFlag("my-module", "equipped-items")); // [{ id: "abc123" }]
// Force schemaVersion back to 0
await game.settings.set("my-module", "schemaVersion", 0);
// Run migration
await Migration.migrate();
// Check results
const actor = game.actors.getName("Test Character");
console.log(actor.getFlag("my-module", "new_field")); // "test value"
console.log(actor.system["old-field"]); // undefined (removed)
Before committing migration code:
targetVersion in migrate() methodmigrate{FeatureName})actor.update() call-= syntax to remove old fieldsmigrate() sequencescripts/migration.jsscripts/settings.jsscripts/module.js (ready hook)CONTRIBUTING.md line 56For BitD Alternate Sheets module:
scripts/settings.js line 59scripts/module.js line 43 (ready hook)scripts/migration.js line 6character, crew, npcThis skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.