Mod Capabilities
A mod's entry.script runs with exports.setup(api, manifest) inside a sandboxed
iframe. The api object it receives is capability-gated: manifest.capabilities.allowed
is a list drawn from 12 fixed slugs, and every gated method throws unless its capability
is on that list. Declaring fewer capabilities than a mod's script uses fails validation.
Declaring more than it uses does not.
If capabilities is omitted from the manifest entirely, it defaults to
{ version: "1", allowed: ["log"] } — a mod with no capabilities block can only call
api.log().
The capability → method matrix#
| Capability | api methods it gates |
|---|---|
log |
log(message) |
spawn-object |
createObject(object) |
register-action |
registerAction(action) |
read-context |
getMySeat(), getMyTeam(), getTurn() |
read-world |
getSnapshot(), getObject(objectId), listObjects(filter?), getContainerContents(objectId), getHandObjects(seat?), getZoneObjects(seat, zoneId) — all six least-privileged, on every peer including the host — and it is additionally demanded by an onZoneEnter/onZoneLeave/onTriggerEnter/onTriggerLeave subscription |
read-hidden-information |
getUnredactedSnapshot() — the elevated read, and the only route to real card faces, pile order and secretMetadata. Never implied by read-world, never granted by default. |
object-action |
objectAction(objectId, action) |
saved-data |
getSavedData(scope?), setSavedData(data, scope?) |
subscribe-events |
on(eventName, handler) |
ui |
getUiState(), listUiElements(), setUiElement(element), deleteUiElement(elementId) |
play-sound |
playSound(params), setObjectSound(objectId, action, ref) |
plugin-call |
listPlugins(), callPlugin(pluginId, functionName, params?) — naming a declared function on an installed plugin your manifest also declares in plugins. Not network access; see below. |
See the api reference for full parameter and return details
on every method above.
plugin-callis not a network grant, and describing it as one is wrong. A mod names a function; the platform performs the request from its own servers, to an origin the plugin declared, under that plugin's quota and circuit breaker. A mod cannot express a destination or a payload the plugin did not declare a schema for, and every network pattern in the scanner still rejects the script at publish. It also requires a matchingpluginsdeclaration — see Calling a plugin from a mod.
How a declaration is checked#
Every capability is detected by matching literal api.<method>( call syntax in the
entry script's source — a whole-word, unlexed regex, the same style as the scanner's
5 banned-pattern rules. There is no AST parse: matching happens against raw text.
| Capability | Detector |
|---|---|
spawn-object |
/\bapi\.createObject\s*\(/ |
register-action |
/\bapi\.registerAction\s*\(/ |
read-context |
/\bapi\.(getMySeat|getMyTeam|getTurn)\s*\(/ |
read-world |
/\bapi\.(getSnapshot|getObject|listObjects|getContainerContents|getHandObjects|getZoneObjects)\s*\(/ |
read-world (zone/trigger hook) |
An api.on("onZoneEnter" | "onZoneLeave" | "onTriggerEnter" | "onTriggerLeave", …) subscription demands read-world too, because those hooks are only delivered to a frame that was granted it. |
read-hidden-information |
/\bapi\.getUnredactedSnapshot\s*\(/ |
object-action |
/\bapi\.objectAction\s*\(/ |
saved-data |
/\bapi\.(getSavedData|setSavedData)\s*\(/ |
ui |
/\bapi\.(getUiState|listUiElements|setUiElement|deleteUiElement)\s*\(/ |
subscribe-events |
/\bapi\.on\s*\(/ |
play-sound |
/\bapi\.(playSound|setObjectSound)\s*\(/ |
plugin-call |
/\bapi\.(listPlugins|callPlugin)\s*\(/ — plus a second, finer pass that reads each api.callPlugin("id", "fn" pair out of the source and rejects any the manifest's plugins array does not declare, or any call site whose two targets are not plain string literals |
log |
/\bapi\.log\s*\(/ |
Because the detectors match a literal api. prefix, a call reached through a renamed
binding (const a = api; a.createObject(...)) or computed property access
(api["createObject"](...)) is not detected — and will then fail at run time with a
missing-capability throw instead. Call the methods the plain way.
This check runs at three points, all calling the same validateManifestCapabilities():
- GitHub registration / scan — an undeclared capability is a
severity: "error"issue, which sets the mod's scan status toincompatible. An incompatible mod cannot be registered or selected for a room. - Serve time — re-run every time an already-registered mod is loaded into a room.
If the script changed since the last scan and now uses a capability the manifest does
not declare, the mod fails to load with
"Mod script no longer passes sandbox compatibility checks."— even though it wascompatibleat registration. - Local editor draft — the same check runs while you are authoring, before anything is published.
The exact rejection#
Script uses capability "<capability>" but it is not declared in manifest.capabilities.allowed.
with code: "undeclared-capability", severity: "error". For a script that calls
api.createObject(...) while capabilities.allowed is ["log"]:
Script uses capability "spawn-object" but it is not declared in manifest.capabilities.allowed.
Declaring a capability you do not use — not flagged#
This is the single most useful fact on this page. The validator walks only the
capabilities the script uses and checks each is declared; it never walks the declared
list looking for entries the script never calls. A manifest can declare all 12
capabilities against a script that only calls api.log(), and nothing rejects it — no
warning, no error, at any of the three checkpoints above. Least privilege here is a
discipline you keep yourself; the scanner will not enforce it for you.
plugin-call is the one partial exception, and only in one direction: a plugins entry
naming at least one function without plugin-call is rejected as undeclared-capability,
because that manifest contradicts itself. A resource-only entry ("functions": []) names no
call and needs no capability. Declaring plugin-call with no plugins entry and no call
site is still not flagged.
Declaring read-hidden-information you do not use is not flagged either, and it is the
one entry where an idle declaration costs you something real: it is what a reviewer and a
player read as "this mod can see hidden cards". Take it off the list when the call goes.
What a declared capability list actually guarantees#
At run time 10 of the 12 capabilities are enforced in two places, and only one of them counts:
- In the frame. Each gated
apimethod begins withrequireCapability, which throwsMissing mod capability: <capability>synchronously (apps/web/src/mods/sandbox/modSandbox.html). This is the error you will see while developing — and it executes in the same sandboxed JavaScript realm as the mod script, so it is a courtesy, not a boundary: a script that posts a message straight at the host never runs it. - On the host — the authoritative one. Before acting on any gated message from a mod
frame, the host re-validates it against that mod's granted capabilities using the shared
MOD_API_METHOD_CAPABILITIESmap (apps/web/src/mods/SandboxedModRunner.ts, therequireCapabilityhelper inrun). A fire-and-forget message —log,createObject,registerAction,objectAction,playSound,setObjectSound— is dropped and reported as a runtime mod diagnostic readingMissing mod capability: <capability> (blocked <method> from sandbox).A read or write that expects an answer (requestWorld) is answered with aworldResponsecarryingerror: "Missing mod capability: <capability>", which surfaces in the mod as a rejected promise.
That second check covers log, spawn-object, register-action, read-world,
read-hidden-information, object-action, play-sound, saved-data, ui and
plugin-call.
The one capability that is a wall rather than a disclosure#
read-hidden-information is the exception to the framing in this whole section. For the
other eleven, the host check stops a message — it does not, on its own, decide what the mod
could have learned some other way. For this one it decides exactly that: the six
read-world reads are redacted to the least-privileged view on every peer including the
host, so hidden card faces, pile order and secretMetadata are simply not in any answer
a mod can obtain without this slug. Its host-side check runs before the state is served,
using the same MOD_API_METHOD_CAPABILITIES map, so a frame that forges the
requestWorld message is refused rather than served. It is the only grant whose absence
withholds data rather than declaring an intent.
The two capabilities the host cannot re-check#
read-context and subscribe-events gate no message to the host, so there is nothing for
the host to re-validate. getMySeat, getMyTeam, getTurn and on are all answered
inside the frame, from state the host has already delivered — and it delivers that state
unconditionally: updateContext posts a contextUpdate and dispatchEvent posts a
hookEvent to every running mod frame regardless of what that mod declared
(apps/web/src/mods/SandboxedModRunner.ts). A frame granted neither capability still holds
the seat, team and turn context and still receives every hook payload, in its own realm,
reachable by ordinary property access — and self is not one of the five banned script
patterns, which reject document, window, parent, top and opener.
For these two, a declaration is disclosure, not enforcement. Withholding read-context
from a mod does not hide the seat map from it; withholding subscribe-events does not stop
hook payloads reaching its frame. Both remain worth declaring accurately — that is what the
list is for — but do not design a game's hidden information around either one.
What none of this changes is what a capability list means. It is still a scanned
declaration, verified against literal api.<method>( call sites in the script that was
scanned — least-privilege disclosure, and the thing a reviewer or a player can read before
opening a mod's code. It is not a statement about a mod's intentions, and, as the section
above says, a manifest may declare more than the script uses without being flagged.
The boundaries that hold regardless of anything a manifest declares are unchanged and
independent of all of this: the opaque-origin iframe and its CSP, the neutered DOM,
network and storage globals, the five banned script patterns, the ten-item object-action
allowlist, the per-mod namespacing of saved data, and the ownership check on sound
references. A mod cannot reach fetch, localStorage, eval, another mod's saved data,
or an object action outside the ten, whatever it declares.
Reading a manifest's declared capabilities#
mods/example's manifest declares:
"capabilities": {
"version": "1",
"allowed": ["log", "spawn-object", "register-action", "read-world", "object-action", "saved-data", "subscribe-events"]
}
Seven of the twelve. A reviewer reading only this list — before opening the script — can
correctly infer: this mod writes diagnostic log lines, spawns objects, adds a custom
action button, reads the table state as a spectator sees it, dispatches one of the 10
safe object actions, persists small per-mod values, and reacts to lifecycle events. It
does not read seat/team/turn context, does not touch the table UI overlay, does not
play or override sound, and — because read-hidden-information is absent — cannot see
a face-down card, a pile's order or any secretMetadata, on any peer. That absence is
exactly as informative as the presence — a mod with ui
declared is one whose author says it draws its own on-table panel; a mod without it
cannot, because its api.setUiElement call would fail scanning.
What players are told, at the table#
A capability list is not only read by reviewers. Since the room-level disclosure landed, the capabilities of every mod a table runs are shown to everyone at that table — not only to whoever loaded them.
Two independent sources produce that disclosure and the client unions them, so neither can shrink the other:
| Source | Derived from | Cannot be suppressed by |
|---|---|---|
| Registry | room.selectedModIds and the stored manifests, served by GET /api/rooms/:roomId/capabilities |
the host — the joining client fetches it directly, before any peer connection exists |
| Host | the mods whose sandbox frames actually started, published on the replicated room state | the registry — it is the only source that can see a mod loaded outside the room's selection |
What that produces, in the product:
- Before joining. A player who opens a table whose capability set includes
read-hidden-informationgets a blocking prompt naming every mod and its capabilities, and nothing connects until they accept. This is fetched from the server, so a host cannot skip it. - While playing. A persistent indicator — a "recording light" — stays visible for as long as such a mod is loaded. It is platform-owned chrome: no mod UI, panel, overlay or fullscreen mode can cover it, and it has its own world-space surface inside an immersive-VR session, where none of the page's 2D chrome renders at all.
- When it changes. The disclosure is identified by a fingerprint over
(mod id, version, capabilities). If a mod is added, a capability appears, or a version changes under a player who already accepted, they are prompted again. A room that installed a package at1.0.0stays pinned to1.0.0rather than drifting to whatever was published since.
Only read-hidden-information blocks. Every other capability is disclosed in the same
places without a modal — making a log-only mod interrupt a join would train players to
click through the one prompt that matters. Players can re-read the full list at any time
from the player menu.
Two things this does not claim. It is a disclosure layer, not an enforcement one: nothing here narrows what a mod may do, and everything in the sections above still decides that. And a host that runs a mod outside the room's selection and misreports its own live set is not detectable from another peer — the host is the party running the code. What the disclosure closes is the silent case (nobody was told) and the drift case (the set changed after people agreed to it).
For authors, the practical consequence is the one already stated above: an idle
read-hidden-information declaration is not just a reviewer's impression any more. It
turns a blocking prompt on for every player at every table that loads your mod, and lights
a permanent indicator while it runs. Take it off the list when the call goes.
Capability reference#
log#
Gates log(message). The only capability granted by omitting capabilities from the
manifest entirely. Declaring only log is a statement that a mod does nothing observable
at the table — useful while scaffolding, or for a diagnostics-only mod.
spawn-object#
Gates createObject(object). Fire-and-forget — the call returns nothing, not even the
new object's id. A mod that needs to act on what it just spawned also needs read-world
to look the object back up (by a label or tag it chose) after the fact.
register-action#
Gates registerAction(action), which adds a labeled entry to the app UI. It grants no
ability to do anything when that entry is used — whatever it triggers is gated by whatever
other capability that code path uses.
read-context#
Gates getMySeat(), getMyTeam(), getTurn(). The narrowest read capability: only this
peer's own seat, team and turn state, from a locally cached value pushed by the host. It
exposes no roster of other peers.
read-world#
Gates getSnapshot(), getObject(objectId), listObjects(filter?),
getContainerContents(objectId), getHandObjects(seat?) and getZoneObjects(seat, zoneId).
listObjects's tags filter is capped at 32 entries and match is coerced to "any" or
"all" on the frame side and the host side independently.
All six return the least-privileged view, on every peer including the host. They are
passed through the same redactors a spectator with no seat and no team gets, so a
face-down card arrives as label: "Card" with metadata.__redacted === true and no
displayName, metadata.cardId or secretMetadata; a deck or bag reports at most its
single publicly visible face-up first card and never its order; an entity a hidden seat
zone conceals is dropped from lists and resolves null from getObject; and a hand whose
entries are all concealed is dropped rather than returned empty. A mod is granted nothing
extra by the peer it happens to run on, and these six never change behaviour based on what
else the manifest declares — one call site, one meaning. Reading a hidden face needs
read-hidden-information below.
read-world is also what the four zone/trigger hooks need: onZoneEnter,
onZoneLeave, onTriggerEnter and onTriggerLeave are delivered only to a frame granted
it, so subscribing to one without declaring read-world is a severity: "error" at
publish time even though api.on itself is gated by subscribe-events.
read-hidden-information#
Gates getUnredactedSnapshot(), and nothing else. It resolves the host's table state with
no redaction at all: real card faces, a deck's and a bag's ordered metadata.cards, every
seat's hand, secretMetadata on every kind, and the entities a hidden seat zone
conceals. It exists because the six read-world reads above stopped returning any of that.
Four things make it different from every other slug on this page. It is never implied by
read-world and never granted by default. It is re-checked host-side before the
state is served, so forging the underlying postMessage does not get past it. It is
visible to the publish scanner — detectScriptCapabilities matches
api.getUnredactedSnapshot( by name, so calling it without declaring it is an
undeclared-capability rejection, which means "this mod reads hidden information" is a
fact a reviewer can establish from the script rather than only the manifest. And a
capability cannot grant what a peer does not hold: on a player or spectator client
this resolves that client's own received snapshot, which the host redacted at the wire
boundary before broadcasting, so only on the host is it the full table. Write anything
that adjudicates secrets to run on the host.
object-action#
Gates objectAction(objectId, action). action is independently re-checked host-side
against a fixed 10-item allowlist (flip, rotate, lock, unlock, shuffle, draw,
deal, split, combine, roll). That re-check happens regardless of capability
declaration, so tap, untap, delete, lift, flick, press and the reveal-*
actions are unreachable from a mod under any capability. See
Action vocabularies.
saved-data#
Gates getSavedData(scope?), setSavedData(data, scope?). scope.objectId is the only
thing the mod supplies — the host injects the mod's own id before storage, so one mod can
never read or write another mod's saved data regardless of what scope it passes.
subscribe-events#
Gates on(eventName, handler). Unlike every other gated method, this one does not message
the host per call — it only registers a local handler. The host pushes lifecycle events
into the frame independently; declaring subscribe-events is what lets the mod's
api.on(...) registration succeed, not what lets events reach the frame.
ui#
Gates getUiState(), listUiElements(), setUiElement(element),
deleteUiElement(elementId). setUiElement and deleteUiElement scope to the calling
mod's own id, injected host-side — a mod cannot create or delete another mod's UI
elements.
play-sound#
Gates playSound(params), setObjectSound(objectId, action, ref). Semantic-only by
design: a mod names a material × action event, an object id and action pair, or one of its
own declared sounds from manifest.soundSets — never a raw clip id. setObjectSound's
ref is host-validated against the mod's own declared sound names, so a mod cannot set
another mod's sound as an override.
plugin-call#
Gates listPlugins() and callPlugin(pluginId, functionName, params?). It is the only
capability that also requires a second manifest field: plugins, naming each plugin id and
each function on it your script calls. Both targets of every api.callPlugin( must be plain
string literals so the scanner can read them, an undeclared pair is rejected at publish, and
the mod-visible surface deliberately never includes a plugin's endpoints, origins or auth
shape. Full rules on Calling a plugin from a mod.
See also#
- Manifest reference — the
capabilitiesfield itself, its schema and defaults. - The
apireference — full signatures, badges and examples for every method above. - What gets rejected —
undeclared-capabilityalongside the other rejection rules. ModCapability— the 12 slugs in one table.- Calling a plugin from a mod — the
pluginsdeclarationplugin-callrequires. SANDBOX_SAFE_OBJECT_ACTIONS— the 10 action namesobject-actionunlocks.- Scanner rule codes — the codes an undeclared-capability rejection is reported under.
- Known limitations — the maintained list of gaps, including the two capabilities the host cannot re-check.
- Mod hooks and capabilities — which capability each
apimethod and each hook needs.
modCapabilitySchema#
15 values. Declared in manifest.capabilities.allowed. Using one without declaring it is an undeclared-capability rejection.
| Capability |
|---|
log |
spawn-object |
register-action |
read-context |
read-world |
read-hidden-information |
object-action |
saved-data |
subscribe-events |
ui |
play-sound |
plugin-call |
read-cards |
read-decks |
host-message |
Eleven slugs, listed in manifest.capabilities.allowed, each gating a group of api methods. Omit the
block and a mod gets ["log"] and nothing more. The scanner infers what a script uses from literal
api.<method>( call sites, so declaring fewer than you call is an undeclared-capability rejection while
declaring more than you call is not flagged at all — least privilege here is your discipline, not a check.
Nine of the eleven are re-checked host-side, and that is the enforcement that counts. Before acting on a
message from a mod frame the host revalidates it against that mod's granted set through
MOD_API_METHOD_CAPABILITIES (apps/web/src/mods/SandboxedModRunner.ts), covering log, spawn-object,
register-action, read-world, read-hidden-information, object-action, play-sound, saved-data
and ui.
read-hidden-information is the one slug that withholds data rather than declaring an intent. It gates
api.getUnredactedSnapshot() alone. Every read-world read is redacted to the least-privileged view on
every peer, the host included, so hidden card faces, a pile's order and secretMetadata are not in any
answer a mod can obtain without it — running on the host does not help. Its host-side check runs before the
state is served, it is never implied by read-world, it is never granted by default, and
detectScriptCapabilities matches api.getUnredactedSnapshot( by name so an undeclared call is rejected
at publish time.
read-context and subscribe-events send the host nothing to re-check. getMySeat, getMyTeam, getTurn
and on are answered inside the frame from state the host pushes to every running mod — updateContext posts
a contextUpdate, and dispatchEvent posts a hookEvent whatever the mod declared for every hook except the
four narrowed ones below — and that state rests on self.__diceytableCtx and self.__diceytableHooks,
reachable by ordinary property access, with self absent from the five banned script patterns. For those two a
declaration is disclosure rather than enforcement: withholding either hides nothing from the frame. Declare
them accurately anyway, and do not rest a game's hidden information on them.
read-world gates four hook deliveries as well as six pull methods, and this is the one narrowing of its
kind. MOD_HOOK_EVENT_CAPABILITIES maps onZoneEnter, onZoneLeave,
onTriggerEnter and
onTriggerLeave to read-world,
and SandboxedModRunner.dispatchEvent never posts one of those messages into a frame that was not granted it —
so the handler is simply never called, with nothing reported. It is genuinely host-side: requireCapability
inside modSandbox.html runs in the untrusted frame's own realm, whereas this check does not, so a frame
cannot opt itself back into a hook it was not granted. Everything else is fanned out to every running mod.
Those four payloads are sent in full — they carry an objectId (and, for a trigger, an ownerObjectId)
for every entity that crosses, face-down and hidden-zone ones included, and the host-side capability
gate is the only control on them. Note the asymmetry this now creates: since the read-world pull reads
became least-privileged, a hook can name an id that
api.getObject then resolves to null and
api.getZoneObjects omits. The push tells you that something
crossed; it does not tell you what, and the pull will not fill the gap in. The trigger hooks added no new
capability value — neither subscribe-events nor the sandbox was widened for them.
The scanner covers all four on the way in: subscribing with api.on("onZoneEnter" | "onZoneLeave" | "onTriggerEnter" | "onTriggerLeave", …) counts as a use of read-world, so a manifest that omits it is rejected
at publish time with undeclared-capability rather than shipping a handler that silently never runs. That
detection only ever demands a declaration; it never grants one.
See ModCapability.
