Appearance
Personal Plugins
What it is
A personal plugin belongs to one user, not to a server. The user adds it from their node's market to My plugins in the left rail, where it opens as a full page. If its manifest declares overlay surfaces, the same plugin also appears as panels in the in-game overlay on the desktop app (see Overlay Panels).
No admin installs it for anyone, and nothing it does touches another person or the server's shared resources. That is why it gets a narrow set of permissions.
When to use this context
- A tool for yourself: a game wiki, a run tracker, notes, a build planner
- Anything that reads your own game saves (Game Saves)
- Anything that should be available in the game overlay
If the plugin is for a community — a poll, a leaderboard for a server — use a channel or sidebar plugin instead.
Permissions: the personal lane
A personal plugin may declare only these permissions. The node refuses to install one that declares anything else, and the app refuses any call outside this list on every request.
| Permission | What it allows |
|---|---|
storage:user | Dissent.storage.* — values kept by the node for this user and this plugin |
storage:local | Dissent.storageLocal.* — values kept on this device only |
game:saves | Dissent.gameSaves.* — the user's own save files, filtered by the desktop app |
overlay:context | Dissent.overlay.* — which game and panel the overlay is showing |
net:direct | Dissent.net.fetch() — HTTPS to approved hosts, from the user's computer. Desktop only |
friends:link | Dissent.friends.* — reach this same plugin on a friend's account |
fetch:external, realtime, members:read, bot:post and the rest are not available to personal plugins today. That is deliberate: they spend the node's bandwidth or reach other people.
🔴 On the web client, a personal plugin has no network at all
net:direct runs in the desktop app, and fetch:external — the node-proxied fetch — is not in the lane. So in a browser your plugin cannot reach any server but the user's own node.
Design for it rather than discovering it. Ship the data you can with the plugin, cache what you fetch on desktop (storage:local or storage:user, which the web copy can read back), and say plainly in your UI when something needs the desktop app. STS2 Companion does exactly this: its community statistics come from a bundled snapshot on the web and refresh over net:direct on desktop.
Manifest
json
{
"id": "my-run-tracker",
"name": "Run Tracker",
"version": "1.0.0",
"description": "Tracks my runs.",
"tier": 2,
"context": ["personal"],
"url": "https://example.com/my-run-tracker/plugin.html",
"declared_permissions": ["storage:user", "game:saves"],
"allowed_fetch_domains": [],
"resources": []
}contextmust include"personal".declared_permissionslists exactly what the plugin uses — the user approves this list once.resourceslists every file the page loads besides the entry file, with its sha256, so a node can verify and mirror the plugin. Most plugins generate it with a script rather than by hand.- Add an
overlayblock to get panels in the game overlay — see Overlay Panels.
Install, consent, and updates
- A node admin adds the plugin to the node's registry by its manifest URL.
- The user opens My plugins → Browse, picks it, and approves its permissions.
- When a new version declares a permission the user has not approved, calls that need it are refused and the plugin shows Review permissions until the user approves. Nothing new is ever granted silently.
Changing a manifest
The node reads a manifest when it is added. A registry "Refresh" does not re-read declared permissions or the overlay block — to publish a manifest change, the admin adds the plugin again by the same URL.
Knowing where you are
dissent:init tells a personal plugin where it is drawn:
javascript
const ctx = await Dissent.init();
ctx.contextType; // 'personal'
ctx.placement; // 'page' (in the app) or 'overlay' (in the game overlay)
ctx.surface; // overlay only: the manifest surface id, e.g. 'wiki'
ctx.game; // overlay only: the game's catalog id, e.g. 'slay-the-spire-2'One page serves every placement. Render the full layout for page, and a compact one for overlay.
Both placements are also told what the install may do — ctx.permissions (granted and still declared), ctx.capabilities (the calls that will be allowed) and ctx.overlayApi. A personal plugin never receives a user identity; ctx.user is null.
Storage
javascript
await Dissent.storage.set('bookmarks', ['card:BASH']);
const bookmarks = (await Dissent.storage.get('bookmarks')) ?? [];
const keys = await Dissent.storage.list('archetype:');
await Dissent.storage.delete('archetype:x1');For a personal plugin the scope defaults to user, which is the only scope it may use. The same data is visible to the plugin's page and its overlay panels.
| Limit | Value |
|---|---|
| Size of one value | 64 KB (JSON) |
| Writes | 60 per minute per user |
| Reads | 120 per minute per user |
Write less often than you click
Store one key per object (archetype:<id>) rather than one big key, and debounce writes that follow user input. A deck builder that saves on every click reaches 60 writes a minute quickly.
Minimal example
html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://app.dissent.chat/sdk/v1/dissent-plugin-sdk.iife.js"></script>
</head>
<body>
<h1 id="title">Run Tracker</h1>
<p id="run">Loading…</p>
<script>
const GAME = 'slay-the-spire-2';
async function show() {
const run = await Dissent.gameSaves.currentRun(GAME);
const el = document.getElementById('run');
if (run.status !== 'ok') {
el.textContent = run.status === 'desktop_only' ? 'Open Dissent desktop to see your run.' : `No run: ${run.status}`;
return;
}
const me = run.players[0];
el.textContent = `${me.hp}/${me.max_hp} HP · ${me.deck.length} cards`;
}
Dissent.init().then((ctx) => {
if (ctx.placement === 'overlay') document.body.classList.add('compact');
show();
Dissent.gameSaves.onChange(show); // in the app page
Dissent.overlay.onChange((o) => o?.panelOpen && show()); // in the overlay
});
</script>
</body>
</html>Never write game text or save data with innerHTML. Build elements and set textContent — the data came from outside your plugin.