/ picker lists them, and receive typed invocations through one handler API. Registration is synchronous and happens on client.commands, a CommandRegistry.
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. 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.
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.
register():
- Up to 25 choices per option, each 1 to 100 characters.
- A
booleanoption cannot carry choices at all. - For
integerandnumberoptions every choice must coerce to that type.
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.
Handle an invocation
Your handler receives aCommandContext. The same handler runs no matter which lane the invocation arrived on.
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.
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
ThecommandError 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./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 inmessageCreate, 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 beyondctx.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.
/ picker still reaches your bot over text, with the same handler.
The text grammar
The formatter and the parser are inverses over this grammar:/volume declaration above, all of these parse:
/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.
/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.
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, socommandis 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.
messageCreatestill fires for DMs, so a bot that wants DM commands can parsemsg.contentitself. - 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.