Skip to content

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's InvocationJournal in 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:

  1. enters targeting mode, user selects 10 targets in an area
  2. requestRoll() from each target's owner for a save — 10 prompts, 10 results
  3. rolls a DC and damage — 2 more rolls
  4. 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 its status: 'pending' | 'approved' | 'denied' | 'modified' and ownerUserId approval fields. The existing damage card renders unchanged inside the doc before it is ever aggregated.
  • [DICE_ROLL_PENDING:…] becomes a roll step with total: null. The single-slot onRollComplete handler in ChatLog keeps doing exactly what it does now, except it patches a step instead of a message.
  • error replaces 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 to summary.headline plus 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:

  • updateMessage PATCH — useChatMessages.ts:178
  • router.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:

  • applyDamage approval happens on the target owner's client (ChatLog.handleDamageAction).
  • A requestRoll answer 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.

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 key stable and derived from (invocationId, seq), not from content or array position. That single discipline is what keeps replay reachable.

8. Roadmap (independently shippable slices)

  1. Group at write, not at render. ActionExecutor already mints the invocation. Make the invocation context's sendMessage / roll / applyDamage append 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.
  2. Step-scoped PATCH (§5.1) — land before concurrent writers exist.
  3. requestRoll resolves in place — ask steps, stepKey on the relay payload (§5.2), deadlines and the GM affordance (§6.2).
  4. 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.
  5. Optional authoring sugar — api.step() / api.summary() for actions that want explicit control of their own presentation. Additive; belongs with the 1.0 api.* 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

  1. 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.
  2. Who may force-complete an abandoned doc (§6.3) — GM only, or also the invoking user on reconnect?
  3. Editing — chat messages are user-editable today (the messageHtml round-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?
  4. 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.
  5. 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.