Chat Invocation Document — one evolving message per action
Status: SHIPPED as v0.73.0 — all 28 tasks. An invocation is now a single
[ACTION_DOC:…]chat message that every writer patches in place, which is what this document proposed.The paragraph below is what it said before that, and it stayed wrong for months. It is left in place rather than edited away, because "nothing here is built" outliving the build is the failure this whole roadmap system was written to stop — and v0.80.0 corrected the same class of error in eight other documents by hand. One later reversal to note: v0.80.0 decided an action document never collapses itself, against this document's design.
Original status. Proposal / RFC. Nothing here is built. It targets a concrete problem (chat spam from automation-heavy actions) and is deliberately scoped so its first two slices are ordinary TODO items, not a rewrite.
Relationship to other specs: it evolves the shipped Action API 0.1 presentation layer only — no change to the authored-script surface. It is independent of the Action API 1.0 proposal, but §7 below shows the two are the same object seen from two sides: this document's
steps[]is that proposal'sInvocationJournalin its persisted form, and shipping it answers that proposal's open question #1.
1. The problem
An action written to handle an area power is very good automation and very bad chat. A realistic one:
- enters targeting mode, user selects 10 targets in an area
requestRoll()from each target's owner for a save — 10 prompts, 10 results- rolls a DC and damage — 2 more rolls
applyDamage()on each target — 10 approval cards
That is 21–25 chat messages for a single button press. Two things are wrong, and they need separating because they have different fixes:
- Fragmentation — the output of one logical event is scattered across many bubbles.
- Volume — even perfectly grouped, the content is mostly duplicated boilerplate ("Kara rolled 1d20+3 for a save… total 17… Kara takes 14 damage…" × 10) and detail nobody normally reads (per-die breakdowns).
Fragmentation is already partly addressed:
actionInvocationMarker.ts
tags each message with an invisible <!--PCG_ACTION:…--> marker, and
ChatLog.tsx (~line
1113) re-groups consecutive messages with the same invocation id into a
bordered frame. Volume is untouched — the frame is a box drawn around 25
messages, not fewer messages.
1.1 Approaches already tried, and why they stalled
Message-level dice parsing. Instead of firing roll() per die, an action
returns one string — I rolled 1d20+5 against your AC, and rolled 3d8+5 damage — chat rolls the expressions and substitutes the totals. One clean
message. This works well and should be kept (see §4.4), but it only covers
output the action can compute up front.
The conditional-text dead end. It breaks as soon as applyDamage() and
requestRoll() enter, both of which are too useful to give up. requestRoll()
is asynchronous and may never return — the user must not wait on it before
seeing any chat output. The obvious patch is conditionals inside the message
("if that hits, target takes 3d8+5 damage"), evaluated once the rolls land.
That is a slope that ends with chat messages evaluating arbitrary user code,
which is not a direction worth taking — the platform has a deliberate
script-execution trust boundary
and this would open a second, weaker one.
1.2 The target experience
Something closer to a modern AI chat response: the message appears immediately,
evolves as reasoning proceeds, can embed prompts to the user (an applyDamage
approval), and ends as one bubble for the whole response. But the goal is not
merely to wrap everything in one container — it is to shrink the result by
collapsing duplication and hiding detail that is not normally wanted. "Kara
passed the save with a 17", where 17 carries a tooltip with the exact dice,
modifiers, and source.
2. The core inversion
Today an invocation emits N messages, tagged, re-grouped at render time.
Instead: an invocation posts one message immediately, whose body is a structured, append-only document that the still-running script mutates as it learns things.
This dissolves the conditional-text dilemma outright:
The script is the reasoning engine; the message is its render target.
Chat never needs to evaluate a conditional, because the code that owns the
conditional is still running and still writing to the message. requestRoll()
never blocks anything: the message is already posted, in running state, with
that step rendered as pending. It resolves in place whenever the answer
arrives — a second later, a minute later, or never.
It also retires a known fragility. Frame grouping is currently positional: any untagged message interleaving splits the frame in two (documented as an invariant that keeps getting re-broken). When the group is one message, splitting is structurally impossible.
3. The document
Reuse the existing marker convention ([DAMAGE_PENDING:…],
[DICE_ROLL_PENDING:…], [COMBAT_LOG:…]): the message's messageHtml is a
single marker whose payload is the document. No schema change, and the marker
survives the REST round-trip and Socket.IO broadcast exactly as the existing
ones do.
/** `[ACTION_DOC:{…}]` — the whole body of an invocation's chat message. */
interface ActionInvocationDoc {
v: 1;
id: string; // the existing ActionInvocation uuid — unchanged
actionName: string;
actorName: string;
actorTokenId: string;
status: 'running' | 'awaiting' | 'complete' | 'error';
startedAt: string; // ISO
steps: Step[]; // append-only; never reordered, never removed
summary?: InvocationSummary;
}
interface InvocationSummary {
headline: string; // "Fireball — 6 targets, 4 failed, 38 damage"
facts?: Record<string, unknown>; // structured, for the collapsed view
}
3.1 Steps
Every effect the Action API can produce today maps onto a step. Each carries a
stable key, `${invocationId}:${seq}`, which is what makes late,
out-of-order, cross-client updates safe (§5).
type Step =
| { k: 'text'; key: string; html: string }
| { k: 'roll'; key: string; expr: string; label?: string;
total: number | null; // null === still rolling
breakdown?: DiceBreakdown }
| { k: 'ask'; key: string; tokenId: string; tokenName: string; expr: string;
label?: string;
status: 'pending' | 'answered' | 'declined' | 'expired';
total: number | null;
deadline?: string } // ISO; see §6.2
| { k: 'damage'; key: string } & IDamagePendingData
| { k: 'prompt'; key: string; spec: unknown; answer?: unknown }
| { k: 'error'; key: string; message: string };
Notes on the mapping:
IDamagePendingData(vtt.ts:465) drops in verbatim, including itsstatus: 'pending' | 'approved' | 'denied' | 'modified'andownerUserIdapproval fields. The existing damage card renders unchanged inside the doc before it is ever aggregated.[DICE_ROLL_PENDING:…]becomes arollstep withtotal: null. The single-slotonRollCompletehandler in ChatLog keeps doing exactly what it does now, except it patches a step instead of a message.errorreplaces the standalone error notice an action currently posts.
3.2 Why a marker rather than a column
Consistency with three existing precedents, and zero migration. The cost is
size: a 10-target doc with full breakdowns is a few KB of JSON inside
messageHtml. That is acceptable, and if it ever stops being acceptable, the
serialize/parse pair is a clean seam — moving the payload to a jsonb column
touches two functions and no callers. Do not pre-emptively add the column.
4. Rendering: collapse, not just wrap
This is where the volume half of §1 is won. Because steps are data rather than pre-rendered HTML, the renderer is free to aggregate them.
4.1 Aggregate same-shape steps
Ten ask steps sharing an expr render as one table, one row per target:
| Target | Save | Result |
|---|---|---|
| Kara | 17 | passed |
| Bront | 8 | failed — 14 |
The totals are the only numbers on screen; each carries the breakdown in a
tooltip. Ten damage steps likewise collapse into one card with ten rows and
a single "approve all" affordance alongside the existing per-row approve /
modify / deny.
4.2 Progressive disclosure
status: 'running'→ expanded, so the user watches it fill in.status: 'complete'→ collapse tosummary.headlineplus the interactive bits (anything still awaiting approval stays visible), expandable on click.- Detail (per-die breakdowns, modifier sources) lives in tooltips and the expanded view, never in the default render.
4.3 The summary
Derived by the renderer from the steps by default (count targets, count failures, sum applied damage). An action may override it — see §8, slice 5 — but nothing should require an author to write one.
4.4 The declarative-text shorthand survives
The sendMessage("I rolled 1d20+5 …") parsing from §1.1 is not thrown away.
It becomes a producer: parsing yields text and roll steps that get
appended to the current doc. One mechanism, two front doors.
5. Writers, transport, and the one real hazard
The transport already exists and needs nothing new:
updateMessagePATCH — useChatMessages.ts:178router.patch("/:id", …)→broadcastChatMessageUpdated— chatMessages.ts:171
5.1 The hazard: multiple writers
The invoking client owns the doc, but it is not the only writer:
applyDamageapproval happens on the target owner's client (ChatLog.handleDamageAction).- A
requestRollanswer happens on the target owner's client.
Whole-document PATCH from two clients = lost updates, and with ten targets answering at once this is not a rare race — it is the normal case.
Fix: a step-scoped endpoint. PATCH /chat-messages/:id/step/:stepKey,
where the server merges one step into the stored doc. Small, self-contained
server change. It makes every writer's update commutative and removes the
race permanently rather than narrowing it. Ship it before anything writes
concurrently (§8, slice 2).
5.2 Routing a requestRoll answer back to its step
Nearly free. RollRequestPayload
(rollRequestRelay.ts)
already forwards invocation verbatim — the relay handler spreads the payload,
so new optional fields pass through with no server change. Add stepKey
next to invocation (set in
tokenApi.ts:354 where
invocation: ctx.actionInvocation is already threaded), and the answering
client patches that step directly instead of posting its own tagged message.
5.3 Batching
Naively PATCHing per step just relocates the spam from chat to the network —
25 REST calls instead of 25 messages. Buffer step appends and flush at await
boundaries and on completion; executeAction is async, so the flush points
are natural. createQueuingContext in
useTargetingMode.ts:35 is
already ~80% of this buffer — it exists to queue sendMessage calls during a
targeted action and flush after resolution.
6. Failure and edge cases to design for now
6.1 Late steps mutate an old bubble
A save answered two minutes later updates a message that has since scrolled up. That is the desired AI-chat behaviour, but the resolution can go unseen. Needs an "updated" affordance — reuse the existing pending/mention glow vocabulary rather than inventing a new one.
6.2 Unbounded pending
ask steps must carry a deadline and, past it, render a GM affordance —
"roll for them" / "skip". An offline player then degrades to expired rather
than leaving a bubble pending forever. This is strictly better than today,
where an unanswered requestRoll simply never produces its message.
6.3 Abandoned invocations
A doc stuck in status: 'running' because the invoking client reloaded or
crashed. Do not add a heartbeat; render docs that have been running past a
threshold as incomplete, and let the GM dismiss or force-complete.
6.4 Transcript and search
The Transcript view and any export need a plain-text projection of the doc (steps → flat lines). Write it as a pure function next to the parser; it is also the natural place to keep the current text output for anything that reads chat as prose.
6.5 Out-of-action rolls are untouched
A manual /roll, an initiative roll, a persona roll — no invocation, no doc,
one message, exactly as today. This proposal only changes what happens inside
ActionExecutor.executeAction.
6.6 Combat Log
Unaffected. Combat-log events are separate type: 'combat' messages carrying
[COMBAT_LOG:…] and are already excluded from the Chat view. Emission points
stay where they are. (Longer term, the combat log could become a projection
of invocation docs — noted, not proposed.)
7. Relationship to the Action API 1.0 proposal
The 1.0 proposal defines an
InvocationJournal (§5.2) recording every nondeterministic input and every
reconciled effect, keyed by the same invocation id, as the durable substrate
for rewind & replay. Its §9 open question #1 asks where that journal should
live — piggybacked on the frame's first message, or a dedicated
action_invocations table.
This document is that journal, in its persisted, user-visible form.
steps[] and JournalStep[] are the same sequence; key and effectKey are
the same stable identifier (invocationId + seq) serving the same purpose —
supersede-in-place rather than duplicate. So:
- Shipping this answers open question #1 in the affirmative (on the message), and gets replay's durability prerequisite for free.
- Conversely, this proposal stands entirely on its own. It needs no
versioning, no
api.*namespace, no new authored surface — nothing from 1.0 §1–§4. - The one thing to preserve for later: keep
keystable and derived from(invocationId, seq), not from content or array position. That single discipline is what keeps replay reachable.
8. Roadmap (independently shippable slices)
- Group at write, not at render.
ActionExecutoralready mints the invocation. Make the invocation context'ssendMessage/roll/applyDamageappend steps to a doc rather than post messages; render the doc where the frame is rendered today. Zero changes to authored action scripts. This alone collapses 21–25 messages into one and retires the consecutive-grouping invariant. - Step-scoped PATCH (§5.1) — land before concurrent writers exist.
requestRollresolves in place —asksteps,stepKeyon the relay payload (§5.2), deadlines and the GM affordance (§6.2).- The collapsing renderer (§4) — aggregation tables, breakdown tooltips, derived summary, collapse-on-complete. This is the slice that delivers the size reduction; slices 1–3 deliver the fragmentation fix.
- Optional authoring sugar —
api.step()/api.summary()for actions that want explicit control of their own presentation. Additive; belongs with the 1.0api.*namespace if that lands.
Slices 1 and 2 are small enough to be ordinary CHANGELOG TODO items. Slice 4 is the largest and is pure presentation — it can be iterated on without touching the data model.
9. Open questions
- Aggregation trigger — collapse same-shape steps into a table at a fixed threshold (say 3+), or only when the action opts in? Automatic risks surprising an author who wanted separate lines.
- Who may force-complete an abandoned doc (§6.3) — GM only, or also the invoking user on reconnect?
- Editing — chat messages are user-editable today (the
messageHtmlround-trip in ChatLog preserves markers deliberately). Should an invocation doc be editable at all, and if so, does editing it mean editing the projection or the steps? - Ordering vs. approval — when a damage step is approved long after the fact, the applied HP change is what the Combat Log records. Confirm the combat-log entry should stay anchored to approval time, not invocation time.
- Size ceiling (§3.2) — is there a step count past which the doc should spill to a second message or a column? A 50-target sweep is not absurd.