Skip to content

Module API 1.6.0 — De Facto Specification

Status: Descriptive. This documents the module-facing contract as it actually ships today (MODULE_API_VERSION = '1.7.0'). Unlike the Action API, the Module API is formally versioned in code. The body below was written from production source on branch version/0.40.0; §1.6.0 was added on version/0.45.0. Where the implementation drifts from prior docs or has sharp edges, it is flagged ⚠︎.

1.7.0 shipped (v0.45.0, task P3a): CS-6 projectToken must be callable on the server. Additive — the audit found no signature change needed, so every 1.6.0 module keeps working; what it asks for is a server-reachable registration. See the CS-6 note in character-sheet-api-0.1.md.

⚠︎ This file is still named 1.6 and documents the 1.6.0 surface. Rename it when 1.7.0 gains a body beyond the CS-6 obligation.

Companion design doc (philosophy, hosting model, deployment tiers): modules.md. This file is the contract/surface reference; modules.md is the design narrative.

1. What the Module API is

The platform follows an Open Core model. A module is an npm package that declares a purple-cat-module field in its package.json and extends the platform through two complementary directions:

  1. Contribute → the platform (declarative registration): a module registers UI/system extensions with a singleton ModuleRegistry (packages/shared/src/services/moduleRegistry.ts). The platform renders/consumes whatever is registered, with no module-specific code.
  2. Contribute → the backend (server routers): a module exports an Express router that the platform auto-mounts at /api/modules/{moduleId}.

The reverse direction — consume ← the platform (imperative calls a module makes into platform services) — has a proposed home (the PlatformAPI facade, §7) but is not built; today modules consume the platform via authFetch + the ordinary REST API.

Modules are build-time dependencies, delivered to the client via code-split dynamic imports. There is no runtime plugin loader and no arbitrary code execution — adding/updating a module requires a rebuild/redeploy.

Real modules validating this surface: genai (artwork source + extender + settings panel), system8 (system module), scenes, vtt-assets.

2. Versioning & compatibility

  • MODULE_API_VERSION = '1.7.0' (module.ts). SemVer, and independent of the product release version — it bumps only when the module-facing contract changes. A per-bump changelog lives in the same file.
    • 1.6.0 (Character Sheet Overhaul, Phase 0): additive throughout — every 1.5.0 module keeps working unchanged.
      • CS-1.6 SystemModule.formatAttribute(attribute, { surface, format }) — pure, synchronous display formatting. Returns null to fall through, so a module formats only what it cares about. Tier 2 of resolveAttributeFormat: campaign-scoped override → module → String(value). Stock formatters move from seeded renderFunction strings into module code, so updating one no longer needs a fixture version bump plus a destructive re-seed.
      • SystemFixture.attributeGroups — ordered character-sheet sections ("Ability Scores", "Combat"). Property definitions reference one via attributeGroup and gain a display-only abbreviation, deliberately distinct from script-facing aliases (1d20+str resolves through aliases, never abbreviation).
      • IPropType three-axis model — valueType (number/string/boolean/enum/ dice-expression), shapes (scalar/composed/range/faceted, combinable), and presentation (formats — a named map superseding the single renderFunction — plus unit, signed, options). "Ability Score" is no longer a primitive: it is number + composed + a d20-modifier format.
      • IPropType.rollValueFunction — what an attribute contributes when substituted into a dice expression. Replaces the platform's hardcoded /(^|_)ability-score$/ match, which is now deleted. ⚠︎ A module that does not declare it substitutes raw values, so ability rolls regress until the module is published — publish before deploying the platform.
      • IPropertyDefinition.facets — named alternate totals under one heading, in two modes: includeComponents (a derived view — 3.x touch AC) and ownComponents (an independent channel — fly speed, death saves).
      • CS-1.2 consolidation — NumericAttributeSchema.propType references an IPropType id. NumericAttributeFormat is @deprecated but still honoured, making IPropType the single attribute-type registry. It was a closed enum a module could not extend without a platform release.
    • 1.5.0 (System-Platform Separation): the Character Sheet / Monster / Content API surfaces on SystemModule (all optional — see system-api-0.1.md §3); the CharacterSheetSectionExtension (CS-7) registry point; and ModuleServerExport.systemFixtures (plural), .aiParseSchema, .allowedHosts. Fully additive — all pre-1.5.0 modules stay compatible.
  • Enforced at load. During initializeModules() each module's declared engine range is tested with a hand-rolled SemVer matcher (semverSatisfies(MODULE_API_VERSION, engineRange), modules/index.ts:304-326, matcher at modules/utils.ts:54-125). Outcome per module is surfaced as compatibility: 'compatible' | 'incompatible' | 'no-requirement'.
    • Satisfied → load normally.
    • Incompatible → warn and skip (never crash), but still reported in moduleInfos.
    • engine omitted → treated as compatible ('no-requirement').
  • Served to the client: GET /api/modules returns { moduleApiVersion, modules } (index.ts:846-851).
  • Guard test pins the constant: libraryModuleApi.test.ts:143-148.
  • Real-world engine usage: only system8 declares one ("engine": "^1.1.0"); genai/scenes/vtt-assets omit it. ⚠︎ System 8 now implements 1.5.0 members but keeps ^1.1.0 — those members are optional, so it stays loadable on older platforms (which simply never call them).

Deprecation pipeline (infra-ready, currently unused ⚠︎): a three-phase cycle (introduce → deprecate w/ console.warn → remove at major) is documented in modules.md §4.3, and the helper exists (moduleDeprecation.ts:32 moduleDeprecated, :72 withDeprecation), but there are zero production call sites — no API is actually mid-deprecation.

3. Module descriptor (purple-cat-module)

Declared in the module's package.json; replaces a separate manifest. Types: ModuleDescriptor (modules/types.ts:17).

Field Required Description
type yes Category: "assets", "integration", …
client no Relative path to client ES module exporting register()
server no Relative path to server ES module exporting ModuleServerExport
assetPaths no Asset modules: category → relative path in dist/
buildCommand no Build override (default npm run build)
engine no SemVer range of the Module API this module targets (see §2)

moduleId is derived from the package name by stripping any @scope/ (deriveModuleId, modules/utils.ts), e.g. @purple-cat-games/system8 → system8.

⚠︎ Scope inconsistency: shipped modules use @purple-cat-games/…, but doc-comments in module.ts/modules.md use @purple-cat/vtt-genai. Harmless (the scope is stripped) but the canonical scope is @purple-cat-games.

4. Loading model

4.1 Server (two-stage)

Config resolution (modules/config.ts:66-116): MODULES_CONFIG env → modules.local.json → modules.hosted.json (first non-empty wins).

  • Stage 1 — register (fast, ms/module): initializeModules() (modules/index.ts:272) resolves each package (resolveModule, 3 strategies incl. ESM walk-up, index.ts:87-170), validates engine, dynamically imports the server entry (loadServerEntry, index.ts:233-263), and mounts routers at /api/modules/{id} (index.ts:826-839).
  • Stage 2 — initialize (lazy): initializeModule() builds if dist/ is missing, uploads assets on version change, and calls initialize(context); kicked off in the background via setImmediate (index.ts:438-442). This keeps hundreds of backend modules cheap to register on a shared host.

4.2 Client (build-time codegen + code splitting)

The Vite plugin (apps/web/vite-plugin-modules.ts) reads the same JSON configs at build time and emits a virtual module (virtual:purple-cat-modules) exporting moduleLoaders (moduleId → dynamic import()). At runtime loadModules() (apps/web/src/moduleInit.ts:28) calls each enabled chunk's register() before React mounts (no flash of missing UI). useModuleLoader (apps/web/src/hooks/useModuleLoader.ts:91-113) intersects user prefs with build-available modules and calls moduleRegistry.setActiveModules(...) so registry getters gate extensions by the server's active list. The plugin also enforces peer-dep hygiene (vite-plugin-modules.ts:304-367).

4.3 Hosted vs self-hosted

  • Hosted (multi-tenant): all first-party backend modules load for everyone (safe because the backend API is additive-only); frontend modules load per-user via code splitting. Custom/third-party backend modules are not supported on hosted servers (deliberate boundary). Install scripts: scripts/install-hosted-modules.sh (server runtime) and …-frontend.sh (Vite build resolution), driven by modules.hosted.json.
  • Self-hosted: full control via modules.local.json (uncommitted).

5. Extension points (contribute → platform)

Every point is registered on moduleRegistry and consumed by a specific platform site. Getters gate on the active-module list (§4.2).

Extension point Interface (module.ts) Register method (moduleRegistry.ts) Consumed by (file:line)
Artwork source ArtworkSourceExtension (:56) registerArtworkSource (:48) TokenArtworkEditor.tsx:671,859; genai probe WebGLMap.tsx:4182
Artwork extender / outpaint ArtworkExtenderExtension (:133) registerArtworkExtender (:67) SourceImageEditorDrawer.tsx:187,307; BackgroundTokenBar.tsx:415,505
Settings panel SettingsPanelExtension (:162) registerSettingsPanel (:86) SettingsPage.tsx:122
Dice roller (replaces built-in 3D dice) DiceRollerExtension (:264) registerDiceRoller (:169); active = getActiveDiceRoller (:190) diceExtensionAdapter.ts:35; DiceExtensionBridge.tsx:60-100
System module SystemModuleExtension (:307) registerSystemModule (:106) system loader systems/index.ts:129,156
System fixture (DB seeding) SystemFixtureExtension (:324) registerSystemFixture (:126); also auto-registered from serverExport.systemFixture (index.ts:831-836) fixtures/index.ts:30,39; seedAllSystems()
System service (opaque per-system object, e.g. templates) (generic) registerSystemService (:146); getSystemService<T>() (:151) TokenSheet.tsx:30
Server API router ModuleServerExport.router (:366) (exported from server entry) auto-mounted /api/modules/{id} index.ts:826-830
Server initialize(ctx) ModuleInitializeContext (:343) (exported from server entry) called lazily with {moduleId, dataDir} modules/index.ts:418-423
Static assets assetPaths in descriptor (descriptor) tracked/uploaded in Stage 2 modules/index.ts:348-353,404-416
Character sheet section (CS-7, 1.5.0) CharacterSheetSectionExtension (module.ts) registerCharacterSheetSection / getCharacterSheetSections (moduleRegistry.ts) Character Sheet / Play Mode (resolved dynamically) — character-sheet-api-0.1.md §CS-7
Character sheet renderer (NS.3, 1.39.0; the seam's corrections 1.40.0: SheetApi.rest / .equipment / .treasury.claim, the equipment prop, openPicker('attack', attackId), CharacterSheetSchema.metadataFields) CharacterSheetRendererExtension (module.ts) registerCharacterSheetRenderer / getCharacterSheetRenderer(systemId) (moduleRegistry.ts; the registration is validated) SheetHost.tsx (rendererFor) — character-sheet-renderer.md
Buffs (B.9, 1.42.0: the keys a buff may target and the token field each rewrites, how the system stacks modifiers, its buff categories; and the modifiers in play handed to the module so every derived number resolves through them) SystemModule.modifierTargets(): ModifierTarget[], .buffPolicy: Partial<BuffPolicy>, .buffCategories; a trailing modifiers?: ModifierSet on getAttributes / getTokenAttributes / getDerivedStats / projectToken, and SheetRendererProps.modifiers (systems/types.ts, buffs/modifierSet.ts) declared on the system module; none = the generic attribute.<slug> vocabulary and the stack-everything policy collectModifiers → makeModifierSet (fan-out, sheet host, token overlay) — Buffs
Trait buffs (1.43.0, v0.119.0: the buffs the system's own rules give a character's powers and feats, held while the trait is — System 8's Large is Strength +8) SystemModule.traitBuffs(character): TraitBuffs[] (systems/types.ts, buffs/collect.ts); evaluateBuffExpression (buffs/expression.ts) for an expression value declared on the system module; none = a trait contributes only the buffs stored on it collectModifiers({ traitBuffs }) via traitBuffsOf(module, character) (fan-out, sheet host)
Mini-Me's action menus (ST.10, 1.41.0: which menus the impersonated character's overlay on the map offers — Ability Scores, Saves, Attacks, Powers, Spells, Skills, or every action — each filled by the platform from the token's actions) SystemModule.actionMenus: ActionMenuSpec[] (systems/types.ts) declared on the system module; none = one menu of every action MiniMeMenus.tsx (miniMeMenus)
AI parse schema (R3.3, 1.5.0) ModuleServerExport.aiParseSchema registerAiParseSchema (auto from server entry at boot) AI import routes/ai.ts — content-api-0.1.md §4
External-host allowlist (R3.2, 1.5.0) ModuleServerExport.allowedHosts registerAllowedHosts / getAllowedHosts (auto from server entry at boot) proxy systemDataProxy.ts + image SSRF tokens.ts
Sidebar tab (1.44.0, procedural maps M1: a module's own tab in the play bar's strip, beside the built-ins, by the v0.113.0 tab rules) SidebarTabExtension, SidebarTabContext, SidebarTabProps registerSidebarTab / getSidebarTabs(side?) / getSidebarTab(id) PlayBar.tsx and SidebarTabList.tsx via moduleTabs.ts; drawn by contextContent.tsx (moduleTab) — §5.2
Map layer renderer (1.44.0: an r3f component drawn at the map's ground or overlay slot) MapLayerRendererExtension, MapLayerRendererProps, GeneratedMapRendererProps registerMapLayerRenderer / getMapLayerRenderers(slot?) MapLayerRenderers.tsx, mounted by WebGLMap.tsx — §5.2
The platform object (1.44.0: how module code reaches PlatformClientAPI) PlatformClientAPI, PlatformMapsAPI (platformApi.ts) getPlatform() (the host calls setPlatform before any module registers) apps/web/src/platform/platformClient.ts — §5.2, §7

⚠︎ registerSystemModule / registerSystemService / registerSystemFixture do not throw on duplicate id (plain .set()), unlike the artwork/settings/dice registrars which guard against duplicates — last registration silently wins.

5.1 Buffs (1.42.0)

The platform collects the modifiers in play for a character (its equipped items, enabled feats and powers, conditions) and for a token (plus the TimedEffects running on it), and hands them to the module as a ModifierSet — the trailing argument of getAttributes, getTokenAttributes, getDerivedStats and projectToken, and SheetRendererProps.modifiers. The module derives every number through it, inputs before outputs:

const str = modifiers.total('ability.str', storedStr);          // abilities first…
const toHit = modifiers.total('attack.melee', mod(str) + prof); // …then what derives from them
const ac = modifiers.resolve('ac', base);                      // { total, parts } — breakdownOf(ac) for the sheet

A module declares what may be targeted (modifierTargets(): keys, labels, groups, and the flat token field each rewrites on an unlinked token), how it stacks (buffPolicy: sameSource stack | mostPotent, groups ignore | highest | highestBonusWorstPenalty, percentOf), and its buffCategories. A module that declares nothing gets the generic attribute.<slug> keys and the stack-everything policy; one that ignores the argument derives exactly as before. Never write a resolved number back into a stored attribute — a buff saved as a base applies twice. dnd5e 0.21.0 is the reference implementation (core/buffs.ts, core/buffedCharacter.ts).

Trait buffs (1.43.0). A system whose own rules give a trait a buff — System 8's Large, held rather than switched on — returns them from traitBuffs(character), pure and synchronous, from what the module bundles. The platform collects them unconditionally, labelled by the trait, with everything else; a buff a user authors on a power still needs the power enabled. evaluateBuffExpression(expr, scope) is the arithmetic for an expression value ('(2 * @rank)'); the system builds the scope. System 8 0.20.0 is the reference (traitBuffs.ts, buffedCharacter.ts, and the generated generated/traitBuffs.ts). See Buffs for the rules as players see them.

5.2 Map-level extensions (1.44.0)

Three seams a module needs to work on a map (procedural maps M1; the map-generator module is the first user). A module's register() takes no arguments, so everything goes through moduleRegistry:

import { moduleRegistry } from '@purple-cat/shared';
import { IconTrees } from '@tabler/icons-react';

export function register() {
  moduleRegistry.registerSidebarTab({
    id: 'map-generator:generate', moduleId: 'map-generator', side: 'left',
    label: 'Generate', description: 'Generate this map', icon: IconTrees,
    component: GenerateTab,                       // ComponentType<SidebarTabProps>
    visible: (ctx) => ctx.canEditMap && !ctx.isMobile,
  });
  moduleRegistry.registerMapLayerRenderer({
    id: 'map-generator:preview', moduleId: 'map-generator', slot: 'ground',
    component: Preview,                           // ComponentType<MapLayerRendererProps>
    visible: (ctx) => ctx.canEditMap,
  });
}

// anywhere in module code, after registration:
const platform = moduleRegistry.getPlatform();
await platform.maps.setModuleState(mapId, 'map-generator', recipe, version);
  • Sidebar tabs. A tab sits in its side's strip after the built-in tabs, ordered by order (default 100), and behaves like them: one tab open per side, a click on the active tab closes it, a popped-out tab's click closes its window. Its content is drawn under the tab's label as a header, given SidebarTabProps (campaignId, mapId, close()). visible(ctx) decides who sees it; SidebarTabContext.isGM is the campaign owner not previewing as a player, and canEditMap is the server's edit on the map (the verb mayEditMap checks), both false while the GM views as a player. A tab is opened by id with openSidebarTab(id) (components/UI/PlayBar/moduleTabs.ts), as Generate Map does. The ids are namespaced: <module>:<tab>.
  • Map layer renderers. An r3f component inside the map's canvas, on every map, for every viewer visible allows. ground draws above the foundation and the committed generated map and below every map layer; overlay above every map layer and below lighting and fog. Its props (MapLayerRendererProps) give the map, gridSize, the slot's zBase (draw within zBase … zBase + 0.09), the squares in view (plus a margin) from the camera, isGM, and GeneratedMap: the platform's own generated-map renderer, so a preview is drawn exactly as its committed map will be, with no three.js in the module.
  • The platform object. moduleRegistry.getPlatform() returns the platform's PlatformClientAPI: authFetch (the user's token, the API's origin), uploadImageFromUrl / uploadImageFromFile, library, and maps — the map write facade (bulkCreate, removeGeneration), per-map module state (get/set/deleteModuleState, GM only, never broadcast; setModuleState with ifVersion rejects a stale write with a PlatformHttpError of status 409), and committed generated maps (getGenerated, commitGenerated, removeGenerated). It throws if called before the host set it, which in the app is before any module registers.

6. Type surface (packages/shared/src/types/module.ts)

Type Purpose Line
ArtworkSourceProps / ArtworkSourceExtension Props + registration for an artwork source :22 / :56
ArtworkExtendDirection / ArtworkExtenderProps / ArtworkExtenderExtension Outpaint direction, props, registration :87 / :104 / :133
SettingsPanelExtension Settings-page section registration :162
DiceRollContext / DiceRollDieResult / DiceRollResolution / DiceRollerExtension Dice roller context, results, registration :192 / :229 / :241 / :264
SystemModuleExtension TTRPG system registration (lazy loadModule()) :307
CharacterSheetSectionProps / CharacterSheetSectionExtension (1.5.0, CS-7) Props + registration for a custom sheet section —
SystemFixtureExtension DB-seeding fixture registration :324
ModuleInitializeContext initialize() arg (moduleId + persistent dataDir) :343
ModuleServerExport Server entry: router?, initialize?, systemFixture?, systemFixtures? (plural), aiParseSchema?, allowedHosts? (1.5.0) :366
AiParseSchema (1.5.0) AI import target attributes + guidelines (services/aiParseSchema.ts) —
resolveSystemFixtures() (1.5.0) Normalize singular/plural fixtures into a list —
ModuleInfo / ModulesResponse Per-module metadata + GET /api/modules response :388 / :441
MODULE_API_VERSION Canonical version constant ('1.5.0') :436

Supporting types: ModuleDescriptor / ResolvedModule / ModuleManifest (modules/types.ts:17,59,71); SystemFixture (types/systemFixture.ts); SystemModule (the System API — see system-api-0.1.md).

7. The PlatformAPI facade

Since 1.44.0 the client half is built: apps/web/src/platform/platformClient.ts implements PlatformClientAPI (including library over its four routes and maps), and module code reaches it with moduleRegistry.getPlatform() (§5.2). PlatformServerAPI is still a contract only. What follows is the history from before 1.44.0.

Before 1.44.0 (proposed — NOT built) ⚠︎

The "consume ← platform" direction is declared but unimplemented: PlatformClientAPI (platformApi.ts:27), LibraryAPI (:72), PlatformServerAPI (:162). The header explicitly says "implementation will be provided in a future phase" (platformApi.ts:8-11); no object satisfies these interfaces anywhere (grep finds only type references + the contract guard test). The underlying library REST routes do exist (routes/library.ts), so the facade is an un-wrapped layer today. This is where version compatibility / deprecation tracking is intended to apply.

8. Known gaps

  1. PlatformServerAPI unbuilt (§7). The client half is built since 1.44.0 (moduleRegistry.getPlatform()); server code still uses the platform's services directly.
  2. Docs undercount extension points. modules.md lists 2; MODULE_AUTHOR_GUIDE.md lists 3 (adds DiceRoller). Code ships 6+ registry points. Undocumented in both: ArtworkExtender, SystemModule, SystemFixture, registerSystemService. (This spec is the corrected inventory.)
  3. ModuleServerExport.systemFixture is a live contribution path (consumed index.ts:831) but absent from modules.md's interface snippet.
  4. Deprecation cycle dormant (§2): helper exists + tested, zero production uses.
  5. Duplicate-id silent-win for system/systemService/systemFixture registrars (§5).
  6. Module routers are public by platform default — and, since v0.95.0 (MODULE_API_VERSION 1.37.0), every request they see carries req.actor and req.effectivePermissions when the caller sent a token (ModuleRequestContext, mounted by middleware/moduleRequestContext.ts). A module that needs an actor checks for one and answers 401 itself; the resolver it gets is the platform's own. See Permissions, read routes and the Gatekeeper.
  7. Scope-name inconsistency across docs vs. shipped modules (§3).