Plugin API
Overview
Tokenizer 2 exposes a hook-based plugin system that allows other Foundry VTT modules to:
- Register toolbar tools with custom icons, panels, and consent dialogs
- Listen to lifecycle hooks fired during editor use
- Inject license information into the Settings → Licenses tab
Plugins are standard Foundry VTT modules that declare a dependency on tokenizer-2 and register themselves via the tokenizer-2.registerPlugins hook.
They talk to the editor through the versioned Editor API.
Quick Start
1. Create a Foundry module
my-plugin/
├── module.json
├── scripts/
│ └── index.js
└── lang/
└── en.json
module.json - declare a dependency on tokenizer-2:
{
"id": "my-tokenizer-plugin",
"title": "My Tokenizer Plugin",
"version": "1.0.0",
"compatibility": { "minimum": "13", "verified": "13" },
"relationships": {
"requires": [{ "id": "tokenizer-2", "type": "module" }]
},
"esmodules": ["scripts/index.js"],
"languages": [{ "lang": "en", "name": "English", "path": "lang/en.json" }]
}
2. Register your plugin
In scripts/index.js:
import { MyTool } from "./MyTool.js";
Hooks.once("tokenizer-2.registerPlugins", (registry) => {
registry.register({
id: "my-tool",
moduleId: "my-tokenizer-plugin",
name: "My Tool",
description: "Does something useful",
version: "1.0.0",
author: "Your Name",
requires: "1.0",
tools: [{
id: "my-tool",
icon: "fa-solid fa-wand-magic-sparkles",
tooltip: "My Tool Tooltip",
toolClass: MyTool,
panel: renderMyPanel,
}],
});
});
function renderMyPanel(container, { tool, editor }) {
const p = document.createElement("p");
p.textContent = "My tool is active!";
container.appendChild(p);
}
Plugin Descriptor
The object passed to registry.register():
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique plugin identifier |
moduleId | string | yes | The Foundry module ID (must match module.json) |
name | string | yes | Display name |
requires | string | recommended | Minimum Tokenizer 2 API version, "major.minor" (see Versioning). The plugin is skipped, with an error in the console, when the running API cannot satisfy it |
description | string | no | Short description |
version | string | no | Plugin version |
author | string | no | Author name |
license | object or object[] | no | License info shown in Settings → Licenses tab; a list adds one entry per item (API 1.2) |
license.name | string | yes (if license) | Library/license name |
license.text | string | no | Description text |
license.url | string | no | Link to library/license |
license.copyright | string | no | Copyright notice |
tools | ToolDescriptor[] | no | Array of tools to add to the toolbar |
layerMenu | LayerMenuItem[] | no | Entries for the layer list's right-click menu (API 1.2, see Layer Menu Entries) |
hooks | object | no | Map of hook names to callback functions |
Versioning
The plugin API has a version, "major.minor", readable as registry.apiVersion inside tokenizer-2.registerPlugins and as Tokenizer2.apiVersion afterwards.
The minor version goes up when something is added and the major version when something changes incompatibly.
Declare the version you were written against with requires; a plugin needing "1.0" loads on any 1.x.
The versioned surface is: the plugin descriptor, the tool and panel signatures below, the Editor API, the hooks and their payloads, and the public API object (Tokenizer2).
Everything reached through the raw editor (app, editor.editor) is internal and can change in any release.
Tool Descriptor
Each entry in the tools array:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Unique tool identifier (used as data-tool attribute) |
icon | string | yes | FontAwesome class (e.g. "fa-solid fa-scissors") |
tooltip | string | yes | Tooltip text or localization key (keys containing . are passed through game.i18n.localize()) |
toolClass | class | yes | ES class instantiated when the tool is activated |
panel | function | no | (container: HTMLElement, ctx: { tool, editor, app }) => void - builds the left-panel UI |
consent | object | no | Consent dialog shown before first use |
requiresStaticLayer | boolean | no | Disable the tool while the active layer is a video (for tools that work on still pixels) |
Consent Descriptor
If your tool requires user consent (e.g. for AI/ML features):
| Field | Type | Required | Description |
|---|---|---|---|
moduleId | string | no | Module ID for the setting (defaults to plugin's moduleId) |
settingKey | string | yes | Setting key storing consent state ("" = not asked, "yes", "no") |
title | string | yes | Dialog title (or localization key) |
content | string | yes | Dialog body HTML (or localization key) |
yesLabel | string | yes | Accept button label (or localization key) |
noLabel | string | yes | Decline button label (or localization key) |
You must register the consent setting in a Hooks.once("init", ...) handler under your module's ID:
Hooks.once("init", () => {
game.settings.register("my-tokenizer-plugin", "my-consent", {
scope: "world",
config: false,
type: String,
default: "",
});
});
Tool Class Interface
A new instance of your tool class is created each time the tool is activated (toolbar click, editor.activateTool(id)), and deactivate() is called when the user switches away or the editor closes.
Every method is optional.
export class MyTool {
/**
* @param {HTMLCanvasElement} overlayCanvas Transparent canvas over the editor canvas; receives pointer events
* @param {object} app The raw editor - internal, prefer context.editor
* @param {object} context
* @param {string} context.toolName Your tool id
* @param {EditorContext} context.editor The Editor API (see below)
*/
async activate(overlayCanvas, app, context) {
this.editor = context.editor;
}
/**
* The user switched tools or closed the editor. Release everything (listeners, timers),
* even if storing your work fails: when this throws, the switch or close goes ahead
* without your tool and the error is logged.
*/
deactivate() {}
/** The user selected another layer while your tool is active. */
onActiveLayerChange(newLayerId) {}
/** Whether your tool holds work not yet in the layers: Cancel then asks before the editor closes without it. */
hasPendingWork() { return false; }
// Pointer events on the overlay canvas. Map them to logical canvas
// coordinates with the Editor API:
onPointerDown(e) {
const { x, y } = this.editor.toCanvasPoint(e.clientX, e.clientY);
}
onPointerMove(e) {}
onPointerUp(e) {}
}
Space+drag always pans the view, whichever tool is active; your pointer handlers do not see those events.
Panel Function
The panel function is called on every editor render while your tool is active.
It receives:
container- an empty<div id="tie-plugin-panel">element. Build your UI by appending DOM nodes.ctx.tool- your tool instance (read its state, call its methods)ctx.editor- the Editor APIctx.app- the raw editor (internal)
The container is cleared (innerHTML = "") before each call, so build the full panel every time.
Attach event listeners directly to your elements.
Call editor.refresh() to re-render the panel after your tool's state changes.
function renderMyPanel(container, { tool, editor }) {
const heading = document.createElement("h3");
heading.className = "tie-panel__heading";
heading.textContent = "My Tool";
container.appendChild(heading);
// Add sliders, buttons, etc.
}
The Editor API (EditorContext)
The supported way to work with an open editor.
Tools get it as context.editor, panels as ctx.editor, and the editor hooks as context.
| Member | Description |
|---|---|
actor, token, element, mode | The actor being edited, the scene token (or null), the editor window element, and "token" or "avatar" |
canvasSize | { width, height } of the logical canvas layers are laid out in (512×512 for tokens; avatars can be other sizes) |
layerScale | How many times smaller frames, rings and art are drawn on this token: 2 or 1.3 on an oversized token, otherwise 1 (always 1 for avatars). Scale a new layer by 1 / layerScale to fit it to the token as they are (API 1.2) |
toCanvasPoint(clientX, clientY) | A pointer position (from your tool's pointer events) in canvas coordinates |
viewport | { zoom, offset }. The overlay context arrives in device pixels scaled to logical pixels; apply translate(offset.x, offset.y) then scale(zoom, zoom) to draw in canvas space |
layers | The live layer stack, bottom first. Change it inside edit() (or call pushUndo() first) |
getLayer(id), getActiveLayer(), setActiveLayer(id) | Look up and select layers |
createImageLayer(src, name), addLayerAbove(layer, id), removeLayer(id) | Build and edit the stack. Load a new layer's picture with loadImage(src) before adding it (or changing a layer's src): the canvas and the saved token only draw pictures the editor has loaded |
getFlag(target, scope, key), setFlag(target, scope, key, value) | Your own data on a layer or mask entry, stored at target.flags[scope][key] and saved with the layer. Use your module id as the scope; never add top-level fields to layers |
edit(change, { refresh }?) | Make one undoable change: runs change (may be async), keeps the layers from before it as one undo step and redraws the canvas and panels (refresh: "all", "canvas" or "none"). Return false from change when nothing changed: no step is kept. A change that throws keeps a step only if it had already changed the layers. While an async change is still running, other edits (yours or the user's) join its step, and undo, saves and mode switches wait for it to finish; an edit asked for during a mode switch waits for it and is made on the mode then on screen. Returns what change returned |
pushUndo() | Record the current layers as an undo step, for a change you make yourself over time (a drag). Call before the change |
requestRender() | Redraw the canvas (cheap; several calls in one tick draw once) |
refresh() | Re-render the whole window, including your panel |
loadImage(src) | Load and cache an image; resolves to the image |
invalidateImage(src) | Forget the image or video loaded from src and every cached bitmap derived from it, e.g. after overwriting the file |
preloadMasks(layers) | Load the bitmaps for the masks on layers, so the next render applies them |
getMaskBitmap(mask) | The bitmap a mask entry renders with (alpha, custom, frame-ray, frame-marching), or null if not loaded |
storePNG(canvas, { folder, filename, label }) | Save a canvas as a PNG layers can reference: uploads into today's subfolder of folder, or returns a data: URL when layer persistence is off. Returns null (after notifying the user) on failure |
setOverlay(fn), clearOverlay() | Draw on the tool overlay every frame, until cleared |
activateTool(id, context) | Switch tools as the toolbar does (e.g. back to "move" when your tool cannot run). Resolves true once the tool is active, and false when it was not activated: there is no tool with that id or the current tool could not store its work (a brush stroke whose upload failed), both leaving the current tool on, a later switch took over first, or the editor closed meanwhile |
editor | The raw editor. Internal and unversioned |
Layer Menu Entries
Each entry in the layerMenu array adds an item to the menu that opens when a layer in the layer list is right-clicked:
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Identifier, unique within the plugin |
label | string | yes | Menu text or localization key (keys containing . are passed through game.i18n.localize()) |
icon | string | no | FontAwesome class |
visible | function | no | (layer, editor) => boolean - whether the entry shows for this layer (every layer when omitted) |
onClick | function | yes | (layer, editor) => void | Promise<void> - runs when the entry is picked |
editor is the Editor API.
Clicking a layer row only selects the layer, so a plugin that edits its own layers should offer that here rather than reacting to the selection, for example by opening its tool on the layer:
layerMenu: [{
id: "edit-my-layer",
label: "MY-MODULE.EditLayer",
icon: "fa-solid fa-pen",
visible: (layer, editor) => Boolean(editor.getFlag(layer, "my-module", "data")),
onClick: (layer, editor) => editor.activateTool("my-tool", { layerId: layer.id }),
}],
The context given to activateTool reaches the tool's activate(overlay, app, context).
Lifecycle Hooks
Subscribe with the standard Foundry Hooks.on() API, or through the hooks field in your plugin descriptor.
editor in a payload is the raw editor (internal); context is the Editor API.
| Hook Name | Fired When | Data |
|---|---|---|
tokenizer-2.registerPlugins | Once at startup, before tokenizer-2.ready | registry - call registry.register({...}); registry.apiVersion |
tokenizer-2.registerAnimationPresets | Once at startup | registry - register per-layer animation presets: (layer, time) -> overrides pure functions |
tokenizer-2.registerEncoders | Once at startup | registry - register animated-output encoders (e.g. MP4, GIF). Core's WebM encoder is registered first |
tokenizer-2.registerFrames | Once at startup | frameRegistry - add tabs/categories to the Frame browser (see the Programmatic API) |
tokenizer-2.registerMasks | Once at startup | frameRegistry - add tabs/categories to the Mask browser |
tokenizer-2.ready | Once, after all plugins registered | api - the public API object (Tokenizer2) |
tokenizer-2.editorOpen | Editor finishes its first render | { editor, context, actor, token } |
tokenizer-2.editorClose | Editor begins closing | { editor, context, actor } |
tokenizer-2.preSave | Before a token image the editor saves is exported (also the token of Save Both, from either mode); not for avatar or API saves | { editor, context, actor, layers, isAnimated, format, duration } |
tokenizer-2.postSave | After that token image is saved and its documents updated | { editor, context, actor, path, isAnimated, format, duration } |
tokenizer-2.toolActivated | A tool becomes active (of switches that overlap, only the one whose tool ends up active fires it) | { toolName, tool, context } |
tokenizer-2.toolDeactivated | The editor's starting tool, or one toolActivated named, is switched away from | { toolName } |
tokenizer-2.layerAdded | A layer is added to the stack | { layer } |
tokenizer-2.layerRemoved | A layer is removed from the stack | { layerId } |
tokenizer-2.tokenizeProgress | Each actor of a batch tokenize | { current, total, actor } |
tokenizer-2.customFrameRegistered | A frame is added through registerCustomFrame | { src, label, group, shared } |
Using the hooks field
registry.register({
id: "my-plugin",
moduleId: "my-tokenizer-plugin",
name: "My Plugin",
requires: "1.0",
hooks: {
"tokenizer-2.editorOpen": ({ context, actor }) => {
console.log(`Editor opened for ${actor.name}: ${context.layers.length} layers`);
},
"tokenizer-2.layerAdded": ({ layer }) => {
console.log(`Layer added: ${layer.name}`);
},
},
});
Player Requests
Added in API 1.3.
A player can send something to the GM for approval: the GM reviews it, and on approval it is carried out on the GM's client, which can do what the player may not (upload a picture, change a document they cannot). The Coat of Arms window uses it for players' coats of arms.
A request type is registered by a module, on every client (the sender's client checks the payload before sending):
Hooks.once("tokenizer-2.ready", (api) => {
api.gmRequests?.register("my-module.portrait", {
label: (record, actor) => `a portrait for ${actor.name}`, // shown on the chat card and in the window
sanitize: (payload) => cleanCopy(payload), // a clean copy of untrusted data; throw when it is not usable
describe: (record) => record.payload.title, // optional line of text in the window
preview: async (record) => drawCanvas(record.payload), // optional: a canvas, or a picture's address
apply: async (record, actor) => { /* carry it out, on the GM's client */ },
maxBytes: 16 * 1024, // optional; the largest payload as JSON characters
});
});
send(type, payload, { actor }) writes the request as a flag on the actor, so the sender must own it, and whispers a chat card to the GMs.
That works whether a GM is online or not: the GM reviews it from the card's Review button, or from Tokenizer 2's Player Requests settings menu.
A new request of the same type on the same actor replaces the old one.
On Approve the payload goes through sanitize again, then apply; the request is cleared and the player is whispered the result.
A request whose apply throws stays waiting.
Keep payloads small JSON, with no image data: flags are stored in the world database and sent to every client.
Send the recipe for a picture and draw it again in apply.
The Public API
After tokenizer-2.ready, the API is on window.Tokenizer2 and game.modules.get("tokenizer-2").api (see the Programmatic API for the full list): apiVersion, openEditor, tokenize, exportLayers, pluginRegistry, frameRegistry and more.
Tokenizer Lib
Tokenizer 2 exposes its helper library as Tokenizer2.lib (storage, naming, logger, frames, and more).
It is available from the tokenizer-2.ready hook on; plugin modules load before that, so read it when you use it rather than at import time.
Example: Auto Subject Cutout
This example will be available on GitHub soon!