Appearance
SDK API Reference
The complete API for building Dissent plugins.
Loading the SDK
Script tag (no toolchain)
html
<script src="https://app.dissent.chat/sdk/v1/dissent-plugin-sdk.iife.js"></script>
<script>
Dissent.init().then(ctx => {
// Your plugin code here
});
</script>ES module
The same SDK is served as an ES module, with Dissent as its default export:
html
<script type="module">
import Dissent from 'https://app.dissent.chat/sdk/v1/dissent-plugin-sdk.js';
const ctx = await Dissent.init();
</script>Both builds come from one source and expose the identical API; the module build also sets window.Dissent.
There is no npm package
@dissent/plugin-sdk is not published, and no type definitions are shipped. The types below are copied from the SDK source; paste the ones you need into your project.
Methods
Dissent.init(options?: DissentInitOptions): Promise<PluginContext>
Initialize the plugin and retrieve the context.
Parameters:
| Name | Type | Description |
|---|---|---|
options.onFallback | () => void | Callback if running outside Dissent. Useful for development. |
Returns: PluginContext object with all context data.
Example:
javascript
const ctx = await Dissent.init({
onFallback: () => console.log('Not in Dissent'),
});Dissent.getContext(): PluginContext | null
Synchronously get the context, or null if init() has not resolved yet.
Plugins cannot read channel messages
There is no getMessages(), and no permission that grants message history. channel:read was removed on 2026-08-15: together with fetch:external it was the whole path by which a plugin could send a user's conversations elsewhere. The host does not forward chat messages to plugins as events either.
Dissent.getUser(): Promise<PluginUser | null>
Get the viewing user's display name. Requires profile:read; without it the call rejects with profile:read not granted.
Returns: { displayName, avatarUrl }. avatarUrl is currently always null. For a profile-context plugin, the profile owner is already in ctx.user.
Example:
javascript
if (ctx.permissions.includes('profile:read')) {
const user = await Dissent.getUser();
console.log(`Hello, ${user.displayName}!`);
}Dissent.fetch(url)
Make an HTTP request through the Dissent proxy. All external requests must use this.
Requires: fetch:external permission
Signature:
typescript
Dissent.fetch(url: string, options?: {
method?: 'GET' | 'POST'; // default 'GET'
body?: string; // request body
headers?: Record<string, string>; // request headers
}): Promise<{ status: number; body: string; content_type: string }>Parameters:
| Name | Type | Description |
|---|---|---|
url | string | Full URL (http/https only) |
options.method | 'GET' | 'POST' | HTTP method (default 'GET') |
options.body | string | Request body (for POST) |
options.headers | Record<string, string> | Extra headers. Identity and transport headers (Cookie, Host, Origin, Referer, User-Agent, Sec-*, Proxy-*, X-Forwarded-*, …) are dropped. |
Returns: Promise<{ status: number; body: string; content_type: string }> — the proxied response with status code, raw body string, and content type.
Notes:
- The host must be listed in the manifest's
allowed_fetch_domains(exact match, every redirect too); anything else is refused. - The response body is cut off at 1 MB.
- Your IP is never exposed; the request comes from dissent-core.
Example:
javascript
const res = await Dissent.fetch('https://api.example.com/data');
const data = JSON.parse(res.body);Dissent.postMessage(content: string): Promise<void>
Post a message to the current channel as the plugin bot. Requires bot:post, and a channel: in a frame with no channel it rejects with no channel context.
Parameters:
| Name | Type | Description |
|---|---|---|
content | string | Message content (plain text or markdown) |
Example:
javascript
await Dissent.postMessage('The event has started!');Dissent.on(event: string, callback: (data: unknown) => void): void
Listen for events from the host app. The handler receives the event's data.
Events the host actually sends:
| Event | Data | When it fires |
|---|---|---|
theme:change | { [cssVar: string]: string } | The user switches themes |
| any name | whatever the sender put in data | A realtime event under that name was published to this plugin's install in the current channel (for example by the same plugin open on another member's screen). Only delivered to frames granted realtime. The SDK has no publish method; send the realtime:publish action — see postMessage Protocol. |
game.saves.changed | { game } | See Personal plugins — use Dissent.gameSaves.onChange |
No message or member events
message:new, message:deleted, member:join and member:leave appear in an old SDK comment, but nothing in the host emits them. A handler for them never runs.
Example:
javascript
Dissent.on('theme:change', (theme) => {
Object.entries(theme).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
});
});Dissent.onCommand(command: string, handler: (args: string) => EmbedPayload | Promise<EmbedPayload>): void
Register a slash command handler. When the host sends a command:invoked event for that command, the SDK calls your handler and renders the returned EmbedPayload into the channel.
Not wired in the current app
The SDK side exists, but the chat composer does not yet route a typed /command to a plugin, so no command:invoked event is ever sent and the handler does not run. Registration itself succeeds (it is acknowledged and ignored).
Parameters:
| Name | Type | Description |
|---|---|---|
command | string | Command name, with or without the leading / |
handler | Function | Called when user types /command <args>. Receives the raw argument string. Returns EmbedPayload. |
Example:
javascript
Dissent.onCommand('weather', async (args) => {
const location = args.trim() || 'London';
// ... fetch weather ...
return {
html: `<div>Weather for ${location}</div>`,
height: 200,
};
});Other methods and namespaces
| Member | Requires | What it does |
|---|---|---|
imageUrl(remoteUrl) | — | Returns a node-proxied URL for a remote http(s) image, so it loads under the frame's CSP without exposing the user's IP. Relative URLs are returned unchanged. Synchronous. |
mediaUrl(url) | — | Adds stream=1 to a node attachment URL (/api/v1/storage/view/…) so <video>/<audio> stream through the node instead of redirecting. Other URLs are returned unchanged. Synchronous. |
media.embed(url, title?) | media:embed | Opens an embedded media overlay (streams, videos). |
voice.setGain(userId, gain) | voice | Sets the local playback volume of a voice participant, for this user only. |
gallery.contributors(), list(userId, folder), myFolders(), share(folder, gameName), unshare(folder, userId?), editClip(attachmentId, patch, folder), deleteClip(attachmentId), rate(attachmentId, value), featured(window?) | clips:gallery | The current channel's clip gallery. rate takes 1–5; featured takes 'week' or 'all' (default). |
gameTelemetry.status(game), bind(game, channelId?), unbind(game) | game:telemetry | Live match data from a game on the user's machine. Broadcasting is desktop-only; status() reports desktop: false elsewhere instead of throwing. |
storageLocal.get(key), set(key, value), delete(key) | storage:local | Key–value storage on this device only. |
net.fetch(url, { method?, body?, headers? }) | net:direct | HTTPS from the user's computer to approved hosts. Resolves { status, body, content_type, etag, truncated }; desktop only. See Permissions. |
friends.list(), links(), invite(handle, payload), respond(id, accept), remove(id), match(game, run?), setAuto(enabled) | friends:link | This plugin on Dissent friends' accounts: invite and accept, or link automatically with friends in the same game (match, desktop, needs game:saves). Personal installs. See Permissions. |
wallet.connect({ chainId }), balance({ tokenContract, decimals }), signTypedData({ domain, types, primaryType, message, summary }) | wallet | ⚠ Host-mediated wallet access; signing shows a confirm dialog. |
storage, gameSaves and overlay are documented under Personal plugins below; storage works for server plugins too.
Personal plugins
These namespaces serve personal plugins. Each requires the permission named.
Dissent.storage · storage:user / storage:server
| Method | Returns |
|---|---|
get(key, scope?) | the stored value, or null |
set(key, value, { scope?, ttl? }?) | resolves when saved; rejects if the node refuses (e.g. over 64 KB) |
delete(key, scope?) | resolves when deleted |
list(prefix?, scope?) | string[] of keys |
scope defaults to 'user' for a personal plugin — the only scope it may use — and 'server' otherwise.
Dissent.gameSaves · game:saves
| Method | Returns |
|---|---|
currentRun(game) | the run in progress |
runs(game, { limit?, before? }?) | finished runs, newest first |
run(game, id) | one finished run |
profileStats(game) | whole-profile stats |
onChange(handler) | an unsubscribe function; handler({ game }) runs when a save is rewritten (in-app pages only) |
Every answer has a status and never throws for a missing save. Shapes and statuses: Game Saves.
Dissent.overlay · overlay:context
| Method | Returns |
|---|---|
context() | { game, surface, width, height, panelOpen }, or null outside the overlay |
onChange(handler) | an unsubscribe function; handler(context) runs with a fresh context whenever the game or the panel layer changes |
See Overlay Panels.
Context fields for personal plugins
ctx.contextType is 'personal'; ctx.placement is 'page' or 'overlay'; in the overlay, ctx.surface is the manifest surface id and ctx.game the game's catalog id.
Personal plugins are also told ctx.permissions (granted and still declared — read from the init context, since a personal plugin has no user), ctx.capabilities (the actions that will be allowed) and ctx.overlayApi (the overlay contract the host implements). All three are absent on hosts that predate them — treat absent as unknown, not as empty.
Types
Copied from the SDK source (src/lib/plugin-sdk/types.ts).
PluginContext
The object Dissent.init() resolves with.
typescript
interface PluginContext {
server: { id: string; name: string };
/** Present for channel, sidebar, and embed contexts. Absent for profile. */
channel?: { id: string; name: string };
/** The viewing user (the profile owner in profile context). null unless profile:read or identity was granted. */
user: PluginUser | null;
/** Admin-configured values. Decrypted at runtime by the host. */
config: Record<string, unknown>;
/** CSS custom properties from the Dissent theme. */
theme: Record<string, string>;
/** Permissions granted by this user. A personal plugin (which has no `user`) gets them from the init context. */
permissions: string[];
/** 'channel' | 'sidebar' | 'profile' | 'embed' | 'personal' */
contextType?: string;
/** Personal plugins only — the full page, or the compact overlay view. */
placement?: 'page' | 'overlay';
/** Overlay only — the manifest `overlay.surfaces[].id` this frame is. */
surface?: string;
/** Overlay only — the catalog id of the game running (e.g. `slay-the-spire-2`). */
game?: string;
/** Personal plugins — the actions this install can dispatch right now (granted ∩ declared, on its lane). */
capabilities?: string[];
/** Personal plugins — the overlay contract version the host implements. */
overlayApi?: number;
}There is no plugin metadata object and no embed field: the plugin's id, version and tier are not in the context.
PluginUser
typescript
interface PluginUser {
/** Stable user UUID. Only present when the plugin was granted 'identity'. */
id?: string;
displayName: string;
avatarUrl: string | null;
permissions: string[];
}PluginMessage and PluginMember
Both types are exported by the SDK, but no SDK method returns either one — there is no message access, and no SDK wrapper for the member list. They are listed for completeness.
typescript
interface PluginMessage {
id: string;
content: string;
user_id: string;
username: string;
created_at: string;
}
interface PluginMember {
user_id: string;
username: string;
display_name: string | null;
avatar_url: string | null;
}EmbedPayload
Returned by a slash command handler.
typescript
interface EmbedPayload {
/** HTML string to render inside the embed iframe. */
html: string;
/** Height of the embed in pixels. Defaults to 200. */
height?: number;
}DissentInitOptions
typescript
interface DissentInitOptions {
/** Called if no dissent:init is received within 3 seconds (running outside Dissent). */
onFallback?: () => void;
}Personal-plugin types
typescript
type StorageScope = 'server' | 'user';
type GameSavesStatus =
| 'ok' | 'desktop_only' | 'no_game_data' | 'no_current_run' | 'unsupported_version'
| 'unsupported_coop' | 'unreadable' | 'not_found' | 'unsupported_game';
/** The other fields depend on the game; see Game Saves. */
type GameSavesAnswer = { status: GameSavesStatus } & Record<string, unknown>;
interface OverlayContext {
game: string;
surface: string;
width: number;
height: number;
/** The overlay's panel layer is open, so this frame is visible. */
panelOpen: boolean;
}Error handling
A method that the host refuses rejects with an Error whose message is the host's reason. Wrap calls in try/catch:
javascript
try {
const res = await Dissent.fetch('https://api.example.com/data');
} catch (err) {
console.error('Fetch failed:', err.message);
// Gracefully degrade
}Messages you will see:
<permission> not granted— the user did not grant it. Checkctx.permissionsfirst.<permission> is no longer declared by this plugin— the user granted it once, but the installed plugin no longer asks for it.<action> is not available to personal plugins— a personal plugin called something outside its lane.Dissent SDK: request "<action>" timed out— no answer within 10 seconds (20 seconds fornet.fetch, 2 minutes for wallet connect and signing).