Appearance
Permissions
Tiers do not grant permissions
A plugin's permissions come from declared_permissions in its manifest, and nothing else. The manifest tier (1, 2 or 3; any other value is stored as 1) is a label — Passive, Interactive or Activity — shown in the marketplace, server settings and the consent card. It changes no behaviour.
In channel and sidebar placements, a plugin that declares any permission shows a consent card before it loads, whatever its tier; a plugin that declares none shows no card. The card lists declared_permissions, and Join grants exactly that list; the node refuses to grant anything the plugin did not declare. View Without Joining grants nothing and loads the plugin anyway, so every permissioned call is refused.
Changed 2026-09-13
The card used to be shown only for tier 2 and 3, so a tier 1 plugin that declared permissions was never asked and could not use them. It now follows declared_permissions.
What a call may use is the intersection of what the user granted and what the installed plugin still declares. The consent card comes back when the plugin declares a permission the user has not granted — for example after an update adds one. Dropping a permission from the manifest takes it away from everyone at once.
Profile plugins have their own lane: only storage:local can be used there, and the card offers nothing else. See Profile Plugins.
Personal plugins do not use the card: the user approves declared_permissions when adding the plugin on My plugins. See Personal Plugins.
Every permission
The node accepts these strings and no others. The catalog also records a tier for each permission, but nothing reads it — it does not restrict which tier may declare a permission.
| Permission | Catalog tier | Consent card label (abridged) | SDK |
|---|---|---|---|
profile:read | 1 | Read your display name and avatar | getUser() |
identity | 1 | Access your stable user ID to link records across sessions | ctx.user.id |
storage:user | 1 | Store private data on your account | storage.* with scope user |
storage:server | 1 | Read and write shared server data | storage.* with scope server |
storage:local | 1 | Store data on this device only | storageLocal.* |
storage:log | 1 | Read and add to this channel's shared plugin history, recording who did what | — (log:append, log:read) |
members:read | 1 | View the list of server members | — (members:list) |
overlay:context | 1 | Know which game the overlay is showing over | overlay.* |
net:direct | 2 | Connect directly to approved websites from your computer (they see your IP address) | net.fetch() — desktop only |
friends:link | 2 | Connect with Dissent friends who use this plugin — by invite, or automatically when you play together (matched by your connected Steam accounts) | friends.list(), links(), invite(), respond(), remove(), match(), setAuto() — personal installs |
fetch:external | 2 | Fetch data from external websites | fetch() |
bot:post | 2 | Post messages as the plugin bot | postMessage() |
realtime | 2 | Send and receive live events with other users in this channel | on() to receive; realtime:publish to send |
media:embed | 2 | Open embedded media in an overlay | media.embed() |
clips:gallery | 2 | Browse and manage this channel's clip galleries | gallery.* |
voice | 2 | Adjust the playback volume of voice participants (only for you) | voice.setGain() |
game:telemetry | 2 | Read live match data from games running on your computer | gameTelemetry.* |
game:saves | 2 | Read your Slay the Spire 2 runs and stats saved on this computer | gameSaves.* |
wallet | 3 | ⚠ Connect a crypto wallet and approve real-money signatures | wallet.* |
Actions marked "—" have no SDK method; send them as raw requests — see postMessage Protocol.
Permission strings
Each permission grants access to a specific API:
profile:read
Access to user profiles. Allows:
Dissent.getUser()— read the current user's profile- In profile context: see the profile owner's user data
No message access
There is no permission that lets a plugin read channel messages. channel:read was removed on 2026-08-15, and the node now refuses it as an unknown permission — both in a registry submission and in a consent grant.
fetch:external
Make outbound HTTP/HTTPS requests. Allows:
Dissent.fetch(url)— fetch from any external domain
All external requests are proxied through dissent-core for privacy — your users' IPs are never exposed.
bot:post
Post messages to the channel. Allows:
Dissent.postMessage(content)— send a message as a bot
storage:user
Key–value storage kept by the node for this user. Allows Dissent.storage.* with scope user (values ≤ 64 KB).
game:saves · personal plugins
Read the user's own save files for a supported game, filtered by the desktop app. Allows Dissent.gameSaves.*. See Game Saves.
overlay:context · personal plugins
Know which game and panel the overlay is showing, and when it opens. Allows Dissent.overlay.*. See Overlay Panels.
net:direct · desktop only
HTTPS requests from the user's own computer to hosts the plugin lists in allowed_fetch_domains and the user approved. Nothing passes through the node, so no admin is involved — and the site sees the user's IP address, which the consent says in those words.
The desktop app enforces, whatever the plugin does:
- Exact approved hosts, on every redirect too.
example.comdoes not admitapi.example.com. - HTTPS only.
- Never the user's own network — loopback, private ranges, link-local, Tailscale/CGNAT (
100.64.0.0/10) and IPv6 private ranges are refused, checked on the address actually connected to. - No cookies or saved credentials; identity headers (
Cookie,Origin,Referer,User-Agent,Sec-*, …) are dropped.Authorizationpasses. - Bounded: request body 1 MB, response 5 MB (then
truncated: true), 15-second timeout, 60 requests a minute per install.
A later version that adds a host shows Review permissions; until the user approves it, that host is refused. A host the plugin stops listing stops working at once. On web, net.fetch rejects with net:direct needs the Dissent desktop app. My plugins shows what the plugin contacted this session.
friends:link · personal installs
Reach this same plugin on a friend's account. It is how a co-op plugin lets the host's copy share with the guest's, without a code to type.
friends.list()answers{ friends: [{ handle, name }] }— only local Dissent friends who installed this plugin and grantedfriends:link. Ahandleis opaque and different for every plugin; it is never a user id.friends.invite(handle, payload)sends an invite with a JSON object of at most 4 KB — a key or a pointer, never bulk data. One link per pair of people; inviting again returns the existing link.friends.links()lists invites sent and received and accepted links:{ links: [{ id, status: 'pending' | 'accepted', direction: 'sent' | 'received', peer: { handle, name }, payload?, created_at }] }. 🔴 A received invite has nopayloaduntil the user accepts it.friends.respond(id, accept)— the recipient only. Declining deletes the invite.friends.remove(id)— either side withdraws an invite or stops a link.- Unfriending, or either person uninstalling or revoking
friends:link, ends the link at once.
Automatic linking — friends.match(game, run?) (desktop, and game:saves must be granted too). The Dissent app reads the players of the run in progress, or of a finished run by id, from the save on this computer, hashes their Steam ids with a fresh salt, and asks the node which friends' verified Steam accounts (connected through Steam sign-in) are among them. Those friends are linked without an invite, and the answer is { status, matches: [{ handle, name, player, link_id, status, secret }] } — which player each friend was, never a Steam id. The reasoning: a friend in the same game already sees that game on their own screen. Guards: friendship, the same plugin, both grants, and either person's setAuto(false). A link either person stopped is never matched again; only a new invite revives it. Send a run only to the friends matched in that run.
Every accepted link has a random secret both sides read; key your own channel from it rather than from a payload.
Nothing here carries the data itself: send that over your own channel (for example with net:direct), keyed by what the accepted payload holds. A plugin that also reads game data gets an elevated consent finding saying it can share that data with friends who accept. There is no push yet: poll links() (the node allows 240 calls a minute per user).
The personal lane
A plugin whose context is personal may declare only storage:user, storage:local, game:saves, overlay:context, net:direct and friends:link. The node refuses to install one that declares anything else, and the app refuses every call outside the lane. See Personal Plugins.
In an overlay panel a second limit applies, to any plugin however it was installed: only storage:user, storage:local, game:saves, overlay:context, net:direct, friends:link and realtime work there. A refused call answers <action> is not available in the overlay, and the same plugin may use everything it is granted on its in-app page. See Overlay Panels.
Slash commands
Not a permission, and not tied to a tier: Dissent.onCommand() needs no grant. The current app never delivers a command to a plugin, though — see onCommand.
Checking permissions at runtime
Your plugin receives ctx.permissions — an array of permission strings the user has actually granted. Always check before calling a method:
javascript
const ctx = await Dissent.init();
// Example: only greet the user if they granted profile:read
if (ctx.permissions.includes('profile:read')) {
const user = await Dissent.getUser();
} else {
console.log('User did not grant profile:read permission');
}This is useful for plugins that gracefully degrade: a user who chose View Without Joining has granted nothing.
Consent UX
- No declared permissions (channel, sidebar): no card. The plugin loads immediately.
- Any declared permission (channel, sidebar): a card before first load, showing the plugin's name, tier, a summary of what its permissions add up to, and each declared permission. Two buttons:
- Join — grants every declared permission.
- View Without Joining — grants nothing; the plugin still loads.
- Either choice is remembered. The card returns only if the plugin later declares a permission the user has not granted.
Best practices
- Declare only the permissions you use. Every one appears on the consent card. Fewer permissions = more trust from users.
- Explain why you need each permission. Use clear, honest descriptions in your
manifest.json. - Gracefully degrade. If a call is refused, show a fallback UI instead of crashing.
- Never ask for more permissions than you use. If you declare
bot:postbut never post messages, users will lose trust.