The Hub plugins
Plugins add features to The Hub without changing its code: slash commands, buttons next to the message box, decorations on messages, extra themes and more. They are plain JavaScript (plus optional CSS), with no build step.
- The server owner installs plugins, by putting their folders in
plugins/or from the plugin shop in Settings → Plugins (see The plugin shop). Only the accounts inOWNER_USERNAMEScan install from the shop, and only from the catalog set inconfig.php: there is no "install from any link". - Each person chooses which installed plugins they use, in Settings → Plugins. The choice is saved in their browser and takes effect right away, without reloading.
Two examples ship with The Hub, both off until someone turns them on:
| Folder | What it shows |
|---|---|
plugins/dice/ | The reference plugin: /roll, a composer button, a settings panel, a message decorator, an event and a stylesheet. |
plugins/dusk-theme/ | A plugin that only adds a theme. |
Security: read this first
A plugin runs inside The Hub as the person who turned it on. It can read everything they can see and do everything they can do: send messages, change their profile, leave spaces. Only install plugins you wrote yourself or whose code you have read and trust, and keep them up to date like the rest of The Hub.
The server never runs plugin code: plugins/.htaccess stops PHP and other server scripts in this folder (on Apache).
Installing a plugin
- Upload the plugin's folder to
plugins/, so you haveplugins/<id>/plugin.jsonnext to its files. The folder name must be the plugin's id. - That's it. Everyone sees it in Settings → Plugins the next time they open The Hub (or reload).
To update a plugin, replace its files and raise "version" in plugin.json (browsers fetch the new files when the version changes). To turn a plugin off for everyone, set "enabled": false in its plugin.json. To remove it, delete its folder.
If a folder is ignored because something is wrong with it, The Hub owner (the accounts in OWNER_USERNAMES in config.php) sees why under Left out at the bottom of Settings → Plugins.
The plugin shop
Settings → Plugins → Plugin shop lists the plugins in a catalog: set PLUGIN_SHOP_URL in config.php to an https:// link to the catalog's JSON file, or to the path of one inside this Hub (like plugin-shop/index.json). Everyone can browse it; The Hub owner installs, updates and removes plugins from it. plugin-shop.example.json is a sample catalog:
{
"plugins": [
{
"id": "dice",
"name": "Dice roller",
"version": "1.0.0",
"author": "The Hub",
"description": "Roll dice in chat with /roll 2d6.",
"homepage": "https://example.com/voicelink-dice",
"download": "https://example.com/voicelink-dice/dice-1.0.0.zip",
"sha256": "64 hex characters: the SHA-256 of that exact zip file",
"tags": ["fun", "games"]
}
]
}
id,name,versionanddownloadfollow the same rules as inplugin.json, and must match the plugin.json inside the zip.downloadis anhttps://link, or a path next to the catalog when the catalog is on this server.sha256is required: a download that doesn't match it is refused. Make it withsha256sum plugin.zip(Linux, macOS) orGet-FileHash plugin.zip(Windows PowerShell).- The zip holds the plugin's files, at its top or inside one folder. Only plugin files are accepted (
js,css,json,md,txt, images, fonts and sounds), up to 3 MB zipped and 6 MB unpacked.
Installing unpacks the plugin into plugins/<id>/ and writes a .shop.json there, so plugins/ must be writable by PHP. The shop never replaces or removes a plugin that was installed by hand. Turning a plugin on is still each person's choice, and the same warning applies: a plugin runs with the account of whoever turns it on, so only list plugins you trust in your catalog.
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).