Plugins
Plugins add features to The Hub: slash commands, buttons next to the message box, decorations on messages, settings panels, extra themes. They are plain JavaScript (plus optional CSS), with no build step.
Everyone chooses which plugins they use, in Settings → Plugins. Plugins come from the community shop: when yours is ready, send it.
Security
Every plugin runs in its own sandbox: it can't touch The Hub's page, the person's session or their stored data, and it can't reach the internet unless it asks. Reading messages, sending them, acting as the person through the API and using the internet are permissions you list in plugin.json; each person sees them and agrees before your plugin turns on. HTML and CSS you show are cleaned first. Ask for as little as you can: plugins that ask for more than they need, collect data, or hide what they do are refused from the shop, and anyone can report a plugin.
Try yours as you build it
You don't need a server. In The Hub, Settings → Plugins → Developer mode → Load a plugin folder runs a plugin from a folder on your computer, in your own browser only, until you reload. Nothing is uploaded. Load it again after you change a file.
A plugin folder looks like this:
my-plugin/
plugin.json what it is (required)
plugin.js its code (required)
plugin.css its styles (optional)
plugin.json
{
"id": "dice",
"name": "Dice roller",
"version": "1.0.0",
"author": "The Hub",
"description": "Roll dice in chat with /roll 2d6, or roll a d20 with the button next to the message box.",
"main": "plugin.js",
"styles": "plugin.css",
"default": false
}
| Field | Required | Rules |
|---|---|---|
id | yes | 1–40 characters: a-z, 0-9 and -, starting with a letter or digit. Must equal the folder name. |
name | yes | Up to 60 characters. |
version | yes | Letters, digits, ., + and - (like 1.0.0). Raise it when you change the files. |
author | no | Up to 60 characters. |
description | no | Up to 300 characters, shown in Settings → Plugins. |
main | yes | The script: a plain .js file name inside the plugin's folder (no slashes, no ..). |
styles | no | A stylesheet: a plain .css file name inside the plugin's folder. Loaded only while the plugin is on. |
default | no | true turns the plugin on for people who haven't chosen yet. Defaults to false. |
enabled | no | false hides the plugin from everyone without deleting it. Defaults to true. |
A manifest that breaks any rule is ignored as a whole.
Writing a plugin
A plugin's script calls TheHub.plugin() with its id and a setup function. Setup runs each time the plugin is turned on, and receives the api object:
TheHub.plugin('hello', api => {
api.commands.register({
name: 'hello',
description: 'Say hello to everyone',
run: () => `👋 Hello from ${api.me().display_name}!`,
});
api.ui.toast('Hello plugin is on');
});
The rules of the road:
- Call
TheHub.plugin()right away, at the top level of yourmainscript, with the id from yourplugin.json. Calls made later (after afetch, in a timer) or with another plugin's id are refused. - Do your work inside setup. Code outside it runs only once per page load, even if the plugin is turned off and on again.
- Everything you add through
apiis removed for you when the plugin is turned off: commands, buttons, event listeners, decorators, styles, themes, its settings panel and any modal it opened. Messages are redrawn without your decorations. - If you set up anything yourself (a
setInterval, a listener onwindow, elements you added outside the placesapigives you), return a cleanup function from setup. It runs when the plugin is turned off. Setup may also beasync, as long asTheHub.plugin()itself is called right away. - Escape text before you put it in HTML. Messages, names and topics come from other people. Use
api.util.esc(text), or settextContentinstead ofinnerHTML. - Prefix your CSS classes (for example
dice-…) or scope rules to[data-plugin="<id>"].
Every call into your code is guarded: if it throws, the error is shown in your plugin's entry in Settings → Plugins and logged to the console, and the rest of The Hub (and other plugins) carry on. If setup throws, everything it had registered is removed and the plugin stays off until it's turned on again.
API reference
Basics
| Member | What it is |
|---|---|
api.id | Your plugin's id. |
api.manifest | Your manifest as The Hub read it: { id, name, version, author, description, main, styles, default }. |
api.version | The Hub version, like "8.0.0". Also available as The Hub.version. |
Events: api.on(event, fn) → off()
const off = api.on('message', msg => {
if (!msg.mine && /good night/i.test(msg.content)) api.ui.toast(`${msg.author.display_name} is off to bed`);
});
| Event | When | fn receives | |
|---|---|---|---|
'ready' | Once per turn-on, after setup and once The Hub has finished its first sync. | nothing | |
'message' | A new message arrived in the channel or conversation that's open, including your own. Not for history being loaded or for edits. | a message object (below) | |
'channel' | The open channel or conversation changed. | the same object as api.channel(), or null | |
'voice' | You joined or left a voice room or call. | `{ joined: true \ | false, channelId }` |
'theme' | The theme changed. | { theme, applied }: the chosen theme ('night', 'auto', 'custom:dusk'…) and the look in effect ('night', 'day', 'mono'…) |
Everything a handler receives is a copy: changing it changes nothing in The Hub.
Message objects look like this:
{
id: 42, channel_id: 7,
author: { id, username, display_name, avatar, color, badge, name_style, … },
content: 'hi <@&3>', // raw text with Markdown; role mentions are <@&roleId>
attachments: [{ url, name, size, type, … }],
sticker: null, // or { name, url, … }
reply: null, // or { id, author, content, … }
reactions: [], pinned: false,
created_at: 1759660000000, edited_at: null,
mine: true, // added for plugins: you sent it
pending: true, // decorators only: your own message is still being sent ('message' events come after it's sent)
}
If your 'message' handler sends messages, ignore msg.mine ones, or you'll answer yourself forever.
Slash commands: api.commands.register({ name, description, run }) → off()
api.commands.register({
name: 'flip', // the user types /flip; a-z, 0-9 and -, up to 32 characters
description: 'Flip a coin', // shown in the suggestions and in Settings → Plugins
run(args, ctx) { // args: the text after the name; ctx: { name, channel }
return Math.random() < .5 ? '🪙 Heads' : '🪙 Tails';
},
});
- Return a string to send it as the person's message in the open channel.
- Return nothing when you've handled it yourself: nothing is sent.
- Return
falsewhen you couldn't use what was typed (bad arguments, say): nothing is sent and the person's text goes back in the message box so they can fix it. runmay beasync. If it throws, the person sees an error and gets their text back in the message box.- Commands can't replace The Hub's built-in ones (
/shrug,/tableflip,/unflip,/lenny,/disapprove), and the first plugin to register a name keeps it. - While someone types
/and the start of a name, matching plugin commands are suggested above the message box.
Message decorations: api.messages.decorate(fn) → off()
fn(el, msg) runs for every message drawn in the chat, each time the list is redrawn. el is the message's element (.msg, with the text in .msg-content); msg is a message object. Change only el and what's inside it.
api.messages.decorate((el, msg) => {
if (!msg.content.includes('#todo')) return;
el.querySelector('.msg-content')?.insertAdjacentHTML('beforeend',
`<span class="todo-tag" title="${api.util.esc(msg.author.display_name)}'s to-do">✔ to-do</span>`);
});
Interface
| Member | What it does |
|---|---|
api.ui.toast(text, type) | A short notice at the bottom of the screen. type: '', 'success' or 'error'. |
api.ui.addComposerButton({ icon, label, onClick }) → off() | A button next to the message box. icon: a name from The Hub's icon set (like 'star', 'music', 'smile'), an emoji, or an <svg> string (24×24, stroke="currentColor"). label is its tooltip. onClick(event). |
api.ui.settings(render) → off() | Your panel in Settings → Plugins, under your plugin's entry while it's on. render(el) fills the empty element el, each time the panel is shown. The Hub's own classes work here: setting-row, switch, btn btn-secondary btn-sm… (see the dice plugin). |
api.ui.modal(title, html) → element | Opens a dialog. title is plain text; html is the body, which you must escape. Returns the dialog element so you can add listeners. It closes when the plugin is turned off. |
Styles: api.styles.add(cssText) → off()
Adds a <style> element. For a stylesheet file, use "styles" in plugin.json instead. Use The Hub's colour tokens so you match every theme: var(--text), var(--muted), var(--s3), var(--line), var(--air) (the accent), var(--air-soft), var(--rose)… (the full list is at the top of assets/app.css).
Storage: api.storage.get(key, fallback), api.storage.set(key, value)
Small JSON values saved in this browser, separately for each plugin (in localStorage, under voicelink.plugin.<id>). set(key, undefined) removes a key. Nothing is shared between devices or people.
The person and the conversation
| Member | What it does | ||
|---|---|---|---|
api.me() | A copy of the logged-in user: { id, username, display_name, avatar, color, status, … }. | ||
api.channel() | The open channel or conversation, or null: `{ id, type: 'text' \ | 'voice' \ | 'dm', name, topic, spaceId, spaceName, user } (user` is the other person, in a DM). |
api.send(text) → Promise | Sends text to the open channel or conversation, as the person. | ||
api.request(action, data, opts) → Promise | Calls The Hub's own API (api.php?action=…) as the person; opts.method is 'POST' (default) or 'GET'. Resolves to the JSON reply, rejects with an Error whose message the server gave. The endpoints are the case '…': entries in api.php. |
Themes: api.themes.register(theme) → off() or null
Adds a theme people can pick in Settings → Appearance. It's removed when the plugin is turned off, and anyone using it goes back to its base theme.
api.themes.register({
id: 'dusk', // a-z, 0-9 and -, up to 40 characters
name: 'Dusk', // up to 40 characters
base: 'night', // 'night', 'day', 'mono' or 'mono-light': what it starts from
vars: { '--bg': '#1f1420', '--s1': '#2a1a29', '--air': '#ff9e5e' }, // colour tokens to override
css: '', // optional extra CSS
author: 'You', description: 'Plum dusk with a warm orange glow',
});
Only The Hub's theme colour tokens can be set in vars, with plain colour values. Returns null (and shows why in Settings → Plugins) if the theme isn't valid or this Hub can't add themes. See plugins/dusk-theme/.
Utilities
| Member | What it does |
|---|---|
api.util.esc(text) | Escapes & < > " ' so text is safe inside HTML and attribute values. |
api.util.icon(name, size = 20) | The <svg> markup of one of The Hub's icons, or '' if there's no such icon. |
Debugging
- Settings → Plugins shows each plugin's last errors under its entry: a script that couldn't load (missing file), a syntax error, a setup that threw, or an error in one of its commands, events, decorators or buttons (including a promise it rejected without catching). A plugin that is switched on but not running has a red outline.
- The browser console logs every error with its plugin id, like
[plugin dice] Setup: TypeError: …, with the full stack trace. - A plugin that doesn't appear at all was left out: log in as The Hub owner and look at the bottom of Settings → Plugins, or check
plugin.jsonagainst the table above. - Changed files not showing up? Raise
"version"inplugin.json, then reload. - To try changes quickly, turn the plugin off and on in Settings → Plugins: setup runs again (the script itself is only fetched again after a reload).