Skip to content

Slash commands, plugins & webhooks

OriginChats has three ways to add behavior beyond plain chat:

  • Slash commands are named actions that users run from the message box, such as /ban or /hello. They come from the server, from connected apps, or from plugins.
  • Webhooks let an outside service post messages to a channel over HTTP.
  • Plugins are small JavaScript programs that the server operator installs. They can add slash commands and react to incoming commands.

A slash command definition looks like this:

{
"name": "remind",
"description": "Set a reminder",
"options": [
{ "name": "text", "description": "What to remind you about", "type": "str", "required": true },
{ "name": "unit", "description": "Time unit", "type": "enum", "choices": ["minutes", "hours"], "required": false }
],
"ephemeral": false,
"whitelistRoles": null,
"blacklistRoles": null,
"registeredBy": "reminder-bot"
}

Each option has a type: str, int, float, bool, enum, or user. A user argument is a username string. An enum needs a non-empty choices list. Options are required unless required is false.

registeredBy says where a command comes from:

registeredBy Source Answered by
originChats Built into the server. The server, straight away.
A username An app connected over WebSocket that sent slash_register. That app, with slash_response.
plugin:<name> A plugin. The plugin’s onSlash function, straight away.

slash_list returns the commands the caller is allowed to use. A command can limit who uses it in three ways:

  • whitelistRoles: the caller needs at least one of these roles.
  • blacklistRoles: the caller must have none of these roles.
  • permission: the caller needs this server permission. Built-in commands use this. Apps can set it in slash_register too. The server enforces it, but leaves it out of slash_list, slash_add, and slash_remove.

When commands are registered or removed, every connected client receives slash_add or slash_remove with a commands list. These events aren’t filtered by permission.

A user runs a command with slash_call in a channel or thread they can view. The server:

  1. Finds the command by name. If more than one source registered the same name, the caller must send username to pick one.
  2. Checks the caller’s roles and permission against the command.
  3. Checks the arguments against the options.
  4. For Rotur accounts, checks that the call is signed. See sign slash calls.
  5. Runs the command, or forwards it to the app that registered it.

A reply becomes a message from the command’s source, with an interaction object that names the command and who ran it.

  • Public replies are saved to the channel and broadcast as message_new.
  • Ephemeral replies, from commands with ephemeral: true, have ephemeral: true on the message. They aren’t saved. A built-in or plugin reply goes only to the connection that ran the command. An app’s reply goes to all of the caller’s connections.

An app registers its commands with slash_register. The registration belongs to that WebSocket connection. Sending slash_register again replaces the app’s list, and closing the connection removes its commands.

When a user runs an app command:

  1. The caller gets {"cmd": "slash_call", "id": "<call_id>", "val": true} to confirm the call was forwarded.
  2. The app’s connection gets a slash_call event with the call id, val.command, val.args, the invoker, the channel, and any signing fields.
  3. The app answers once with slash_response, using the same id, within five minutes. After that, the call is forgotten and the answer fails with Unknown slash call.

Automod checks app replies before they’re posted.

The server registers these commands itself. See slash commands for their options and replies.

Command What it does Needs Reply
/ban Ban a user, optionally deleting their messages. manage_users Public
/unban Lift a ban. manage_users Public
/mute Time a user out for a number of seconds. manage_users Public
/unmute Clear a user’s timeout. manage_users Public
/sever Hide two users from each other. manage_users Ephemeral
/unsever Undo /sever. manage_users Ephemeral
/slow Set slow mode in the current channel. manage_channels Public
/sticky Keep a message at the bottom of the current channel. manage_messages Ephemeral
/nick Set or clear your own nickname. Nothing Ephemeral

The moderation commands are explained in moderation, and sticky messages in messages.

A webhook is a secret URL that posts messages to one channel. Anything that can send an HTTP request can use it, with no WebSocket connection or account.

Create one with webhook_create for a text or voice channel. All webhook commands need manage_server.

The webhook’s token is its password. Anyone who has it can post. These commands return the token:

webhook_get leaves the token out. If a token leaks, regenerate it.

Send JSON to /webhooks?token=<token> with Content-Type: application/json:

{
"content": "The build passed.",
"username": "Build service",
"avatar_url": "https://example.com/ci.png"
}

A successful post returns HTTP 204. The server saves the message in the webhook’s channel and broadcasts message_new. The message’s user is originChats, and its webhook object holds the display name and avatar. username and avatar_url override the webhook’s own name and avatar for that message.

A post needs content or embeds. text and message are accepted as other names for content. See HTTP endpoints for the full request format, limits, and errors.

Webhook posts go through automod. They don’t get link previews or send push notifications.

A webhook URL can also receive GitHub’s push events and turn them into a summary message.

To set one up in GitHub, add a repository webhook with:

  • Payload URL: https://<your-server>/webhooks?token=<token>
  • Content type: application/json
  • Events: “Just the push event.”

The server treats a request as a GitHub event when it has an X-GitHub-Event header and a body with repository and ref. It handles only push events. Each push becomes a message from a webhook named GitHub, with one embed:

{
"title": "main: 2 commits",
"url": "https://github.com/example/app/compare/1a2b3c4...5d6e7f8",
"color": 2369838,
"author": {
"name": "mist - example/app",
"url": "https://github.com/example/app",
"icon_url": "https://avatars.githubusercontent.com/u/1?v=4"
},
"description": "[`5d6e7f8`](https://github.com/example/app/commit/5d6e7f8...) Fix login redirect\n[`9a8b7c6`](https://github.com/example/app/commit/9a8b7c6...) Update docs",
"timestamp": "2026-09-24T12:00:00Z"
}
  • The title is <branch>: <n> commits, or <branch> created, <branch> deleted, or <branch> force-pushed.
  • The description has one line per commit: the short hash linked to the commit, then the first line of the commit message.
  • The title links to GitHub’s compare view, or to the repository if there isn’t one.

A plugin is a JavaScript program that runs inside the server. It can add slash commands and watch incoming WebSocket commands. Only users with manage_server can install and manage plugins.

Plugin code runs in a QuickJS sandbox with no access to files, the network, or the host. Each call starts a fresh runtime, so plugins can’t keep state in variables between calls. A call has a 2-second time limit and a 1 MiB memory limit. The plugin’s source plus the call’s data must be under 768 KiB.

Plugins are pure functions. The server passes in a JSON object, and the plugin returns a JSON object that tells the server what to do.

Each plugin is a folder in the database directory at plugins/<name>/. The name can use 1 to 64 letters, digits, _, and -.

File Purpose
plugin.json The manifest.
main.js The code. Must not be empty, and must be under 512 KiB.
data.json The plugin’s key-value store, managed by the server.
files/ Extra files managed with the plugin file commands. Each is limited to 256 KiB. Plugin code can’t read them.

The folder can be a git checkout, so you can update a plugin with git pull and then plugin_reload.

{
"name": "hello",
"description": "Say hello and answer !ping",
"version": "0.1.0",
"enabled": true,
"slash": [
{ "name": "hello", "description": "Say hello" }
],
"events": ["message_new"]
}
  • name must match the folder name. description and version must be non-empty strings.
  • enabled defaults to true. A disabled plugin stays installed but doesn’t run.
  • slash lists up to 16 slash command definitions, in the same format apps use. They appear in slash_list with registeredBy set to plugin:<name>.
  • events lists up to 32 WebSocket command names the plugin wants to see, such as message_new.

When a user runs one of the plugin’s commands, the server calls onSlash with:

{
"plugin": "hello",
"command": "hello",
"args": {},
"channel": "general",
"threadId": "",
"user": { "id": "mist", "username": "mist" },
"config": {},
"data": {}
}

threadId is always an empty string. config holds the plugins.<name> block from the server configuration, and data holds the plugin’s store.

Return { "response": "<text>" } to reply, or { "error": "<text>" } to fail the call with that error. The reply is posted from plugin:<name>.

function onSlash(p) {
if (p.command === "hello") {
return { response: "hello, " + p.user.username + "!" };
}
return { error: "unknown command: " + p.command };
}

For each incoming WebSocket command named in events, the server calls onEvent before running the command. This happens after the server checks the caller’s command permission and timeout, and before the command’s own checks. The payload has plugin, command, the incoming msg, user, config, and data.

onEvent can return:

Field Effect
msg Replaces the incoming command. Later plugins and the server see the new version.
block: true Stops the command. No later plugins run and the server doesn’t handle it.
responses Extra frames sent back to the caller.

Return {} to let the command through unchanged. If onEvent throws or times out, the server logs it and carries on as if the plugin weren’t there.

function onEvent(e) {
var content = e.msg && typeof e.msg.content === "string" ? e.msg.content : "";
if (e.command === "message_new" && content.indexOf("!ping") !== -1) {
return { messages: [{ channel: e.msg.channel, content: "pong" }] };
}
return {};
}

Both functions can also return these fields:

Field Effect
dataSet An object of keys to save in the plugin’s store.
dataDelete A list of keys to remove from the store.
messages A list of { "channel": "<name>", "content": "<text>" } messages to post.

Messages are posted as system messages from originChats in the channel itself, not in a thread. The server doesn’t check the triggering user’s permissions for them.

All of these commands need manage_server and are recorded in the audit log.

Task Command
List installed plugins plugin_list, plugin_get
Install from a manifest and code, or from a git URL plugin_install
Turn on or off plugin_enable, plugin_disable
Reload from disk plugin_reload
Remove plugin_uninstall
Manage files in files/ plugin_file_list, plugin_file_get, plugin_file_put

When a plugin’s commands change, clients get slash_add and slash_remove events:

  • Installing, enabling, or reloading one plugin sends slash_remove for its old commands and slash_add for its new ones.
  • Disabling or uninstalling a plugin sends slash_remove.
  • Reloading every plugin, with plugin_reload and no name, sends only slash_remove. Clients should call slash_list again afterwards.