Skip to content

Action API 0.2 — De Facto Specification

Status: Descriptive. This documents the action-scripting contract as it actually ships today. 0.1 was the retroactive number for the surface as it stood when this was first written down; 0.2 (platform v0.86.0, Summoners) is the first version that has a number in code — ACTION_API_VERSION in packages/shared/src/actions/version.ts, exposed to scripts as api.version. It is not a proposal — it is the current behaviour, written down so the Action API 1.0 proposal has a baseline to evolve from. Where the implementation has quirks or sharp edges, they are documented as-is and flagged ⚠︎.

0.1 → 0.2 → 0.3 → 0.4 is additive; every 0.1 script runs unchanged. What 0.2 added: token.characterId (§3.1), api.version, api.pickSquares, api.importCharacter, api.importParty, api.importRelatedCharacters (§3.10b), and roll(expr, { silent }) (§3.4). 0.3 (v0.100.0) added api.history.commits and api.history.diff (§3.10c), read-only. 0.4 (v0.102.0) added api.campaign.configuration (§3.10d), read-only. 0.7 (v0.115.0) added api.chat.brief (§3.10).

1. What an action is

An action is a button attached to a token (or character) whose behaviour is a string of JavaScript (IAction.code). When the user clicks it, the platform compiles the string into an AsyncFunction and calls it with a fixed set of injected globals.

  • Interface: apps/web/src/interfaces/IAction.ts
  • Runtime: apps/web/src/utils/actionExecutor.ts (ActionExecutor)
  • Token helper surface: apps/web/src/utils/tokenApi.ts (createTokenApi)
  • Editor autocomplete (the closest thing to a published surface today): apps/web/src/components/UI/JsCodeEditor/actionApiCompletions.ts

Versioning (since 0.2): ACTION_API_VERSION in packages/shared/src/actions/version.ts is a SemVer constant with a history block, the same shape as MODULE_API_VERSION in packages/shared/src/types/module.ts, and scripts read it as api.version. It is a fact about the platform, not a negotiation with the action: there is still no version field on IAction and no compatibility shim, because nothing since 0.1 has removed or changed anything. The 1.0 proposal's §2.2–2.3 (per-action apiVersion, the shim) arrive with the first breaking change.

IAction carries codeHistory[] and importedVersion (snapshots of prior code revisions), but those track edits to the script text, not the API contract.

2. Execution model

click → ActionExecutor.executeAction(action, ctx)
  → tag invocation with a UUID (ActionInvocation)
  → build sandbox (createSandbox)
  → compile:  new AsyncFunction(...paramNames, action.code)
  → call:     await fn(...paramValues)
  → if returns string → emit to chat
  → persist self-metadata mutations (diff snapshot)

Two entry points:

Method Purpose
ActionExecutor.executeAction Live run. Messages go to chat; damage/roll effects dispatched.
ActionExecutor.testAction Dry run (the Test panel). sendMessage is captured into an array instead of being sent.

Compilation is positional. The globals are passed as ordered parameters to the AsyncFunction, in this exact order (actionExecutor.ts executeAction):

token, character, roll, sendMessage, rollDetailed, prompt, target, targets, damageTypes, draw, ...campaignGlobalFns

⚠︎ Because the surface is positional and untagged, adding/reordering a global is a breaking change with no detection. This is the main structural weakness 1.0 addresses.

3. The injected globals (the 0.1 surface)

3.1 token — the acting token (read/write proxy)

A Proxy over the impersonated token. All properties are readable; only metadata (and existing metadata keys) are writable. Core fields (id, name, position…) are write-protected — the proxy's set trap warns and no-ops.

Properties: token.name, token.id, token.imageUrl, token.metadata (writable bag of campaign-defined properties), token.owner, token.x, token.y, token.chatColor, token.artworks, token.actions, plus any metadata key directly (e.g. token.strength via the proxy).

Derived (v0.24.0): token.currentTokenArtId, token.currentPortraitArtId — zero-based index of the active artwork.

Derived (v0.86.0): token.characterId — the character behind the token, from the indexed column or, for a token that predates it, from metadata.characterId; null for an unlinked token. Also on target and each entry of targets.

Methods (from createTokenApi):

Method Signature Notes
getProperty (name) => any Alias/case-insensitive metadata read. Resolves aliases, ids, and system-prefixed keys (dnd5e-2014_str ↔ str ↔ Strength).
setProperty (name, value) => void Alias-aware write. Emits a Combat Log property-change event.
getDistanceTo (tokenOrId) => number Euclidean grid distance. Infinity if unresolved.
getLineOfSightTo (tokenOrId) => boolean VBL raycast.
applyDamage (amount, type?) => void See §4. Negative amount = heal.
requestRoll (expr, opts?) => Promise<number | null> See §5.
addAction (name, code) => void Appends a runtime action; persists on next token update.
changeTokenArtwork (index) => void Swap map art to artworks[index]. token only.
changePortraitArtwork (index) => void Swap portrait art. token only.
autoTokenizeArtwork () => Promise<void> AI face-crop the current art. token only.

Plus any token-type campaign functions (see §7), merged onto the proxy (e.g. token.lightDamage('dex')).

3.2 target / targets — the targeted token(s) (read-only)

Present when the action has targeting enabled (IAction.target). target is the single target (or null); targets is the array (contains the single target when max=1; [] when none).

Same Token API methods as token, plus applyDamage/requestRoll, but:

  • Direct property writes are blocked by the proxy (set trap returns false).
  • Artwork mutators are excluded (excludeArtworkMethods).

⚠︎ target.setProperty(...) is not blocked — it mutates the target's in-memory metadata directly (it's a method call, not a property assignment, so it bypasses the set trap). Treat "read-only" as "no direct field writes"; setProperty on a target is an undocumented hole. The sanctioned way to affect a target is applyDamage.

3.3 character — read-only source character (v0.25.0)

Present when the token is linked to a Character (via token.metadata.characterId), else null. Surface (characterApi.ts):

Method Notes
getProperty(name) Reads attributes (computed value) → metadata → small scalar allow-list (name, level, systemId, classes, origins). Internal fields are not exposed.
getCurrentTokenArtworkId() Index into character.artworks used as token art.
getCurrentPortraitArtworkId() Index used as portrait.

Use token for writes; character is strictly read-only.

3.4 roll — dice that pipe to chat

roll(expression: string, optionsOrPipe?: boolean | { label?: string; text?: string; silent?: boolean; pipeToChat?: boolean }): number
  • Returns the numeric total synchronously (deterministic RNG). await is optional and harmless.
  • By default emits a rich [DICE_ROLL:…] message (3D dice + chat row), tinted with the acting token's chatColor.
  • Expressions support standard notation and metadata property names: roll('1d20+strength'), roll('2d6+3'). Ability shorthands are substituted from property definitions.
  • roll(expr, { silent: true }) → silent (no chat), returns the value (v0.86.0). roll(expr, false) and { pipeToChat: false } are the older spellings and still work; silent wins when both are given.
  • roll(expr, { label, text }) → one decorated chat row; {result} in text is replaced by the rendered roll.
  • A diceless expression (e.g. roll('strength')) returns its value without emitting a chat row unless decoration was requested.

3.5 rollDetailed — dice without chat

rollDetailed(expression: string): { total: number; rolls: number[]; expression: string; breakdown: string }

Never emits to chat. Use when you want to format the result yourself.

3.6 sendMessage — write to chat

sendMessage(message: string): void
  • Supports HTML. Plain text is auto-wrapped in a <div>.
  • Dice expressions found in the text are converted to rich dice tokens.
  • [DAMAGE_PENDING:…] tokens are passed through untouched (so applyDamage output isn't mangled).
  • Every message is prefixed with an invisible invocation marker (§6).

3.7 prompt — ask the invoking user for input

prompt(message: string, jsType?: Number | Boolean | String): Promise<any>

Backed by ActionPromptModal. Two modes, auto-detected from message:

  1. Simple — message is plain text. Shows one input, validated/coerced to jsType. Number fields accept math expressions ("32-14"). Returns the value, or null on cancel.
  2. HTML form — message contains <input> / <select> / <textarea>. The HTML is sanitized (tag/attr allow-list, event handlers stripped) and rendered as a form. On submit, resolves to an object keyed by each field's name, with type coercion (numbers, checkboxes → boolean, <select multiple> → array). Returns null on cancel.

⚠︎ The HTML-form mode is powerful but every author hand-writes (and hand-escapes) raw HTML strings. There is no typed/validated descriptor form. This is what spawnDialogue() replaces in 1.0.

3.8 damageTypes

Array<{ id: string; name: string; description?: string }> — the campaign/system damage types, for building damage-type pickers.

3.9 draw — map drawing API (when available)

Present when drawApiContext is supplied (drawAction.ts). Lets an action draw shapes / VBL walls at the token's position (e.g. spell templates). Omitted (undefined) when the host doesn't provide drawing context.

3.10 api — the namespaced surface (v0.73.0)

api.chat.stream(value: unknown, opts?: { variable?: string }): void

The first addition to the authored surface since this spec was written as de facto, and the first member of the api.* namespace the 1.0 proposal reserves for new surface — one namespace rather than another positional global per capability.

stream writes into the invocation's chat DOCUMENT (§6a). Its variable is a named slot: an alias for a step key, not a second addressing scheme. A new name appends a step and binds the name; a known name updates that step's value in place — same key, same position — so a script can show progress and then replace it:

api.chat.stream('rolling damage…', { variable: 'dmg' });
const total = rollSilent('8d6');
api.chat.stream(`<b>${total}</b> damage`, { variable: 'dmg' });

Value handling: a string is trusted HTML exactly like sendMessage (same author, same §8 trust boundary); numbers and booleans render as text; objects render as escaped JSON in a <code> block; null/undefined render as an em-dash placeholder. A named slot never changes kind — a text slot cannot become a damage card.

Where no document is live (testAction, a surface not yet wired for documents), stream degrades to an ordinary sendMessage and the variable is ignored. Scripts never need to care which path they ran on.

api.chat.brief(value: unknown): void      // 0.7, platform v0.115.0

The brief is what the invocation's message shows in the feed. An invocation with more than one step renders as a thread: one bubble showing the brief, with the steps — the rolls, each target's save, the damage — folded beneath it, opened by a toggle. brief may be called any number of times as the action runs; the last write wins, so the feed reads correctly without anyone opening the thread:

api.chat.brief('I am casting Fireball. DC is being rolled…');
const dc = rollSilent('1d20+8');
api.chat.brief(`I cast Fireball at DC ${dc}. Saves and damage are being rolled…`);
// …saves, damage…
api.chat.brief(`I cast Fireball at DC ${dc}, rolled ${total} damage, and hit ${targets.length} targets.`);

Values are handled as stream's are. It is stored as the document's summary.headline; without one, the message shows the action's name and a summary derived from the steps. A brief written before any step is carried by the document's first post; a brief with no step ever written posts nothing. Where no document is live (testAction), it does nothing.

3.10a Who must use it (v0.78.0)

Every GENERATED action streams. Hand-written actions may.

Generated actions are the ones that produce chat traffic at scale: a power compiled from a wiki document, a spell from an SRD spec, an attack projected onto a token. One of those fired at a dozen targets is where "one message per roll" stops being tolerable, and the author is a compiler that can be taught once rather than a person who has to remember.

So the rule binds the compilers — derivePowerActions (System 8 powers), specToActions (5e spells), projectToken / characterToToken (attacks), and monster generation — not the person writing a one-off action in the editor. A hand-written action that calls sendMessage still becomes a step in the document; it simply does not get named slots unless it asks for them.

Emit the preamble from actionStreamPreamble() (actionStreamSnippet.ts) rather than hand-writing the hasDocs guard — three generators had written the same six lines before it existed. It declares hasDocs and a stream(variable, html) that is a no-op without a live session, so call sites need no further guarding.

Two rules that are easy to get wrong:

  • Suppress the legacy return when documents are live (suppressLegacyReturn()). With a session the document is the message; returning a summary as well posts the same information twice, once as a step and once as a sibling message — the exact duplication documents exist to remove.
  • A slot that opens must close. stream('saves', 'awaiting 12…') with no later write leaves the table watching a progress line forever. Every cancel and early-return path needs its own final write, which is why the implement attack streams cancelled — no damage rolled rather than simply returning.

3.10b The 0.2 members (v0.86.0, Summoners)

api.version: string                                                   // '0.3.0' since v0.100.0
api.pickSquares(count: number, label?: string): Promise<GridPoint[] | null>
api.importCharacter(idOrSlug: string): Promise<IToken | null>
api.importParty(idOrName: string): Promise<IToken[] | null>
api.importRelatedCharacters(characterId: string, types?: string[]): Promise<IToken[] | null>
  • pickSquares enters the targeting store's square-pick mode (targetingStore.startSquarePick): no origin token, no action, the map's click intercept takes the hovered grid square, and the promise resolves with count squares in click order. Every exit — Escape, right-click, the banner's Cancel, another targeting session starting over it — resolves null exactly once; the promise never rejects on cancel.
  • The three import functions are one procedure (apps/web/src/utils/summonImport.ts) with three ways of choosing who: resolve the characters, pickSquares(n, names), then one token per character built by characterImportItem → characterTokenPayload — the same two functions the Import Character modal uses, so a summoned token is the same object an imported one is. importParty resolves by id, then by name (case-insensitive) among the parties the caller can see; an ambiguous name throws. importRelatedCharacters reads GET /api/characters/:id/relationships and selects by type via shared relatedCharacterIds (every related character when types is empty).
  • All five need a map. They are gated on ActionExecutionContext.mapContext — { mapId, campaignId, fetchJson, createToken }, which only the impersonated-token bar and the map nav provide. Without it they reject with a plain sentence before any targeting mode opens; in testAction on a non-map surface that is the whole behaviour.
  • Errors are sentences. "No party called "Twins"", "No characters related as "familiar"", "importRelatedCharacters needs a character id — this token has no character". The server's own error field is passed through for access failures.

3.10c The 0.3 member: api.history (v0.100.0, Time Machine)

api.history.commits(ref?: HistoryRef, options?: { limit?: number }): Promise<HistoryCommit[]>
api.history.diff(b: string, a?: string, ref?: HistoryRef): Promise<{ summary: string; changes: Change[] }>
// HistoryRef = { type: 'campaign' | 'map' | 'character'; id: string }

Read-only access to the commit store (the commit path): the two reads of /api/history over the surface's fetchJson, defaulting to the map the action runs on. The server's gatekeeper answers for what the user may read (TM-D4), so a ref naming an entity they cannot read throws with the server's sentence. Restore is deliberately absent — it is an edit the user makes, with their name on it. Needs a map, like the 0.2 members. Reference: api.history.commits(), api.history.diff().

3.10d The 0.4 member: api.campaign (v0.102.0, Campaign configuration)

api.campaign.configuration(): Promise<Record<string, unknown>>
api.campaign.configuration(key: string): Promise<unknown>

Read-only access to the campaign's configuration (the settings and the Treasury model): GET /api/campaigns/:id/configuration's resolved — every key the system module's campaignConfigurationSchema and the platform's groups declare, at its edited value or its default (CC-D1) — over the surface's fetchJson, fetched once per run, for the campaign the map belongs to. The settings are the GM's; an action reads them. Needs a map in a campaign. Reference: api.campaign.configuration().

4. applyDamage — the effect model

applyDamage does not mutate HP directly. It builds a damage calculation and emits a structured [DAMAGE_PENDING:{…}] token to chat. ChatLog renders it as an approval card the GM (or token owner) can approve / deny / modify.

token.applyDamage(amount: number, damageType?: string): void
target.applyDamage(roll('2d6'), 'fire');
  • Damage path (amount > 0): applies the target's metadata.damageModifiers (resistance/vulnerability/immunity) via calculateDamage; computes newHp = max(0, currentHp - finalAmount); emits a pending card with the full breakdown.
  • Heal path (amount < 0): skips modifiers, sets isHealing: true, computes newHp = min(currentHp + |amount|, hp_max).
  • Auto-approve path: when the host sets _autoApproveDamage + supplies _persistDamageHp (only the GM heal/harm hotkey flow does this), the card is emitted as approved and HP is persisted in-band. Arbitrary action scripts cannot opt into this; they always go through GM approval.

Payload shape: IDamagePendingData (@purple-cat/shared). HP application and Combat Log emission happen on approval in ChatLog (or immediately on the auto-approve path).

Key property for 1.0: because damage is data emitted to chat and applied only on approval, it is already most of the way to being replay-safe (see the 1.0 rewind/replay design).

5. requestRoll — ask another client to roll

target.requestRoll(expression: string, options?: { label?; text?; pipeToChat? }): Promise<number | null>

(v0.29.0; options v0.30.0.) Dispatches a roll request over the socket bridge (rollRequestBridge.ts → rollRequestRelay.ts) to the owner of the token it's called on. The owner sees a RollRequestModal prompting them to roll or deny.

  • Resolves with the rolled total once the owner rolls, or null if they deny / never respond.
  • The roll executes on the owner's client (3D dice + chat) against the target's stats.
  • The action's invocation marker rides along so the owner's roll message is grouped into the requesting action's chat frame.

Typical use: a saving throw — const save = await target.requestRoll('1d20+dex', { label: 'Dex save' }); if (save !== null && save < 15) target.applyDamage(...).

6. Invocation grouping (the frame)

Each executeAction run mints an ActionInvocation { id, actionName, tokenName } (actionInvocationMarker.ts). Every chat message the run emits is prefixed with an invisible <!--PCG_ACTION:…--> HTML-comment marker carrying that id. ChatLog parses the marker and renders consecutive same-id messages inside one bordered frame.

This id is per-run and ephemeral — there is no persisted record of what the action did (which rolls, which prompt answers). That record is what 1.0's rewind/replay needs to add.

6a. Invocation documents (v0.73.0) — one evolving message per run

On surfaces wired with actionDocDeps (24-chat-log-compactness), an invocation no longer emits N tagged messages: it posts one [ACTION_DOC:{…}] message on its first emission and mutates it while the script runs. Every sendMessage becomes a step in the document — bare [DICE_ROLL:{…}] tokens and whole-message [DAMAGE_PENDING:{…}] cards become typed steps; dice tokens embedded in prose are split out as typed roll steps around the text — answered prompts are recorded, a thrown error becomes an error step, and completion closes the doc as complete, awaiting (damage approvals outstanding), or error.

Nothing about the authored surface changes: scripts still call sendMessage, roll, applyDamage exactly as above. The §6 marker frame remains as the legacy reader for historical messages. The previous paragraph's caveat is also half-retired: the document IS a persisted record of what the action did — the 1.0 proposal's journal, in its user-visible form.

7. System & campaign extension (campaign functions)

The platform has exactly one extension mechanism for action behaviour today: campaign functions.

  • Stored in the campaign_functions table (dbSchema.ts) with either a campaign_id (user-authored, per campaign) or a system_id + campaign_id = null (system-provided).
  • Two kinds (functionType):
    • global — injected as a top-level parameter (callable bare: attackRoll('str')).
    • token — merged onto the token proxy (token.lightDamage('dex')).
  • Compiled with the signature (token, roll, sendMessage, rollDetailed, prompt, ...userArgs) and bound to the current sandbox.
  • System functions are seeded from system fixtures (seedSystems.ts → campaignFunctions), discovered from bundled fixtures or modules (fixtures/index.ts).

System 8 example (the canonical case)

System 8 ships these as token campaign functions (a module fixture — campaign-functions.json):

Function Returns
token.lightDamage(ability) "{rank}d4{±abilityBonus}"
token.mediumDamage(ability) "{rank}d6{±abilityBonus}"
token.heavyDamage(ability) "{rank}d8{±abilityBonus}"
token.attackRoll(ability) "1d20+{abilityBonus+profBonus}"

These are damage/attack-expression calculators. They are correctly system-scoped fixtures — this is the pattern 1.0 generalizes.

⚠︎ The light/medium/heavy tier prompt is NOT one of these functions and is NOT in the Action API. It is hand-generated inline by the platform's character importer (characterToToken.ts createAttackAction, the isImplement branch), which bakes a raw prompt('<select name="tier">…') into each Spellcasting Implement action's code. See the report in the 1.0 proposal §0 for why this should move into the System 8 module.

8. Sandbox & safety

validateAction (actionExecutor.ts) rejects code matching dangerous patterns before save:

eval(   Function(   setTimeout/setInterval   XMLHttpRequest/fetch(
document./window.   global./process.   require(   import   __proto__/prototype

Consequences (and a useful guarantee for 1.0): actions have no I/O, no timers, no DOM, no network, no clock. All nondeterminism must flow through the injected API (roll, requestRoll, prompt). The token proxy additionally write-protects core fields. This sandbox is already close to what deterministic replay requires.

9. Return-value contract

Return Behaviour
string Emitted to chat (dice expressions processed).
undefined / null Nothing emitted.
anything else Console error + an orange "returned a non-string value" warning in chat.

10. Known gaps (carried into the 1.0 proposal)

  1. Half the versioning. ACTION_API_VERSION and api.version exist since 0.2; per-action apiVersion and compatibility checking do not, and are not needed until something breaks.
  2. Positional, untagged surface. Adding/reordering a global is a silent breaking change.
  3. Raw-HTML prompts. No typed/declarative dialogue; authors hand-write and hand-escape HTML.
  4. No persisted execution record. Invocation ids are ephemeral; re-roll only patches a single dice token and never recomputes downstream effects.
  5. System semantics leak into the platform. The System 8 damage-tier prompt lives in characterToToken.ts instead of the System 8 module.
  6. Thin combat vocabulary. No conditions/status, resources, opposed rolls, reactions, or area collection — every author re-implements them ad hoc.
  7. target write hole. target.setProperty bypasses read-only protection.