The /ping bot from the first guide answered with one plain line. Real commands take input, reply with formatted embeds, and put buttons under their messages. This guide grows a single command set — a /greet command — from a bare reply into something that reads options, shows an embed, and answers a button click, using the discord.js v14 pieces you'll reach for again and again.
| At a glance | |
|---|---|
| You need | comfort with the discord.js bot guide, a bot token from Your first Discord bot, and a Falix server running the Node.js application |
| Time | about twenty-five minutes |
We keep the discord.js guide's exact Client + Events + ClientReady + InteractionCreate structure and only enrich it.
Where a command is born, and where it's answered
Two places, same as before. You register commands in the ClientReady handler with application.commands.set([...]), and you answer them in InteractionCreate. Two things to remember as you go:
- Restart after changing a command's shape. A command's name, description, and options are only sent to Discord when
ClientReadyruns, so a definition change needs a restart to take effect. - First-time global registration lags. A brand-new command can take a few minutes to appear the very first time — the same delay you saw with
/ping.
Bring in the builders you'll use, alongside the imports from the first guide:
const {
Client, Events, GatewayIntentBits,
SlashCommandBuilder, EmbedBuilder,
ActionRowBuilder, ButtonBuilder, ButtonStyle,
} = require('discord.js');
Typed options: give a command inputs
Instead of the plain { name, description } object from the first guide, describe /greet with a SlashCommandBuilder so it can take arguments:
const greet = new SlashCommandBuilder()
.setName('greet')
.setDescription('Greet a member')
.addUserOption(option =>
option.setName('who')
.setDescription('Who to greet')
.setRequired(true))
.addBooleanOption(option =>
option.setName('loud')
.setDescription('Shout it'))
.addStringOption(option =>
option.setName('note')
.setDescription('An optional message to add')
.setMaxLength(100));
Register it in ClientReady, exactly where the plain objects went:
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([greet]);
console.log(`Listening as ${c.user.tag}`);
});
set([...]) replaces your whole command list, so keep every command in that array. Now read the options back in InteractionCreate — each getter matches the option's type:
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === 'greet') {
const who = interaction.options.getUser('who');
const loud = interaction.options.getBoolean('loud') ?? false;
const note = interaction.options.getString('note');
let text = `Hello, ${who}!`;
if (note) text += ` ${note}`;
if (loud) text = text.toUpperCase();
await interaction.reply(text);
}
});
getUser returns a User, getBoolean a true/false, getString the text. Each getter pairs with the builder call that declared the option:
| Option type | Builder call | Getter |
|---|---|---|
| User | .addUserOption() |
getUser() |
| Boolean | .addBooleanOption() |
getBoolean() |
| String | .addStringOption() |
getString() |
A required option is always there; an optional one returns null when it's left out, which is why loud falls back to false. And setMaxLength(100) caps note at 100 characters — Discord enforces that before your code ever runs.
Reply with an embed
A plain string works; an embed is what makes a bot look finished. Swap the reply for an EmbedBuilder:
const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setTitle('👋 Greetings')
.setDescription(text)
.setThumbnail(who.displayAvatarURL())
.addFields(
{ name: 'From', value: `${interaction.user}`, inline: true },
{ name: 'To', value: `${who}`, inline: true },
);
await interaction.reply({ embeds: [embed] });
setColor takes a hex number, addFields lays out labelled values (inline: true puts them side by side), and setThumbnail shows the greeted member's avatar. The one change to how you reply: pass { embeds: [...] } instead of a bare string.
Add a button
Buttons live in an action row under the message. Build a row and send it with the embed:
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(`wave:${who.id}`)
.setLabel('Wave back')
.setStyle(ButtonStyle.Primary),
);
await interaction.reply({ embeds: [embed], components: [row] });
The trick is the customId: wave:${who.id}. A customId is just a string Discord hands back to you when the button is clicked, so you can pack data into it — here an action (wave) and the ID of the person to wave at, joined by a colon.
💡 Tip: A
customIdmaxes out at 100 characters, so carry an ID, not a whole payload.
Button clicks arrive through the same InteractionCreate event, so check for them first and pull your data back out:
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isButton()) {
const [action, userId] = interaction.customId.split(':');
if (action === 'wave') {
await interaction.reply({
content: `<@${userId}>, someone waved back! 👋`,
ephemeral: true,
});
}
return;
}
if (!interaction.isChatInputCommand()) return;
// ...your command handlers from above...
});
isButton() tells a button apart from a slash command, and splitting customId on : recovers the action and the data you stored. Routing on that first piece lets one handler serve many different buttons.
Ephemeral replies
Notice ephemeral: true above. An ephemeral reply is visible only to the person who triggered it and quietly disappears — perfect for confirmations, errors, and anything that would otherwise clutter the channel. Add it to any reply: await interaction.reply({ content: '...', ephemeral: true });.
Subcommands: group related actions
When one command has several modes — add, list, remove — use subcommands instead of a pile of top-level commands. Here's a /todo with add and list:
const todo = new SlashCommandBuilder()
.setName('todo')
.setDescription('Manage your to-do list')
.addSubcommand(sub =>
sub.setName('add')
.setDescription('Add an item')
.addStringOption(option =>
option.setName('item')
.setDescription('What to add')
.setRequired(true)
.setMaxLength(200)))
.addSubcommand(sub =>
sub.setName('list')
.setDescription('Show your items'));
Add it to the registration array — await c.application.commands.set([greet, todo]); — and declare a store at the top of the file, next to your client:
const todos = new Map(); // userId -> string[]
Then branch on which subcommand ran with getSubcommand():
if (interaction.commandName === 'todo') {
const sub = interaction.options.getSubcommand();
const list = todos.get(interaction.user.id) ?? [];
if (sub === 'add') {
list.push(interaction.options.getString('item'));
todos.set(interaction.user.id, list);
await interaction.reply({ content: `Added. You have ${list.length} item(s).`, ephemeral: true });
} else if (sub === 'list') {
const text = list.length
? list.map((it, i) => `${i + 1}. ${it}`).join('\n')
: 'Your list is empty.';
await interaction.reply({ content: text, ephemeral: true });
}
}
Users type /todo add or /todo list; getSubcommand() returns 'add' or 'list' and you handle each. (This Map lives in memory and resets when the server restarts — see Store data for your bot to make it stick.)
Limits worth knowing
Discord caps how big one command can get, and the caps are all the same friendly number — 25:
- 25 options on a command (or on a single subcommand).
- 25 choices on a string, integer, or number option — the fixed list a user picks from with
.addChoices(...). - 25 subcommands under one command.
Cross a limit and the command fails to register on ClientReady; the console prints the validation error. The caps are generous, so brushing against one usually means it's time to split the work across more commands or subcommands.
The three-second rule
⚠️ Heads up: Discord expects an answer within about three seconds.
A normal reply() is instant, so most commands are fine. But when a command does slow work — a database call, an external API — acknowledge first, then fill in the answer:
await interaction.deferReply();
const data = await somethingSlow();
await interaction.editReply(`Done: ${data}`);
deferReply() shows a "thinking…" state and buys you time; editReply() delivers the real result once it's ready.
Troubleshooting
- A command's new options don't show up — you changed the builder but didn't restart. The command list is only sent on
ClientReady; restart, then allow first-time global changes a few minutes. - "This interaction failed" in Discord — your handler didn't reply within three seconds, or threw before replying. Read the console;
deferReply()if the work is slow. - A button does nothing — the
isButton()branch is missing or yourcustomIdaction doesn't match. Handle buttons first, and check the string you split. getString/getUserreturns null — that option was optional and left out. Mark itsetRequired(true), or handle thenull(likeloud ?? false).
Next steps
- Store data for your bot
- Host a TypeScript Discord bot
- discord.py cogs — the same ideas in Python