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:
-
The damage calculators are correctly System 8 fixtures.
lightDamage,mediumDamage,heavyDamage, andattackRollare token-type campaign functions shipped by the System 8 module (campaign-functions.json, seeded intocampaign_functionswithsystem_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. ✓ -
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, theif (isImplement)branch). When importing a "Spellcasting Implement" attack, the importer generates an action whose code contains a rawprompt('<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
- Stability through versioning. Authored actions must keep working as the
platform evolves. Introduce
ACTION_API_VERSIONand a per-actionapiVersionwith explicit compatibility rules — mirroring the existing, provenMODULE_API_VERSIONmodel (module.ts). - 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.
- 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. - 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.
- Capability negotiation, graceful degradation. An action can ask
api.has( 'combat.conditions'); hosts that lack a capability degrade predictably rather than throwing. - 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.1and run through the back-compat shim (§7). - System/campaign functions gain the same field (and an optional
enginerange, 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
amountstill heals;applyHealing(...)is sugar. await h.resolvedlets 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
nullif cancelled. Bound<T>functions are evaluated once when the dialog opens, with(token, target, targets)— that's how yourmaxclosure works.when/validaterun live as the user edits.- Built on the existing
ActionPromptModalmachinery (sanitization, numeric math-eval,<select multiple>→ array, focus handling) —spawnDialoguecompiles aDialogueSpecinto 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
dialoguejournal 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
effectKeyas the original, so the host reconciles in place: messages are edited via the existingupdateMessageplumbing; a supersededDAMAGE_PENDINGcard 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:
- Action runs; journal:
seq0 roll(1d20)=8,seq1 requestRoll(save)=12,seq2 roll(damage)=…,seq3 effect:damage. - GM clicks Advantage on the
seq0roll in the frame. - Host computes the advantage roll, then
replayFrom(invocationId, seq=0, { override: max(8, newRoll) }, policy='pin'). - Re-run:
seq0← override;seq1← recorded12;seq2recomputes the damage formula from the new d20 outcome;seq3re-emits damage with the sameeffectKey→ the existing damage card updates.
5.4 What it requires
- §4.4
flow.checkpoint/isReplayand 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
- Journal + frame persistence (no replay yet) — record runs; powers an "action history" inspector. Low risk, immediately useful.
- Pin-downstream replay wired to the existing advantage/disadvantage/re-roll buttons — delivers your scenario.
- 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/requestRollreturn richer values in 1.0. Actions stamped0.1(or unstamped) run with a shim that preserves the old returns (void/number|null);1.0actions get theEffectHandle/ structured result. The shim keys offaction.apiVersion. - Stamping. On first save/edit under the new editor, stamp
apiVersion = ACTION_API_VERSION. Never auto-rewrite existing code. - System functions declare an
enginerange; incompatible ones are skipped with a visible status (same UX as incompatible modules today).
8. Suggested roadmap (independently shippable slices)
- Versioning foundation —
ACTION_API_VERSION,IAction.apiVersion, compatibility check + warning UI, theapi.*namespace with bare-global aliases. (No behaviour change; unlocks everything else.) spawnDialogue+ dialogue presets — biggest ergonomics win; isolated; reusesActionPromptModal. Migrate the System 8 tier prompt (§6) as the proof.- Richer
applyDamage/requestRoll/requestSave/opposed— additive results +await h.resolved. combat.*capabilities — conditions, resources, area collection (system modules supply content). Retire the most common hand-rolled metadata pokes.flow.requestReaction— reactions/interrupts.- Rewind & replay — §5, in its own three sub-phases (journal → pin-replay → full).
9. Open questions
- Journal storage — piggyback on the first frame message's metadata, or a
dedicated
action_invocationstable? (Replay needs durability across reloads; the second is cleaner but a schema add.) - 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.)
- Cross-client replay — replay runs on the GM's client;
requestRollresults are replayed from the journal, so no other player is re-pinged. Confirm that's the desired UX (vs. re-asking). - Capability discovery in the editor — surface
api.has(...)capabilities as autocomplete gated by the campaign's active system/modules? - Should
target.setPropertybe closed (0.1 §3.2 hole) as part of the 1.0 major, or kept for back-compat behind the shim?