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.
The three surfaces
Section titled “The three surfaces”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:
ui.jscallswindow.parley.act("add-card", { text: "..." }).- 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. - Parley calls your guest’s
on_session_action. The guest saves what changed, usually withparley_kv_set. - Parley calls
on_session_stateand broadcasts the result to everyone in the room. - Every
ui.jsin the room gets the new state throughwindow.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.
on_session_state
Section titled “on_session_state”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.
on_session_action
Section titled “on_session_action”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
502and 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_eventoron_jobcounts 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.
on_event
Section titled “on_event”Input: { "topic": "plugin.other.card-added", "payload": {} }. payload is
{} when the publisher sent none. Output is ignored.
on_job
Section titled “on_job”Input: { "kind": "sync", "payload": {} }, the kind and payload you passed
to parley_job_enqueue. Output is ignored.
Delivery for on_event and on_job
Section titled “Delivery for on_event and on_job”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.
Host functions
Section titled “Host functions”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.
Summary
Section titled “Summary”| 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.
parley_kv_get and parley_kv_set
Section titled “parley_kv_get and parley_kv_set”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
quotaBytesin the manifest (default 1 MiB). A write past the quota is refused withplugin storage quota exceededand changes nothing.
parley_settings_get
Section titled “parley_settings_get”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.
parley_secret_get
Section titled “parley_secret_get”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.
parley_session_get
Section titled “parley_session_get”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.
parley_session_patch
Section titled “parley_session_patch”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.
parley_fetch
Section titled “parley_fetch”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.
parley_job_enqueue
Section titled “parley_job_enqueue”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.
parley_emit
Section titled “parley_emit”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" }.
parley_log
Section titled “parley_log”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.
Using @parley/plugin-sdk in a guest
Section titled “Using @parley/plugin-sdk in a guest”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 bridge
Section titled “UI bridge”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();The UI needs grants too
Section titled “The UI needs grants too”| 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.
The session object
Section titled “The session object”| 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
toolbarframe in a planning poker room or anavframe. 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 isunknown. - Your manifest asks for
session:actwithoutsession: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.
actresolvesunknownat 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
facilitatorOnlyaction from anybody else. actsendsPOST. Declare your actions with"verb": "POST"; one declaredPUT,PATCHorDELETEis refused when called throughact.- Do not name an action
standup,ready,story,configormention. Those names are sent with a core kind’s verb and aPOSTaction by that name is refused. actneeds no click. A UI holdingsession:actcan 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.actthrows 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.
Theme and styling
Section titled “Theme and styling”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.
What the frame cannot do
Section titled “What the frame cannot do”- No network requests: the frame’s Content-Security-Policy refuses
fetch, WebSocket, remote images, fonts and external scripts. Adata: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
Section titled “Manifest”{ "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:
kindand each actionnameare 1 to 64 characters ofa-z,0-9and dashes, starting and ending with a letter or digit.displayis 1 to 64 characters.verbisPOST,PUT,PATCHorDELETE.facilitatorOnlydefaults tofalse.- A kind name is unique across the whole instance. Installing a plugin whose kind another org or the core already owns is refused.
Capabilities and scopes
Section titled “Capabilities and scopes”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 |
Handling secrets and personal data
Section titled “Handling secrets and personal data”on_session_stateoutput goes to everyone in the room. Never put a secret or one person’s private data in it.- Keep secrets out of
parley_logmessages andparley_emitpayloads. 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
fetchgrant can send that secret to the allowed host. Send it only where it is needed. - Treat an action’s
bodyas 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.
Limits
Section titled “Limits”| 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.