Skip to main content
Your bot can declare a set of slash commands with a typed option model, publish them so a human’s / picker lists them, and receive typed invocations through one handler API. Registration is synchronous and happens on client.commands, a CommandRegistry.
That is the whole happy path. The rest of this page covers the design constraints, the two lanes an invocation can arrive on, and how the menu gets published.

Two properties of v1 you must design around

Both of these change what you are willing to put in a command, so read them before you write descriptions.
There is no ephemerality. An invocation is an ordinary message, encrypted with the channel’s epoch key. Every member holding that key can read every invocation, and it is a durable message row (subject only to the channel or per-message TTL). There is no “only you can see this” in v1. Ephemeral responses need a different key lane and are not shipped. Do not fake them by deleting the message afterwards.
The published menu is plaintext server-side. Command names, descriptions, option names and choice strings are stored and served unencrypted, so a client’s / picker can render them without holding a key. Never put a secret, an internal hostname, or a customer name in a description or a choices entry. See what is encrypted, and what is not.
Message bodies stay end-to-end encrypted, including the body of an invocation. The plaintext part is the menu, not the traffic.

Declare a command

register(declaration, handler) takes the declaration and the function to run. It returns the registry, so calls chain.
string
required
Lowercase, matching ^[a-z0-9_-]{1,32}$. The server casefolds and stores the casefolded form, and the text lane lowercases before matching, so declare it already lowercased.
string
required
1 to 100 characters. Rendered in the / picker, and served in plaintext.
CommandOption[]
The command’s arguments, in an order that is load-bearing. See below.
Everything register() rejects, it rejects synchronously, in your own stack trace: a bad name, a missing description, a duplicate command, more than 25 options, a bad choice list, or a declaration set whose serialized menu would exceed 16 KiB. A bot author bug never becomes a faraway server ack code.

Option types

CommandOptionType
required
One of string, integer, boolean, user, channel, role, number.
user, channel and role values travel as opaque id strings in v1. The SDK does no resolution and the clients do no argument autocomplete, so read them with getString() and normalize before comparing them to anything. There is no attachment type. Declaring one throws at register() with the reason: upload is closed to bots, so an attachment option could never be filled on the bot’s side.

Choices

choices is a flat list of strings. The choice string is both the label and the value, because the wire has no slot for a separate label.
Rules the SDK enforces at register():
  • Up to 25 choices per option, each 1 to 100 characters.
  • A boolean option cannot carry choices at all.
  • For integer and number options every choice must coerce to that type.
String choices match exactly first, then case-insensitively. Numeric choices are compared after coercion, so 2 and "2" land on the same choice.

Required options come first

Every required option must precede every optional one. register() throws otherwise, and the server rejects the reverse order anyway.
The reason is the text lane: options are positional in declaration order, and positional parsing cannot see a hole in the middle.

Handle an invocation

Your handler receives a CommandContext. The same handler runs no matter which lane the invocation arrived on.
Values arrive already coerced to their declared type on both lanes. The getters add null safety: each returns null for an option that was not filled, rather than undefined or a thrown error. ctx.reply is identical to ctx.message.reply. It posts a true reply into the invoking channel, which renders a quote header and fires a “replied to you” notification that pierces the invoker’s mutes. When you want a quieter response, send into the channel instead.
Both take the same SendOptions object ({ groupId?, mentions?, replyTo? }) as every other send. Both are fire-and-forget: the promise resolves when the frame goes out, and a server-side denial arrives later on the sendRejected event rather than rejecting the promise. ctx.message is the full Message, so ctx.message.authorId is the invoker and ctx.message.serverId is the server the invocation came from. Commands never dispatch from direct messages, so serverId is populated in a handler, but it is typed string | null like everywhere else. Narrow it before passing it into a server-scoped call.

When a command fails

The commandError event carries both failure modes.
'parse' | 'handler'
'parse' means the text matched a registered command but did not fit its declaration: a missing required option, a value outside the choice list, or an uncoercible number. Your handler was not invoked. 'handler' means your own handler threw or returned a rejected promise, both of which are caught.
The SDK does not auto-reply to a parse failure. It never speaks unprompted, so if you want the invoker to see an error message you must send it yourself. An unregistered /foo emits nothing at all, not even an event, because a human talking to some other bot is not your problem.

Do not answer twice

If your bot also does its own text handling in messageCreate, filter out messages the command router already claimed. A claimed message carries msg.command, and its handler has already been dispatched.
messageCreate fires first, then dispatch runs, so a listener and a handler see the ordering you would expect.

How an invocation reaches your bot

There are two lanes, and your handler cannot tell them apart beyond ctx.source. Both fields of an outbound command are encrypted under one epoch key from one context resolution, so they can never disagree about which key decrypts them. The text lane cannot address a bot. Every bot in the channel with /play registered will dispatch a text /play. That ambiguity is exactly why the structured lane carries a target bot id. The resolution rules:
  • Structured wins. A message carrying both never dispatches twice.
  • An invocation addressed to another bot is dropped, and does not fall back to text.
  • A malformed or undecryptable sidecar falls back to text.
The practical consequence is that a client which has not yet shipped its / picker still reaches your bot over text, with the same handler.

The text grammar

The formatter and the parser are inverses over this grammar:
Against the /volume declaration above, all of these parse:
Against /play, the greedy trailing string means /play never gonna give you up arrives as one query argument, spaces and all, with no quoting needed. The prefix is ClientOptions.commandPrefix, default '/'. Only the text lane uses it: a structured invocation carries the command name outright and is prefix-independent.
There is no escape character, by design. A value containing a " does not round-trip through the text lane. The structured lane carries it verbatim, which is what it is for.

Invoking a command from your bot

client.sendCommand() posts an invocation, either of your own commands or of another bot’s.
string
required
The target bot. It has no default on purpose: defaulting it to your own bot would have the bot address itself, the inbound loop guard would drop the invocation, and you would see a silent no-op that looks exactly like a bug.
CommandDeclaration
Required when botId is a different bot. The SDK cannot know a foreign bot’s option order, and it does not read the server registry.
string
The channel’s group, when it is not already cached from an inbound frame.
The message always carries genuine readable text (/play never gonna give you up) alongside the sidecar. That text is what search indexes, what notifications and channel previews are built from, what a reply quotes, and what every surface without a chip renderer shows. It is not a compatibility shim.

Publishing the menu

login() publishes whatever is registered at that moment, so a human’s / picker can list your commands.
Register before login(). A command added afterwards still dispatches locally, but it is not in the published menu until the next login, and the SDK does not re-publish on its own. This is the most common way to end up with a bot that works when you type the command by hand and is invisible in the picker.
Publication is best-effort and never blocks login. A rejection or a transport failure is logged behind debug and swallowed. Within one process an unchanged menu is sent once, so a reconnect loop cannot spam the registry, and the server independently skips the write when the menu it already holds matches.
Publishing needs a Cloak backend running its bot command registry. Against an older backend the frame goes nowhere and the SDK carries on. Everything on this page still works over the text lane.

Guards worth knowing

  • A command never dispatches from fetchMessages. Replaying history would re-execute old commands, so command is never set on a fetched message.
  • A command never dispatches from the bot’s own messages.
  • Commands are never dispatched from direct messages. The structured lane does not exist on the DM frame, and the menu is published per server. messageCreate still fires for DMs, so a bot that wants DM commands can parse msg.content itself.
  • An unregistered command name is a cheap no-op on every inbound message.

Registry caps

Every one of these is checked locally at register(), so you find out in your stack trace rather than as a rejected frame at login. The exported constants are on the commands reference.

A complete bot

keystorePath is not optional in practice. Identity publication is write-once server-side, so a bot with no keystore is bricked on its second run. See keys and the keystore.

Next

Commands reference

Every export from the commands module, with the caps and the parser helpers.

What is encrypted, and what is not

Why the menu is plaintext while the invocation body is not.

Command bot example

The runnable version of this page.

Events

commandError, sendRejected, and the rest of the event map.