Skip to content

Action API 1.0 — Draft Proposal

Status: Proposal / RFC. Forward-looking design. The shipped baseline it evolves from is the Action API 0.1 de facto spec. Nothing here is built yet. Sections are independently adoptable; §1–§4 are the foundation, §5 (rewind/replay) is a stretch that depends on §1.4 and §4.4.


0. Report — how System 8's "light / medium / heavy damage" is actually wired

The prompt asked for this specifically, so it leads the proposal because it motivates the architecture.

Finding — it's two different things, and only one is where you'd expect:

  1. The damage calculators are correctly System 8 fixtures. lightDamage, mediumDamage, heavyDamage, and attackRoll are token-type campaign functions shipped by the System 8 module (campaign-functions.json, seeded into campaign_functions with system_id = 'system8'). They take an ability name and return a dice-expression string (e.g. token.lightDamage('dex') → "2d4+2"). This is exactly the pattern you want: system-specific behaviour, owned by the system module, injected onto the token at runtime, not in the platform's core Action API. ✓

  2. The "ask the player light/medium/heavy" prompt is neither in the Action API nor in the System 8 module. It is hardcoded inline in the platform, in characterToToken.ts (createAttackAction, the if (isImplement) branch). When importing a "Spellcasting Implement" attack, the importer generates an action whose code contains a raw prompt('<label>Damage tier: <select name="tier">…</select>') with the three tiers and their precomputed formulas baked in.

So your instinct is half-right: it's correctly not in the core Action API — but it's also not a system fixture. It's hardwired into the platform's character→token converter, which means System 8's damage-tier rules leak into platform code, can't be reused by hand-authored actions, can't be themed/changed by the system, and (relevant to §5) aren't replay-safe.

Recommendation: move the tier selection into the System 8 module as a reusable function — e.g. a token campaign function token.chooseDamageTier(ability) (or, better, a registered dialogue preset, §4.3) that internally calls spawnDialogue and returns { tier, expression }. The importer then emits const { expression } = await token.chooseDamageTier('int'); const dmg = await roll(expression); target.applyDamage(dmg, 'damage'); — no System 8 rules in characterToToken.ts. This is the concrete first customer of the 1.0 system-extension model (§6).


1. Goals & design principles

  1. Stability through versioning. Authored actions must keep working as the platform evolves. Introduce ACTION_API_VERSION and a per-action apiVersion with explicit compatibility rules — mirroring the existing, proven MODULE_API_VERSION model (module.ts).
  2. Layered, not monolithic. A small Core API owned by the platform; a System layer owned by modules (System 8, 5e, …); a Campaign layer owned by users. Higher layers compose the lower ones. No system semantics in core.
  3. Effects as data. Side effects (damage, roll requests, dialogues, condition changes) are described as serializable values and reconciled by the host — generalizing today's [DAMAGE_PENDING:…] model. This is what makes approval, undo, and replay possible.
  4. Deterministic by construction. All nondeterminism (dice, prompts, foreign rolls, randomness) flows through the API and is journaled. The sandbox already bans clocks/IO/DOM/network (§8 of 0.1), so actions are already close to pure between API calls — 1.0 leans into that.
  5. Capability negotiation, graceful degradation. An action can ask api.has( 'combat.conditions'); hosts that lack a capability degrade predictably rather than throwing.
  6. Ergonomics preserved. The bare globals (token, roll, sendMessage, target, …) stay. New surface lives under namespaces so the simple cases stay one-liners.

2. Versioning model

2.1 ACTION_API_VERSION

A SemVer constant in shared, with a changelog block, exactly like MODULE_API_VERSION:

// packages/shared/src/actions/version.ts
export const ACTION_API_VERSION = '1.0.0';
/*
 * MAJOR — remove/rename a global, change a signature incompatibly, change an
 *         effect's serialized shape incompatibly.
 * MINOR — add a global/method/namespace, add an optional field, add a capability.
 * PATCH — behaviour-preserving fixes.
 *
 * History:
 *   0.1  — (retroactive) initial positional surface: token, character, roll,
 *          rollDetailed, sendMessage, prompt, target, targets, damageTypes,
 *          draw; applyDamage + requestRoll; campaign functions.
 *   1.0  — versioning; api.* namespace; spawnDialogue; richer applyDamage/
 *          requestRoll results; flow.* + combat.* capabilities; effect journal.
 */

2.2 Per-action apiVersion

Add an optional field to IAction:

interface IAction {
  // …existing…
  apiVersion?: string; // SemVer the script was authored against. Default '0.1'.
}
  • Newly created/edited actions are stamped with the current ACTION_API_VERSION.
  • Existing actions (no field) are treated as 0.1 and run through the back-compat shim (§7).
  • System/campaign functions gain the same field (and an optional engine range, like modules) so a system can declare "needs Action API ≥ 1.1".

2.3 Compatibility policy

On execution, compare action.apiVersion to ACTION_API_VERSION:

Case Behaviour
same major, platform.minor ≥ action.minor Run. (Forward-compatible: new platform runs older action.)
same major, platform.minor < action.minor Run with warning — action authored against a newer minor; unknown calls may be absent. Surface a chat/notice badge.
action.major < platform.major Run via compatibility shim for that major (§7), or refuse with a clear message if no shim.
action.major > platform.major Refuse with "this action needs a newer platform."

Expose the negotiated version to scripts as api.version and capabilities as api.has(name) so authors can branch instead of crashing.


3. Architecture

3.1 Three layers

┌────────────────────────────────────────────────────────────────┐
│ Campaign layer   — user-authored campaign/token functions       │  per-campaign
│   (e.g. a table's house-rule "sneakAttack()")                   │
├────────────────────────────────────────────────────────────────┤
│ System layer     — module-provided functions, dialogue presets, │  per-system module
│   damage models, condition sets (System 8: chooseDamageTier,     │
│   lightDamage…; 5e: advantage helpers, conditions)              │
├────────────────────────────────────────────────────────────────┤
│ Core Action API  — platform-owned, versioned, system-agnostic    │  ACTION_API_VERSION
│   token/target/character, roll, sendMessage, applyDamage,        │
│   requestRoll, spawnDialogue, flow.*, combat.* (generic)         │
└────────────────────────────────────────────────────────────────┘

Resolution order for a bare name is Campaign → System → Core (campaign can override a system helper; neither can shadow a frozen core global — collisions with core names are rejected at save time with a lint error).

3.2 The execution context object

0.1 passes ~10 positional params. 1.0 keeps those as ergonomic aliases and introduces a single frozen api namespace object so the surface can grow without positional churn:

// Still injected as bare globals (back-compat + ergonomics):
token, target, targets, character, roll, rollDetailed, sendMessage, prompt, damageTypes, draw

// New: one namespaced handle for everything added in 1.0+
api: {
  version: string,                 // negotiated ACTION_API_VERSION for this run
  has(capability: string): boolean,
  ui:    { dialogue, prompt, notify, chat },
  dice:  { roll, rollDetailed, random },     // random() = seeded, journaled RNG
  flow:  { requestRoll, requestSave, opposed, requestReaction, checkpoint, isReplay },
  combat:{ applyDamage, applyHealing, conditions, resources, areaTokens },
  system: Record<string, Function>,          // resolved system/campaign helpers
}

Bare globals are thin aliases into api.* (e.g. roll ≡ api.dice.roll). New features are reachable both ways; docs steer new authors to api.*.

3.3 Effect bus (generalizing DAMAGE_PENDING)

Today applyDamage emits [DAMAGE_PENDING:{…}] and ChatLog reconciles it on approval. 1.0 generalizes this into a typed effect envelope that all host-reconciled effects share:

interface ActionEffect<T = unknown> {
  kind: 'damage' | 'healing' | 'condition' | 'resource' | 'roll-request' | 'dialogue' | 'message';
  effectKey: string;        // stable per (invocationId, seq) — supersedes on replay
  invocationId: string;
  seq: number;              // ordinal within the invocation (journal index)
  requiresApproval: boolean;
  payload: T;
}

effectKey is the linchpin for §5: a replayed run emits effects with the same keys, so the host updates in place instead of duplicating.


4. Core API 1.0 surface

4.1 applyDamage / applyHealing (formalized + richer)

Keep the 0.1 call shapes; add a structured form and a return handle:

// Back-compat (0.1):
target.applyDamage(amount, type?);            // returns void today → returns EffectHandle in 1.0

// 1.0 structured:
const h = await target.applyDamage({
  amount: 14,
  type: 'fire',
  source: token,            // attribution for the Combat Log
  tags: ['spell', 'aoe'],   // for resistances keyed on tags, riders, etc.
  approval: 'auto' | 'gm' | 'owner',  // default 'gm' (arbitrary scripts can't force 'auto')
});
// h: EffectHandle { effectKey, status: 'pending'|'approved'|'denied'|'modified',
//                   resolved: Promise<{ applied: number; newHp: number|null }> }
const { applied } = await h.resolved;   // optional: wait for GM approval to branch
  • Negative amount still heals; applyHealing(...) is sugar.
  • await h.resolved lets an action react to the approved number (e.g. lifesteal: heal the attacker for half the damage actually dealt). 0.1 has no way to do this.
  • All existing modifier/IDamagePendingData/Combat Log behaviour is preserved.

4.2 requestRoll / requestSave / opposed (richer results)

// Back-compat: still resolves to a number | null.
const n = await target.requestRoll('1d20+dex');

// 1.0 structured result (when called via api.flow):
const r = await api.flow.requestRoll(target, '1d20+dex', { label: 'Dex save' });
// r: { total: number; rolls: number[]; breakdown: string; denied: boolean; timedOut: boolean }

// Sugar for the overwhelmingly common save:
const save = await api.flow.requestSave(target, { dc: 15, expr: '1d20+dex', label: 'Dex save' });
// save: { total; success: boolean; denied: boolean }

// Opposed/contested roll (grapple, etc.):
const o = await api.flow.opposed(
  { token, expr: '1d20+athletics' },
  { token: target, expr: '1d20+acrobatics' },
);
// o: { winner: 'attacker'|'defender'|'tie'; attacker: RollResult; defender: RollResult }

These are thin, generic compositions over the existing roll-request bridge — no system rules (DCs, ability lists) baked in; the caller supplies them.

4.3 spawnDialogue — typed, declarative input (replaces raw-HTML prompt)

The headline ergonomics win and a hard requirement for replay (a serializable dialogue spec + answer can be journaled; a hand-written HTML blob cannot be re-presented reliably).

Your example, supported verbatim:

const { dmg, healOrHarm } = await spawnDialogue({
  dmg: {
    type: Number,
    min: 0,
    max: (token, target) => Math.max(token.getProperty('MaxHP'), target.getProperty('MaxHP')),
  },
  healOrHarm: Boolean,
});

Field descriptor:

type FieldType = NumberConstructor | StringConstructor | BooleanConstructor
               | { enum: Array<string | { value: string; label: string }> }
               | { token: 'ally' | 'enemy' | 'any' };   // pick a token on the map

type Bound<T> = T | ((token, target, targets) => T);    // static or computed-at-open

interface FieldDescriptor {
  type: FieldType;
  label?: string;            // defaults to a humanized key
  help?: string;
  default?: Bound<unknown>;
  required?: Bound<boolean>; // default true
  min?: Bound<number>; max?: Bound<number>; step?: Bound<number>;  // numbers/ranges
  when?: (values) => boolean;        // conditional visibility (live, within the dialog)
  validate?: (value, values) => string | null;  // custom error message
  widget?: 'input' | 'slider' | 'radio' | 'select' | 'textarea';  // hint; sensible default per type
}

// A field can be the bare constructor (`Boolean`) or a full descriptor.
type DialogueSpec = Record<string, FieldType | FieldDescriptor>;

Semantics:

  • Returns an object keyed by field name (coerced to the declared types), or null if cancelled.
  • Bound<T> functions are evaluated once when the dialog opens, with (token, target, targets) — that's how your max closure works. when/validate run live as the user edits.
  • Built on the existing ActionPromptModal machinery (sanitization, numeric math-eval, <select multiple> → array, focus handling) — spawnDialogue compiles a DialogueSpec into the same internal form model rather than asking authors to emit HTML. prompt() stays for back-compat and trivial one-offs.
  • The compiled spec + the returned answer are recorded as a dialogue journal entry (§5), so a replay can re-show the same dialog or reuse the prior answer.

System dialogue presets. A system module can register named presets so authors don't redefine common dialogs:

// In the System 8 module:
registerDialoguePreset('system8.damageTier', {
  tier: { type: { enum: ['light', 'medium', 'heavy'] }, default: 'medium', label: 'Damage tier' },
});
// In an action:
const { tier } = await api.ui.dialogue.preset('system8.damageTier');

This is the home for the migrated light/medium/heavy prompt (§0, §6).

4.4 flow.* — control, checkpoints, replay awareness

api.flow.checkpoint(label?: string): void   // marks a replay boundary (§5)
api.flow.isReplay: boolean                  // true during a replay run
api.flow.requestReaction(target, { prompt, timeoutMs }): Promise<ReactionResult>

requestReaction generalizes requestRoll to "pause and let another player decide" — Shield, Counterspell, Cutting Words, "do you use your reaction?" It reuses the roll-request socket bridge with a different payload.

4.5 combat.* — the shared combat vocabulary (capabilities)

Every author currently re-implements these by poking metadata. Promote them to generic, capability-gated helpers (systems supply the content: which conditions exist, what they do):

// Conditions / status effects (capability 'combat.conditions')
target.conditions.add('prone', { duration: { rounds: 1 }, source: token });
target.conditions.remove('prone');
target.conditions.has('prone'): boolean;
target.conditions.list(): Condition[];

// Resources (capability 'combat.resources') — replaces hand-rolled `token.spellSlots -= 1`
token.resources.spend('spellSlots', 1): boolean;   // false if insufficient (no negative writes)
token.resources.restore('spellSlots', 1);
token.resources.get('spellSlots'): number;

// Area collection (capability 'combat.area') — auto-target an AoE
const hit = api.combat.areaTokens({ shape: 'sphere', radius: 4, center: target });
for (const t of hit) t.applyDamage(await roll('8d6'), 'fire');

Conditions/resources are defined by the system module (names, effects, durations, tick timing tied to the existing combat tracker / Combat Log) and merely manipulated through the core API. Turn-scoped durations hook the existing turn tracker that already drives the Combat Log.

4.6 Summary: 0.1 → 1.0 surface

0.1 1.0 Change
token, target, targets, character same unchanged
roll, rollDetailed, sendMessage, prompt, damageTypes, draw same unchanged (aliases into api.*)
applyDamage(n, type?) → void returns EffectHandle; structured form; await h.resolved additive
requestRoll → number|null + requestSave, opposed, structured results additive
raw-HTML prompt + spawnDialogue(spec) + dialogue presets additive
campaign functions + dialogue presets, condition/resource/damage-model registration additive
— api.version, api.has(), flow.*, combat.*, seeded random() new
— ACTION_API_VERSION, per-action apiVersion, compat shims new

All 0.1 code runs unchanged under 1.0 (everything above is additive); §7 covers the one shim needed for return-shape changes.


5. Rewind & replay (stretch)

5.1 The problem

Today, the ChatLog re-roll / advantage buttons (ChatLog.tsx onReroll/onApplyAdvantage) re-roll a single [DICE_ROLL:] token and patch that one message via onUpdateRoll. They do not re-run the action, so nothing downstream recomputes. Your scenario — attack rolls 1d20, requests a save, feeds the save into a damage formula, applies damage; GM rules it had advantage; you re-roll the d20 → you want the damage to recompute — is impossible in 0.1.

5.2 The model: record-and-replay over a per-invocation journal

An action is async (api) => {…} whose only nondeterminism flows through roll / requestRoll / spawnDialogue / random (the sandbox guarantees this — no clock/IO/DOM). So we can make runs reproducible by journaling every nondeterministic input and every reconciled effect, keyed by the invocation id that already exists (actionInvocationMarker.ts).

interface InvocationJournal {
  invocationId: string;
  actionId: string;
  apiVersion: string;
  seed: string;                 // for random()
  context: { tokenId; targetIds; /* snapshot of inputs needed to re-run */ };
  steps: JournalStep[];
}
type JournalStep =
  | { seq; kind: 'roll';        input: { expr }; output: RollResult }
  | { seq; kind: 'requestRoll'; input: { tokenId; expr }; output: RollResult | { denied: true } }
  | { seq; kind: 'dialogue';    input: { spec };  output: Record<string, unknown> | null }
  | { seq; kind: 'random';      output: number }
  | { seq; kind: 'effect';      effectKey; payload: ActionEffect };  // damage, condition, message…

The journal is persisted with the chat frame (e.g. on the invocation's first message, or a small action_invocations store). It is the durable record 0.1 lacks.

5.3 Replay

"Rewind to step N and replay" re-runs the same action code under a replay harness that intercepts the nondeterministic API:

  • seq < N → return the recorded output verbatim (true replay: same rolls, same answers).
  • seq === N → substitute the override (the re-rolled value, or a GM-edited dialogue answer).
  • seq > N → policy-dependent (below).
  • Effects emitted during replay carry the same effectKey as the original, so the host reconciles in place: messages are edited via the existing updateMessage plumbing; a superseded DAMAGE_PENDING card is replaced (if the original was already approved, the new card shows the delta for re-approval). The invocation frame stays stable.

Two downstream policies (offer both; default = pin):

Policy seq > N behaviour Use case
Pin downstream (default) keep recorded outputs; only pure logic re-runs "advantage on this roll" — your example: the d20 changes, the recorded save is kept, the damage formula recomputes
Re-roll downstream re-execute live from N (fresh dice/prompts) "redo the whole attack from here"

Your exact scenario, end to end:

  1. Action runs; journal: seq0 roll(1d20)=8, seq1 requestRoll(save)=12, seq2 roll(damage)=…, seq3 effect:damage.
  2. GM clicks Advantage on the seq0 roll in the frame.
  3. Host computes the advantage roll, then replayFrom(invocationId, seq=0, { override: max(8, newRoll) }, policy='pin').
  4. Re-run: seq0 ← override; seq1 ← recorded 12; seq2 recomputes the damage formula from the new d20 outcome; seq3 re-emits damage with the same effectKey → the existing damage card updates.

5.4 What it requires

  • §4.4 flow.checkpoint/isReplay and the §3.3 effect bus (effectKey).
  • Seeded RNG (api.dice.random) and journaled rolls — the in-house dice are already deterministic/seedable, so this is wiring, not new math.
  • Per-invocation journal persistence (the main net-new storage).
  • Author guidance: "pin downstream" only recomputes correctly if logic between journaled calls is pure (the sandbox already enforces this). Document it; lint obvious violations.
  • Idempotent effects via effectKey (already true in spirit for damage — it's approval-gated data, not a direct HP write).

5.5 Phasing

  1. Journal + frame persistence (no replay yet) — record runs; powers an "action history" inspector. Low risk, immediately useful.
  2. Pin-downstream replay wired to the existing advantage/disadvantage/re-roll buttons — delivers your scenario.
  3. Re-roll-downstream + GM "edit any input and replay" + dialogue-answer overrides.

6. System extension architecture (the System 8 path)

The System layer (§3.1) extends actions through the module API (module.ts), versioned by its engine range against ACTION_API_VERSION. A system module may register:

Extension Purpose System 8 example
Functions (exists today) global / token helpers lightDamage, attackRoll
Dialogue presets (new, §4.3) named, reusable input dialogs system8.damageTier
Damage model (new) how applyDamage computes/labels rank-scaled tiers
Conditions / resources (new, §4.5) the system's status & resource vocabulary favor, ranks

Concrete migration of the §0 finding:

# System 8 module (campaign-functions.json or registerDialoguePreset)
+ token.chooseDamageTier(ability) →
+   const { tier } = await api.ui.dialogue.preset('system8.damageTier');
+   const die = { light: 'd4', medium: 'd8', heavy: 'd12' }[tier];
+   return `${rank}${die}${signedBonus(ability, tier)}`;

# apps/web/src/utils/characterToToken.ts (platform) — implement branch becomes:
- // ~30 lines of inlined prompt('<select name="tier">…') + precomputed formulas
+ const expr = await token.chooseDamageTier('int');
+ const dmg  = await roll(expr);
+ if (target?.applyDamage) target.applyDamage(dmg, 'damage');

Net: System 8's rules live in the System 8 module; the platform importer emits a generic call; the dialog is a serializable preset (replay-safe). Any hand-authored System 8 action can reuse chooseDamageTier.


7. Migration & compatibility

  • All 0.1 actions run unchanged. The 1.0 surface is additive; bare globals are retained.
  • Return-shape shim. applyDamage/requestRoll return richer values in 1.0. Actions stamped 0.1 (or unstamped) run with a shim that preserves the old returns (void / number|null); 1.0 actions get the EffectHandle / structured result. The shim keys off action.apiVersion.
  • Stamping. On first save/edit under the new editor, stamp apiVersion = ACTION_API_VERSION. Never auto-rewrite existing code.
  • System functions declare an engine range; incompatible ones are skipped with a visible status (same UX as incompatible modules today).

8. Suggested roadmap (independently shippable slices)

  1. Versioning foundation — ACTION_API_VERSION, IAction.apiVersion, compatibility check + warning UI, the api.* namespace with bare-global aliases. (No behaviour change; unlocks everything else.)
  2. spawnDialogue + dialogue presets — biggest ergonomics win; isolated; reuses ActionPromptModal. Migrate the System 8 tier prompt (§6) as the proof.
  3. Richer applyDamage/requestRoll/requestSave/opposed — additive results + await h.resolved.
  4. combat.* capabilities — conditions, resources, area collection (system modules supply content). Retire the most common hand-rolled metadata pokes.
  5. flow.requestReaction — reactions/interrupts.
  6. Rewind & replay — §5, in its own three sub-phases (journal → pin-replay → full).

9. Open questions

  1. Journal storage — piggyback on the first frame message's metadata, or a dedicated action_invocations table? (Replay needs durability across reloads; the second is cleaner but a schema add.)
  2. Approved-effect replay — when a replayed damage differs from an already-approved card, auto-apply the delta or always re-prompt the GM? (Proposal leans re-prompt for safety.)
  3. Cross-client replay — replay runs on the GM's client; requestRoll results are replayed from the journal, so no other player is re-pinged. Confirm that's the desired UX (vs. re-asking).
  4. Capability discovery in the editor — surface api.has(...) capabilities as autocomplete gated by the campaign's active system/modules?
  5. Should target.setProperty be closed (0.1 §3.2 hole) as part of the 1.0 major, or kept for back-compat behind the shim?