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_VERSIONinpackages/shared/src/actions/version.ts, exposed to scripts asapi.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), androll(expr, { silent })(§3.4). 0.3 (v0.100.0) addedapi.history.commitsandapi.history.diff(§3.10c), read-only. 0.4 (v0.102.0) addedapi.campaign.configuration(§3.10d), read-only. 0.7 (v0.115.0) addedapi.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 (
settrap returnsfalse). - 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).
awaitis optional and harmless. - By default emits a rich
[DICE_ROLL:…]message (3D dice + chat row), tinted with the acting token'schatColor. - 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;silentwins when both are given.roll(expr, { label, text })→ one decorated chat row;{result}intextis 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 (soapplyDamageoutput 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:
- Simple —
messageis plain text. Shows one input, validated/coerced tojsType. Number fields accept math expressions ("32-14"). Returns the value, ornullon cancel. - HTML form —
messagecontains<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'sname, with type coercion (numbers, checkboxes → boolean,<select multiple>→ array). Returnsnullon 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 streamscancelled — no damage rolledrather 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>
pickSquaresenters 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 withcountsquares in click order. Every exit — Escape, right-click, the banner's Cancel, another targeting session starting over it — resolvesnullexactly 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 bycharacterImportItem→characterTokenPayload— the same two functions the Import Character modal uses, so a summoned token is the same object an imported one is.importPartyresolves by id, then by name (case-insensitive) among the parties the caller can see; an ambiguous name throws.importRelatedCharactersreadsGET /api/characters/:id/relationshipsand selects by type via sharedrelatedCharacterIds(every related character whentypesis 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; intestActionon 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
errorfield 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'smetadata.damageModifiers(resistance/vulnerability/immunity) viacalculateDamage; computesnewHp = max(0, currentHp - finalAmount); emits a pending card with the full breakdown. - Heal path (
amount < 0): skips modifiers, setsisHealing: true, computesnewHp = 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 asapprovedand 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
nullif 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_functionstable (dbSchema.ts) with either acampaign_id(user-authored, per campaign) or asystem_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)
- Half the versioning.
ACTION_API_VERSIONandapi.versionexist since 0.2; per-actionapiVersionand compatibility checking do not, and are not needed until something breaks. - Positional, untagged surface. Adding/reordering a global is a silent breaking change.
- Raw-HTML prompts. No typed/declarative dialogue; authors hand-write and hand-escape HTML.
- No persisted execution record. Invocation ids are ephemeral; re-roll only patches a single dice token and never recomputes downstream effects.
- System semantics leak into the platform. The System 8 damage-tier prompt
lives in
characterToToken.tsinstead of the System 8 module. - Thin combat vocabulary. No conditions/status, resources, opposed rolls, reactions, or area collection — every author re-implements them ad hoc.
targetwrite hole.target.setPropertybypasses read-only protection.