Skip to content

Plugin API reference

Verified against v0.21.0 · sdk/abi/v1.json, internal/plugin/hostfn.go, internal/plugin/kinds.go, internal/plugin/host.go, internal/plugin/fetch.go, internal/plugin/bus.go, internal/plugin/outbox.go, internal/plugin/grants.go, internal/api/pluginsessions.go, internal/api/dispatch.go, internal/api/pluginframe.go, internal/api/pluginframe_bootstrap.js, internal/api/plugins.go, internal/session/registry.go, web/src/lib/pluginBridge.ts, web/src/lib/api.ts, web/src/components/PluginPanel.tsx, web/src/lib/theme.ts, sdk/plugin-sdk/src/host.js, sdk/plugin-ui/parley.css

This page is the lookup table for plugin authors: what Parley hands your code, what your code may ask of Parley, and the exact shape of each. For a guided first run, start with Build your first plugin. For why each boundary exists, see Plugin sandbox.

A plugin has two halves that never talk to each other directly.

Surface Where it runs What it is for
Hooks Server, in WebAssembly Parley calls your guest: build room state, handle an action, receive an event, run a job
Host functions Server, in WebAssembly Your guest calls Parley: storage, settings, secrets, fetch, sessions, jobs, events, logging
UI bridge Browser, in a sandboxed iframe Your ui.js receives room state and proposes actions through window.parley

The round trip for one action:

  1. ui.js calls window.parley.act("add-card", { text: "..." }).
  2. Parley sends that as the signed-in user to /api/sessions/{id}/actions/add-card, and authorizes it as it would the user’s own click.
  3. Parley calls your guest’s on_session_action. The guest saves what changed, usually with parley_kv_set.
  4. Parley calls on_session_state and broadcasts the result to everyone in the room.
  5. Every ui.js in the room gets the new state through window.parley.onState.

The UI has no other path to the guest.

A hook is a function your guest exports. Parley writes one JSON document as the call’s input, and reads whatever the guest writes as output. In the JavaScript PDK that is Host.inputString() and Host.outputString().

Export Called when Mode May fetch
on_session_state A room of your kind needs its state: on load and after every change Synchronous No
on_session_action A participant runs one of your kind’s actions Synchronous No
on_event An event you hold an events grant for was published Asynchronous Yes
on_job A job you enqueued is due Asynchronous Yes

Export only the hooks you use. A plugin that provides a session kind needs the first two.

Input:

{
"session": "c0a8012e-...",
"kind": "retrospective",
"phase": "collect",
"config": {},
"spaceKey": "9c41e0...b27f"
}
Field Type Meaning
session string The room’s id. Use it as your storage key
kind string Which of your kinds the room runs
phase string The room’s shared phase. Yours to define; set it with parley_session_patch
config JSON The config document the room was created with, passed through unchanged
spaceKey string An opaque key for the room’s space: the same for every room in one space, different in every other space and for every other install of a plugin. It never reveals the space’s id, slug or org. Use it to keep data per space

Output: any JSON document. It becomes state in the room envelope and is sent to every participant, so write only what all of them may see. Hide a card’s author until reveal by leaving the field out, as plugins/retrospective does. An empty output is read as {}. Output that is not JSON fails the call.

There is no user in this input: one state document is built and broadcast to the whole room, so it cannot differ per person.

Input:

{
"session": "c0a8012e-...",
"kind": "retrospective",
"action": "add-card",
"user": "5f2b9d0c-...",
"body": { "columnId": "went-well", "text": "Shipped on time" },
"spaceKey": "9c41e0...b27f"
}
Field Type Meaning
session string The room’s id
kind string Which of your kinds the room runs
action string The action name, one your manifest declares
user string The id of the person who acted. It matches userId in the roster
body JSON The request body; {} when the caller sent none. At most 1 MiB
spaceKey string The room’s space key, as in on_session_state. It is not sent to your panel frame

By the time the hook runs, Parley has already refused an unknown action (404), the wrong verb (405), a facilitatorOnly action from somebody else (403), and any action in an ended room (409). Validating body is your job.

Output is how the guest accepts or refuses the action. Refusing is not failing: validating body and saying no is the plugin working.

Guest outcome HTTP answer Then
Returns with no output, {}, or any output that has no refused key 204 No Content The room’s state is rebuilt and broadcast
Returns {"refused":"invalid"} 400 Nothing is broadcast, and nothing counts against the plugin
Returns {"refused":"forbidden"} 403 The same
Returns {"refused":"not-found"} 404 The same
Returns {"refused":"conflict"} 409 The same
Not reached: the body is over 1 MiB or is not JSON 400 with {"error":"invalid JSON body"} The hook is not called
Throws, or otherwise returns an error 502 with {"error":"the plugin could not run that action"} Nothing is broadcast. The failure is logged on the server and does not count against the plugin
Traps, times out, runs out of memory, or returns a refused that is not one of the four codes 502 with the same body Nothing is broadcast. The failure is logged and counts toward a cooldown, never toward switching the plugin off
Not reached: the room stayed busy with other actions for twice the call timeout 409 with {"error":"the room is busy with another action, try again"} The hook is not called
Not reached: the plugin had as many calls running as it is allowed, for twice the call timeout 503 with {"error":"the plugin is at capacity, try again"} The hook is not called
Not reached: the room’s lock could not be taken for another reason, such as the database being unreachable 503 with {"error":"could not run that action"} The hook is not called
function on_session_action() {
var input = JSON.parse(Host.inputString() || "{}");
if (!input.body || !input.body.text) {
Host.outputString(JSON.stringify({ refused: "invalid" }));
return;
}
// load, change, save
Host.outputString("{}");
}

Refuse with a code; do not throw. The caller’s parley.act promise resolves with the same code as its reason. The error text of a refusal is Parley’s own, one fixed sentence for each code, so nothing your guest writes reaches the response. Put anything the room should read into the state instead. Refuse before you save: Parley does not undo what a refusing call already wrote.

The refused key is matched exactly, in lower case.

What a failed action costs the plugin depends on how it failed, because anybody in a room can send one:

  • A throw, or any error the guest returns after running to its end, costs nothing, provided the call used no more than half its timeout. It answers 502 and is logged. Refusing is still better: the caller learns why. A reported error from a call that used more than half its timeout counts like a stopped one.
  • A call Parley had to stop (a trap, a timeout, the memory cap) counts. Five in a row put the install in a cooldown, during which every call to it is refused, for every room in the org. That is as far as failed actions go. They are counted apart from other failures and never switch the install off.
  • A failure in on_session_state, on_event or on_job counts as it always has, whatever kind it was, and repeated cooldowns from those do switch the install off.

A refusal is a successful call and clears both counts.

Actions on one room run one at a time, on every replica: your hook can load a document, change it and save it without another action on the same room running in between. Actions for one room wait in arrival order on the replica that received them; across replicas the order is whichever takes the lock first. An action that has waited twice the call timeout is answered 409. That covers on_session_action only. An on_event or on_job handler is not held behind a room’s actions, so do not have one rewrite a document an action also rewrites.

A room never has more than one action running, so one busy room uses one of the plugin’s concurrent calls, and other rooms are not held up behind it. Rooms do share the plugin’s allowance: with the default of two concurrent calls for each plugin, a third room acting at the same instant waits for one of the two to finish. Waiting actions are served oldest first, and waiting holds no database connection. All of an action’s waiting, for its room, for a call slot and for the lock, ends at one deadline of twice the call timeout; an action still waiting for a slot then is answered 503, which parley.act reports as busy: nothing was done, and the same action may succeed a moment later. A state build waits for a slot in the same line, for up to twice the call timeout, so the broadcast after an action and a page load are not refused merely because actions are queued. on_event and on_job are refused at once when the plugin is at its allowance, as before, and are retried by their queues.

One action costs two guest calls on the replica that received it, whoever is watching: on_session_action, then one on_session_state whose result is sent to every connected client. Viewers do not multiply it. A room’s broadcasts are built and sent one at a time, so the last frame a client gets is the newest state, and broadcasts that pile up behind one another share a state build, so a burst of actions costs fewer state builds than actions. Every other replica builds the state once more for its own clients.

Input: { "topic": "plugin.other.card-added", "payload": {} }. payload is {} when the publisher sent none. Output is ignored.

Input: { "kind": "sync", "payload": {} }, the kind and payload you passed to parley_job_enqueue. Output is ignored.

Both are at-least-once, so a handler must be safe to run twice. A call that fails is retried with a delay that starts at 5 seconds and doubles, capped at 10 minutes. After 8 attempts it is set aside and not retried again.

A guest calls Parley by passing one JSON request and reading one JSON response. Every response is the same envelope:

{ "ok": true, "data": { "found": true, "value": "eyJjb2x1bW5zIjpbXX0=" } }
{ "ok": false, "error": "kv in scope \"board\": the plugin does not hold this capability" }

A refusal is data, not a trap: check ok and decide what to do. The examples wrap this once:

function hostCall(fn, req) {
var mem = Memory.fromString(JSON.stringify(req));
var env = JSON.parse(Memory.find(fn(mem.offset)).readString());
if (!env.ok) throw new Error(env.error || "host refused");
return env.data;
}
var fns = Host.getFunctions();
var data = hostCall(fns.parley_kv_get, { scope: "board", key: input.session });

A JavaScript guest must also declare each host function it calls in guest.d.ts, or the build does not link it:

declare module "extism:host" {
interface user {
parley_kv_get(ptr: I64): I64;
parley_kv_set(ptr: I64): I64;
}
}

Bytes are base64. value in the two kv functions and body in fetch are byte fields. Send them as base64 strings and decode what comes back. plugins/retrospective/guest.js has utf8ToB64 and b64ToUtf8 helpers you can copy.

Function Grant Request data on success
parley_kv_get kv scope, key found, value
parley_kv_set kv scope, key, value written
parley_settings_get none none the settings object
parley_secret_get secrets, or a secret settings field name value
parley_session_get session:read session state
parley_session_patch session:patch session, patch applied
parley_fetch fetch method, url, headers, body status, headers, body
parley_job_enqueue jobs kind, payload, delay_ms, cron job_id
parley_emit emit topic, payload topic
parley_log log level, message logged

Grants are read from the install record on every call, so a grant an admin revokes stops working on the next call.

Your plugin’s private storage. Another plugin cannot read it, and neither can another org’s install of the same plugin.

Request field Type Notes
scope string Must match a granted kv scope, unless the grant is unscoped
key string Required. scope and key together are at most 512 bytes
value base64 string parley_kv_set only. Replaces the whole value

parley_kv_get returns { "found": true, "value": "<base64>" }, or { "found": false, "value": null } for a key that was never written. parley_kv_set returns { "written": <bytes> }.

  • There is no list, prefix scan or delete. Keep one document per room, keyed by the session id.
  • A write replaces the whole value. Two actions racing on one document can lose an update; keep each action’s read-modify-write short.
  • Storage is capped by quotaBytes in the manifest (default 1 MiB). A write past the quota is refused with plugin storage quota exceeded and changes nothing.

Takes {}. Returns the admin-set, non-secret settings as one object with defaults applied, for example { "greeting": "Hello", "loud": false }. It needs no grant. Secret fields are never included.

Request { "name": "api_token" }, response { "value": "..." } as a plain string. A settings field marked "format": "secret" is readable by its field name with no separate grant; any other name needs a secrets grant. A secret no admin has set yet is a refusal, not an empty string.

Request { "session": "<id>" }. Returns { "state": <envelope> }, the same document a browser in that room is sent:

Envelope field Type Meaning
id, kind, title string The room
phase string The shared phase
revealed boolean The shared reveal flag
version number Rises by one on every change
facilitatorId string The facilitator’s user id
facilitatorConnected boolean Whether the facilitator is connected
endedAt timestamp or null Set once the room has ended
presence string array User ids connected now
participants array userId, name, avatarHue, spectator, guest, avatarIcon for each seat
spaceSlug, orgSlug string Where the room lives
facilitatorOfflineSince timestamp Present only while the facilitator is disconnected
plugin object name, version and grants of the install providing the kind
kindUnavailable boolean Present and true when the kind is not currently on offer; state is then null
serverTime timestamp When the envelope was built
state JSON Your own on_session_state output

It reads only rooms in the install’s own org that run a kind this plugin provides. Any other id, including one that exists, answers no such session. Do not call it from on_session_state for the room being built; the input already carries what you need.

Request { "session": "<id>", "patch": { "phase": "vote", "revealed": true } }. Returns { "applied": true } and broadcasts the room.

Patch field Type Notes
phase string 1 to 64 characters. Your own vocabulary
revealed boolean The shared reveal flag

Both are optional. Any other field is refused, as is a patch to an ended room. The same own-org, own-kind rule as parley_session_get applies.

Outbound HTTP, available only inside on_event and on_job. Called from on_session_state or on_session_action it is refused whatever was granted, because those run while the room waits. To show remote data in a room, fetch it in a job, store it with parley_kv_set, and read it back in the hook.

Request field Type Notes
method string For example GET or POST
url string https only. The host must match a granted fetch scope
headers object Optional; string to string
body base64 string Optional

Response { "status": 200, "headers": { ... }, "body": "<base64>" }.

Each fetch grant’s scope is one hostname, optionally with a single leading *. label. Defaults: 10 second timeout, at most 5 redirects with every hop screened, and a 1 MiB response body. Only public addresses are reachable: private, loopback, link-local and every other non-public address is refused. The fetch guard has the full rules.

Schedules a later call to your on_job.

Request field Type Notes
kind string Your own label. Must match a granted jobs scope, unless the grant is unscoped
payload JSON Optional; handed back to on_job
delay_ms number Optional; run this long from now
cron string Optional; five numeric fields, minute hour day-of-month month day-of-week, read in UTC

Returns { "job_id": "..." }. With neither delay_ms nor cron the job is due immediately. Setting both is refused. A cron schedules one run at the next matching time; to repeat, enqueue the next run from inside on_job.

Request { "topic": "card-added", "payload": { ... } }. The topic is published as plugin.<your plugin name>.<topic>, and the response returns that full name: { "topic": "plugin.retrospective.card-added" }.

To receive an event, a plugin holds an events grant whose scope is the full published topic, for example { "capability": "events", "scope": "plugin.retrospective.card-added" }.

Request { "level": "info", "message": "..." }. level is debug, info, warn or error; anything else is logged as info. Returns { "logged": true }. The line appears in the server log as plugin log with your plugin’s name and your message as an attribute.

createHost(grants, call, manifest) wraps the host functions as methods and fails fast, inside the guest, when the grant a call needs is missing from grants. The host still checks every call.

Method Calls
getSettings() parley_settings_get
getSecret(name), secretGet(req) parley_secret_get
kvGet(req), kvSet(req) parley_kv_get, parley_kv_set
sessionGet(req), sessionPatch(req) parley_session_get, parley_session_patch
fetch(req) parley_fetch
jobEnqueue(req) parley_job_enqueue
emit(req) parley_emit
log(req) parley_log

Each takes the request documented above and returns whatever your call function returns. The Extism JavaScript guest has no module loader, so the SDK is not imported: list sdk/plugin-sdk/src/host.js first in your Makefile’s GUEST_SRC and it is concatenated in.

createHost needs the manifest as a value, and nothing defines one for you. plugins/settings-demo generates it in its Makefile and lists the result in GUEST_SRC after host.js:

GUEST_SRC := ../../sdk/plugin-sdk/src/host.js .build/manifest.js demo.js guest.js
.build/manifest.js: manifest.json
mkdir -p .build
{ printf 'var MANIFEST = '; cat manifest.json; printf ';\n'; } > $@

With that in place the guest can build a host. The manifest lists what the plugin asks for, not what an admin granted, so the guest-side check only catches a call the manifest never declared; the host decides the rest.

var fns = Host.getFunctions();
var host = createHost(MANIFEST.capabilities, function (name, req) {
return hostCall(fns[name], req);
}, MANIFEST);
var settings = host.getSettings();

ui.js runs in an iframe with an opaque origin, under a Content-Security-Policy that refuses network requests. Parley builds the document, which contains one empty <div id="root"></div>, and defines window.parley before your script runs.

Method Purpose
parley.onState(fn) Calls fn(session) on every state push, and immediately if state has already arrived
parley.state() The latest session, or null
parley.act(name, payload) Proposes one of your kind’s actions. payload becomes the hook’s body. Returns a promise of the result
parley.onTokens(fn) Calls fn(tokens) when the color tokens arrive and again whenever the viewer’s theme changes, and immediately if they already have
parley.scheme() "light", "dark", or null before the first tokens arrive
parley.supports("results") true once connected to a host that reports action results
parley.ready() Announces that the UI has loaded. The host does not wait for it
var root = document.getElementById("root");
window.parley.onState(function (session) {
if (!session) { root.textContent = ""; return; }
root.textContent = session.title + ": " + session.state.cards.length + " cards";
});
document.addEventListener("click", function () {
window.parley.act("add-card", { columnId: "went-well", text: "Shipped" });
});
window.parley.ready();
To do this The manifest must ask for
Receive state through onState session:read
Propose actions through act session:act

Without session:read, onState is never called. Without session:act, a call to act is not performed and its result is ungranted.

Field Type Meaning
id, kind, title string The room
phase string The shared phase
revealed boolean The shared reveal flag
version number Rises by one on every change
facilitatorId string Compare with a participant’s userId
selfId string or null The viewer’s own user id. Compare with a participant’s userId, or with facilitatorId to show facilitator controls
endedAt string or null Set once the room has ended
presence string array User ids connected now
participants array userId, name, avatarHue, spectator for each seat
state JSON Your on_session_state output

selfId is the id of whoever is looking at this frame, in this browser. A signed-link guest, a spectator and a viewer inside a meeting client each hold a seat and get their own id like anybody else. It is null only when the host does not know it, and absent on a Parley older than the release that added it, so treat a missing selfId as “unknown” rather than as nobody. It is for presentation: the server decides who may do what, whatever the UI shows.

The frame is not told the room’s space or org. onState receives null when the viewer moves to a room your plugin does not provide, which matters for toolbar and export-menu frames that stay mounted; treat null as “no room”.

act returns a promise that never rejects. Once the frame is connected to the host it always settles; a call made before the connection waits for it, and is answered when it arrives.

window.parley.act("add-card", { text: "Shipped" }).then(function (result) {
if (!result.ok) { showError(result.reason); }
});
Result Meaning
{ ok: true } The action was accepted. The new state arrives through onState as usual
{ ok: false, reason: "forbidden" } The viewer may not do that, for example a facilitatorOnly action from anybody else, or your guest refused it as forbidden. A viewer whose sign-in has lapsed (401) is reported the same way
{ ok: false, reason: "invalid" } The server refused the request as malformed, or your guest refused it as invalid
{ ok: false, reason: "not-found" } The room’s kind has no such action, the room is gone, or your guest refused it as not-found
{ ok: false, reason: "conflict" } The request conflicts with the room as it stands: it has ended, it stayed busy with other actions, or your guest refused it as conflict
{ ok: false, reason: "rate-limited" } Too many requests
{ ok: false, reason: "ungranted" } The install holds no session:act grant
{ ok: false, reason: "unreachable" } The request never reached the server
{ ok: false, reason: "busy" } The server could not take the request right now (503), for example the plugin stayed at its call capacity. Nothing was done; an idempotent action can be retried, and otherwise tell the person to try again. A host older than this reason reports the same case as failed
{ ok: false, reason: "failed" } Anything else the server answered, including a guest that threw (502)
{ ok: false, reason: "unknown" } The outcome is not available to this frame. The action may have been performed

The reason is a code from this list and nothing more: the server’s own error text and the response body stay in the host. Treat a code you do not recognize as failed.

unknown means “no answer for you”, not “refused”. It is the result when:

  • The frame is in a room your plugin does not provide, or in no room at all, such as a toolbar frame in a planning poker room or a nav frame. The action is sent as before, but what the server said about another kind’s room is not yours to read, so every outcome there is unknown.
  • Your manifest asks for session:act without session:read. Outcomes are room information, so they are reported only where state is.
  • The host does not report results: a web bundle older than this feature. act resolves unknown at once rather than waiting.
  • More than 64 calls are unanswered; the oldest resolves unknown.
  • The host closed the bridge, for example after a message flood. Everything outstanding resolves unknown, and so does every later call.

ungranted is reported in any room, because it describes your own manifest.

To tell a host that reports results from one that does not:

if (window.parley.supports && window.parley.supports("results")) { /* ... */ }
var pending = window.parley.act("reveal");
if (pending && pending.then) { pending.then(onResult); }

supports is false until the frame is connected. On a server older than this feature supports does not exist and act returns undefined, hence both checks. A successful action still shows up as a new state push, so keep rendering from the state.

  • The name must be 1 to 64 characters of letters, digits, _ and -. Nothing else about the name is checked in the browser.
  • The request goes to the room the viewer has open, as that viewer, and the server decides: an action that room’s kind does not have is refused, and so is a facilitatorOnly action from anybody else.
  • act sends POST. Declare your actions with "verb": "POST"; one declared PUT, PATCH or DELETE is refused when called through act.
  • Do not name an action standup, ready, story, config or mention. Those names are sent with a core kind’s verb and a POST action by that name is refused.
  • act needs no click. A UI holding session:act can call it at any moment, and it runs with the viewer’s authority, including the facilitator’s controls when the viewer is the facilitator. That is what the admin is told at install, so call it only in response to something the person did.
  • act throws when the serialized message is longer than 65,536 characters, as it always has; nothing is sent and no promise is returned. The host separately caps a message at 64 KiB of UTF-8 and drops one over it without telling the frame, so non-ASCII text can be dropped without a throw. Keep payloads well under 64 KiB.

Parley sets its color tokens as CSS custom properties on the frame’s root element when the frame loads, and again whenever the viewer changes theme, so var(--color-surface) works with no JavaScript and follows the app without a reload. It also sets the root’s color-scheme to light or dark to match, so native controls and scrollbars are drawn for the theme in use. The tokens are felt, felt-deep, surface, surface-hi, ink, ink-soft, ink-faint, line, line-strong, accent, accent-ink, accent-soft, brass, settled, go and stop, each as --color-<name>.

sdk/plugin-ui/parley.css offers three classes built on them: parley-panel, parley-btn and parley-input. The frame cannot load a stylesheet, so create a <style> element from your script, or set styles inline.

  • No network requests: the frame’s Content-Security-Policy refuses fetch, WebSocket, remote images, fonts and external scripts. A data: image works.
  • No cookies, no storage shared with Parley, and no reading of the parent page.
  • Messages are capped at 64 KiB each way and 30 a second from the frame. Exceeding the rate closes the bridge.
  • State pushes are coalesced to one every 100 ms, newest wins.
{
"manifest": 1,
"kind": "plugin",
"name": "retrospective",
"version": "0.1.0",
"quotaBytes": 1048576,
"capabilities": [
{ "capability": "kv", "scope": "board" },
{ "capability": "session:read" },
{ "capability": "session:act" }
],
"kinds": [
{
"kind": "retrospective",
"display": "Retrospective",
"actions": [
{ "name": "add-card", "verb": "POST" },
{ "name": "reveal", "verb": "POST", "facilitatorOnly": true }
]
}
],
"slots": ["room"]
}
Field Required Notes
manifest Yes Always 1
kind Yes Always "plugin"
name Yes The plugin’s name; it prefixes emitted topics
version Yes major.minor.patch. Every upload needs a new one
quotaBytes No Key-value storage cap. Default 1048576
capabilities No What the plugin asks for. Each entry is capability plus optional scope. At most 64 entries; a scope is at most 253 characters
kinds No The session kinds the plugin provides
slots No Where the UI appears: panel, room, toolbar, nav, export-menu
settings No The admin settings schema; see Settings

Rules for kinds:

  • kind and each action name are 1 to 64 characters of a-z, 0-9 and dashes, starting and ending with a letter or digit.
  • display is 1 to 64 characters.
  • verb is POST, PUT, PATCH or DELETE.
  • facilitatorOnly defaults to false.
  • A kind name is unique across the whole instance. Installing a plugin whose kind another org or the core already owns is refused.

A grant with no scope covers the whole capability. A grant with a scope covers only a request that names exactly that scope. fetch and events are the exceptions: both need a scope, and a grant without one allows nothing.

Capability Unlocks Scope means
kv parley_kv_get, parley_kv_set The scope field of the request
secrets parley_secret_get The secret’s name
session:read parley_session_get; state in the UI One session id, for parley_session_get. The UI is sent state whatever the scope
session:patch parley_session_patch One session id
session:act parley.act in the UI Not used
fetch parley_fetch One allowed hostname; required
jobs parley_job_enqueue The job kind
emit parley_emit The topic, before the prefix is added
events Delivery to on_event The full published topic; required
log parley_log Not used
  • on_session_state output goes to everyone in the room. Never put a secret or one person’s private data in it.
  • Keep secrets out of parley_log messages and parley_emit payloads. A log line is read by operators, and an event is delivered to every plugin that holds a grant for its topic.
  • A plugin that holds both a secret and a fetch grant can send that secret to the allowed host. Send it only where it is needed.
  • Treat an action’s body as untrusted input from a participant.

Ask only for what you use: every grant is shown to the admin who installs the plugin, in the words listed under What each grant actually permits.

Limit Default
Time for one hook call 2 seconds
Guest memory 16 MiB
Concurrent calls per install 2
Action body 1 MiB
Key-value quota 1 MiB, or quotaBytes
Key-value scope plus key 512 bytes
Phase length 64 characters
Fetch timeout, redirects, response body 10 seconds, 5, 1 MiB
Event and job attempts 8
UI message size, each way 64 KiB
UI messages from the frame 30 a second

A guest has no filesystem, no sockets and no SQL. An install that keeps failing is switched off by a circuit breaker until an admin re-enables it; see Containment.