Calling a Plugin from a Mod
A mod script has no network access and never will. What the plugin-call capability adds is the
ability to name a declared function on an installed plugin and receive that function's
declared return shape. The request itself happens on our servers, under the plugin's origin
allowlist, quota and circuit breaker.
One line carries the whole distinction, and every tempting simplification erases it:
A mod names a function. It does not describe a request.
There is no api.plugins.fetch(url), no "send this payload" primitive, and no way to reach an
endpoint the plugin's manifest did not declare. A mod that could hand a plugin an arbitrary
payload or an arbitrary destination would turn the plugin's allowlist into a laundering route for
whatever the mod wanted — which is precisely the network the sandbox exists to deny.
plugin-call is not network access#
Do not read the capability that way, and do not describe it that way in your own listing. Every
banned pattern in the script safety scanner still applies
unchanged: a script whose text contains fetch, XMLHttpRequest, WebSocket or EventSource
is rejected at publish, whether or not it declares plugin-call. Declaring a plugin buys exactly
two method names and nothing else.
The chain, and why every link is data#
mod manifest -> plugin id + function name (declared; the scanner reads it out of your source)
plugin manifest -> function -> endpoint (declared; cross-checked when the plugin publishes)
endpoint -> origin + path template (declared; the platform composes the request)
A reviewer can follow that end to end without running anything. That is the property the rules below exist to preserve — each one is there because breaking it would make some link unreadable.
Declaring what you call#
Two manifest changes, and both are required.
{
"capabilities": { "version": "1", "allowed": ["log", "plugin-call"] },
"plugins": [
{ "id": "org.example.cards", "functions": ["searchCards", "getSet"] }
]
}
pluginsis optional. Absent means "declares no plugin use", which permits no call at all.- At most 8 plugins, each naming at most 24 functions.
- You list the functions, not just the plugin. Naming the plugin alone would not be a
declaration worth having: a plugin's exposed surface grows over releases, so
{ id }on its own would silently widen your mod's reach every time somebody else shipped a function. Listing them makes your reach a fixed set that changes only when your mod is re-published and re-scanned.
Declaring plugins without plugin-call in capabilities.allowed is an undeclared-capability
error — a manifest that contradicts itself.
Both targets must be plain string literals#
The publish scanner reads your call sites out of the script text. It counts every
api.callPlugin( and separately reads the ones whose first two arguments are plain string
literals; a call site it could not read is rejected with dynamic-plugin-call.
// Fine — the scanner and a reviewer both see exactly which function this reaches.
await api.callPlugin("org.example.cards", "searchCards", { q: term });
// Rejected: dynamic-plugin-call. Neither target is readable from the source text.
await api.callPlugin(pluginId, fnName, { q: term });
// Also rejected: a template literal carrying an interpolation is a computed target.
await api.callPlugin(`org.example.${which}`, "searchCards", { q: term });
There is no legitimate need for a computed target: you know which functions your mod calls, and
branching between two literal call sites is one extra line. A statically-read pair your manifest
does not declare is undeclared-plugin-call — the manifest is the declaration, and the script
may not exceed it.
The same rules are enforced again at runtime, so an undeclared pair is refused even if a scanner rule were ever weakened. Belt and braces, deliberately.
listPlugins()#
Returns the plugins your manifest declared that are actually installed on this table — your own declaration intersected with reality. Most tables have none, so an empty array is the normal case rather than an error, and a mod that cannot degrade gracefully to no plugin at all is a mod that will not run on most tables.
Each entry carries id, name, version, breakingVersion, attribution, and a functions
list of { name, summary, acceptsTableData }.
Note what is absent: no endpoint, no origin, no path, no auth shape. You learn that a function exists and what it is for, never where it goes. A mod that could read the endpoint mapping could choose a destination indirectly by choosing the function that reaches it, and every future endpoint a plugin added would become a silent grant.
breakingVersion is the plugin's card-model breaking version — see
breakingVersion and migration for what a change to it means.
callPlugin(pluginId, functionName, params?)#
params is Record<string, string | number | boolean>. It is validated against the plugin's
declared parameter schema before anything leaves the machine, and the response is validated
against the plugin's declared return schema before it reaches you. Either failing is
{ ok: false, reason: "refused" }.
The two validations are not symmetric. Your params are checked strictly — passing a key the
function did not declare is a refused, so you cannot smuggle an argument past the declared
surface. The provider's response is checked leniently in one specific way: fields the plugin
did not declare are stripped out rather than causing a failure.
That second rule is what makes data trustworthy. You receive exactly the fields the plugin's
manifest declares and nothing else, whatever the provider actually sent — so a provider that starts
returning an internal id, a debug token or a user flag cannot leak it into your script, and equally
cannot break every call by adding a harmless new field. Declared fields are still fully checked: a
wrong type or a missing non-optional field is still refused.
On success you get { ok: true, data } — two fields, and that is the whole surface. data is typed
unknown, because its real shape is whatever that plugin function declared — narrow it yourself,
and note that it already passed the plugin's declared schema, so it is the plugin's contract rather
than whatever the provider happened to send today.
There is no cache state on the result, on purpose#
The platform caches plugin responses, and the first-party deck UI shows a person when it is displaying cached data. Your script is not told.
The reason is that the cache is shared by every room and every user. A "was this served from cache" flag is therefore not a fact about your call; it is a fact about whether anyone else has recently run the same query. With a stale entry retained for a day, a script that spends one call per card name learns which cards other tables have been looking up — one bit at a time, across rooms, for 24 hours. That is a decklist.
So neither a stale flag nor an age is available here. If you want to avoid re-asking for
something, cache it in your own mod state, which you already control.
The four failure reasons#
{ ok: false, reason } carries one of four words and nothing else:
| Reason | What it means for your mod |
|---|---|
unavailable |
The provider or a server-side step failed. Try later. |
rate-limited |
A quota or the plugin's shared circuit breaker refused it. Try later. |
not-found |
The plugin is not installed, you did not declare it, you did not declare this function, or the plugin does not expose it. |
refused |
Malformed arguments, a schema mismatch, or a function this path will not serve. |
Those four not-found causes deliberately collapse into one answer. If they did not, a mod
could enumerate a plugin's undeclared functions and learn which plugins a table has beyond the
ones it declared.
There is no message, no status code and no upstream detail, and rate-limited is additionally
indistinguishable from unavailable in timing — a script that could time its own refusals
could read the plugin's remaining budget and, because the breaker is shared per origin, infer
other tables' activity. The real cause is written to the mod diagnostics panel, where you can read
it and your script cannot.
Functions that produce card pages are refused#
A plugin endpoint declares returns: "cards" when the platform should run the plugin's declarative
card mapping over the provider's response and hand back normalized cards. A plugin function
pointed at one of those endpoints cannot be called from a mod, and answers
{ ok: false, reason: "refused" }.
Two reasons, and both are structural rather than a limit that might be raised later:
- The declared return schema would be a fiction. For a card endpoint, the shape you get back is
chosen by our normalizer, not by the plugin author. Validating it against the author's
returnsdeclaration would be checking a promise nobody made — and the point ofreturnsis that it is a real contract between the plugin and your mod. - Cards are prefetched, and that is a privacy property. A deck built on a plugin card source pulls its whole card set up front, so the pattern of requests says nothing about which cards are in play. A per-call card lookup driven by script code would put that pattern back.
Build a deck from a plugin card source instead. callPlugin is for the plugin's other declared
functions — the lookups, the validations, the set metadata.
Unlike unavailable, this refused is permanent: retrying will never help, and the plugin's author
should be told their function is unreachable from mods.
Functions that take table data are refused#
A plugin function declaring acceptsTableData is the combined capability's table-to-provider
channel. It carries a platform-owned warning and a consent flow aimed at the fact that the person
who accepts is not the person at risk. Calling one through the mod API is refused,
unconditionally — routing it here would hand a host-side mod exactly the exfiltration path that
disclosure exists to gate.
acceptsTableData is surfaced on every listPlugins() entry precisely so you can filter those
functions out and degrade gracefully rather than discovering the refusal at runtime.
That refusal closes the declared channel, not every channel. The parameter values you pass to
an ordinary function are still yours to choose, so a mod that also holds
read-hidden-information can move hidden state to a provider through a plain search parameter.
DiceyTable does not try to detect that at the call — it cannot tell a card name from an encoded
hand — so it does two other things instead, and both are visible to your players:
- Holding
read-hidden-informationandplugin-callat a table that runs anynetworkplugin elevates that plugin tocombinedin the room's capability disclosure. Every seated player is shown the verbatim off-table warning and must accept it before joining. Seenetworkalone is not a promise. - Every call is recorded server-side with its payload size in bytes (never its contents), so the volume of such a channel is measurable.
If your mod does not genuinely need to read hidden information, leave
read-hidden-information out of capabilities.allowed; it is the declaration that turns an
ordinary plugin call into the strongest warning the platform has.
Example#
The full loop: declare, discover, call, degrade.
// content/scripting-api/examples/concepts.calling-a-plugin.js
// Mod script: declare one plugin, call one of its declared functions, and keep
// working on a table where no plugin is installed at all. Both targets are plain
// string literals, because that is what the publish scanner reads out of the source.
//
// manifest.plugins: [{ "id": "org.example.cards", "functions": ["searchCards"] }]
// manifest capabilities.allowed: ["log", "plugin-call"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
// Your own declaration intersected with what this table actually has. Most
// tables have nothing, so an empty list is the normal case, not an error.
const installed = await api.listPlugins();
const provider = installed.find((plugin) => plugin.id === "org.example.cards");
if (!provider) {
api.log(manifest.name + ": card provider not installed - using the built-in card list.");
return;
}
// Show this wherever the plugin's data appears. Its author accepted the
// provider's terms on their own behalf, and attribution is usually one of them.
api.log(manifest.name + ": card data by " + provider.attribution);
const result = await api.callPlugin("org.example.cards", "searchCards", { q: "goblin", page: 1 });
if (!result.ok) {
// Four reasons and nothing else, deliberately. "unavailable" and "rate-limited"
// both mean try later; "not-found" and "refused" mean fix the mod or its manifest.
api.log(manifest.name + ": card search failed (" + result.reason + ").");
return;
}
// There is no cache state on this result - no "stale" flag and no age. The platform
// does cache plugin responses, but that cache is shared by every room and every
// user, so telling a script whether a query was cached would tell it whether anyone
// else had recently run the same one. Show freshness to the PERSON, not the script.
// `data` already matched the plugin function's DECLARED return schema, so its
// shape is the plugin's contract rather than whatever the provider sent today.
api.log(manifest.name + ": search returned " + JSON.stringify(result.data).length + " bytes.");
};
Attribution is your obligation too#
ModPluginSummary.attribution is not decoration. The plugin's author accepted the provider's
terms on their own behalf, and crediting the provider is usually one of those terms — so display
it wherever you show that plugin's data. The platform renders it on the surfaces it owns; on the
ones your mod draws, that is yours.
See also#
- The mod
apiobject — the full method list, includinglistPluginsandcallPlugin. - Mod capabilities — where
plugin-callsits among the twelve. - Manifest reference — the
pluginsfield in context. - What gets rejected — including
dynamic-plugin-callandundeclared-plugin-call. - Writing a plugin — the other side of
exposedApi. breakingVersionand migration — what a plugin's version numbers mean.
