Discord slash commands are the commands that appear when someone types / in a channel. You describe each command to Discord once, with its name, description and typed options. Discord shows it in the command picker and checks the input, and when a member runs it, your bot receives an interaction it has to answer within 3 seconds.
Adding slash commands to a bot takes three pieces: a definition (in discord.js, a SlashCommandBuilder), a registration call to Discord's HTTP API, either to one server (instant) or globally, and a handler for the InteractionCreate event. This guide builds all three. I tested every file below on September 27, 2026 with discord.js 14.27.0 on Node.js 24.15.0 and 22.22.2, and with discord.py 2.7.1 on Python 3.14.3.
How Discord slash commands work
A slash command goes through four steps:
- You register it. Registration only happens over HTTP. You send Discord a JSON description of the command: name, description, options, who may use it and where it works. Discord stores it, so the command stays in the picker whether your bot is online or not.
- Discord renders and validates it. Members see the options with their types, choices and limits, and Discord won't let them submit input that breaks those rules.
- Your bot receives an interaction. discord.js and discord.py get it over the gateway connection. The alternative is an Interactions Endpoint URL, where Discord POSTs each interaction to your web server. That setup needs no gateway connection, but your server must verify the
X-Signature-Ed25519header on every request, and Discord sends invalid signatures on purpose to check that you do. - You respond within 3 seconds. You can reply, defer (show "thinking" and answer later) or open a modal. The interaction token stays valid for 15 minutes, so edits and follow-ups can come later.
Two things follow from this. A command you delete from your code stays in the picker until you register again. And a command whose bot isn't running fails with "The application did not respond".
Slash commands also don't need the Message Content intent, because option values arrive inside the interaction. The Guilds intent is enough for everything in this guide.
For the people using your bot, it works like this: type /, pick the command, fill in the options Discord prompts for and press Enter. Commands a member lacks permission for don't show up in their picker. Since Discord's September 11, 2026 update, the picker matches fuzzily (/r a finds /role add) and string options accept multiline input.
Limits that shape your design
These come from Discord's application commands documentation as of September 2026. Most design decisions in a command set trace back to one of them.
| Limit | Value |
|---|---|
| Global slash commands per app | 100, plus up to 100 more per server as guild commands |
| User and message (right-click) commands | 15 of each type, raised from 5 on March 3, 2026 |
| Options per command or subcommand | 25 |
| Choices per option | 25 |
| Autocomplete suggestions per response | 25 |
| Nesting | command, then subcommand group, then subcommand; nothing deeper |
| Command and option names | 1 to 32 characters, lowercase where a letter has a lowercase form |
| Descriptions | 1 to 100 characters |
| Total size of one command | 8,000 characters across names, descriptions and choice values |
| String option length | min_length and max_length up to 6,000 |
| New command creations | 200 per day per server |
| First response | 3 seconds |
| Interaction token | 15 minutes |
Set up the project
Create an application in the Discord Developer Portal. Copy the bot token from the Bot tab and the application ID from General Information. Then invite the bot to a test server. The bot scope now includes applications.commands automatically, and the permissions calculator builds the invite link for you. To copy your test server's ID, turn on Developer Mode in Discord's Advanced settings and right-click the server. If you're starting from nothing, how to create a custom Discord bot with discord.js walks through the portal in more detail.
The project looks like this:
Put the three values in .env and add .env to .gitignore so the token never reaches a repository:
DISCORD_TOKEN=your-bot-token
CLIENT_ID=your-application-id
GUILD_ID=your-test-server-idThe package.json uses Node's built-in --env-file flag (Node.js 20.6 or newer), so there's no dotenv dependency:
{
"name": "slash-bot",
"private": true,
"scripts": {
"deploy": "node --env-file=.env deploy-commands.js",
"start": "node --env-file=.env index.js"
},
"dependencies": {
"discord.js": "^14.27.0"
}
}npm installDefine a command with options, choices and subcommands
Each file in commands/ exports a data builder and an execute function, plus autocomplete if an option uses it. This /team command has three subcommands, a fixed list of choices, a length limit, a user option and an autocompleted option:
const {
SlashCommandBuilder,
PermissionFlagsBits,
InteractionContextType,
ApplicationIntegrationType,
MessageFlags,
} = require('discord.js');
// Kept in memory so the example runs on its own. Use a database in a real bot.
const teams = new Map();
module.exports = {
data: new SlashCommandBuilder()
.setName('team')
.setDescription('Manage teams in this server')
// Hidden from members who lack Manage Roles. Admins can change this per server.
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)
// Server only: no DMs, no user installs.
.setContexts(InteractionContextType.Guild)
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.addSubcommand((sub) =>
sub
.setName('create')
.setDescription('Register a new team')
.addStringOption((opt) =>
opt.setName('name').setDescription('Team name').setRequired(true).setMaxLength(32),
)
.addStringOption((opt) =>
opt
.setName('region')
.setDescription('Where the team plays')
.setRequired(true)
.addChoices(
{ name: 'North America', value: 'na' },
{ name: 'Europe', value: 'eu' },
{ name: 'Asia-Pacific', value: 'apac' },
),
)
.addUserOption((opt) =>
opt.setName('captain').setDescription('Team captain (defaults to you)'),
),
)
.addSubcommand((sub) =>
sub
.setName('remove')
.setDescription('Delete a team')
.addStringOption((opt) =>
opt
.setName('name')
.setDescription('Start typing a team name')
.setRequired(true)
.setAutocomplete(true),
),
)
.addSubcommand((sub) => sub.setName('list').setDescription('List all teams')),
async autocomplete(interaction) {
const typed = interaction.options.getFocused().toLowerCase();
const matches = [...teams.keys()]
.filter((name) => name.toLowerCase().includes(typed))
.slice(0, 25); // Discord shows at most 25 suggestions
await interaction.respond(matches.map((name) => ({ name, value: name })));
},
async execute(interaction) {
const sub = interaction.options.getSubcommand();
if (sub === 'create') {
const name = interaction.options.getString('name', true);
const region = interaction.options.getString('region', true);
const captain = interaction.options.getUser('captain') ?? interaction.user;
if (teams.has(name)) {
return interaction.reply({ content: `${name} already exists.`, flags: MessageFlags.Ephemeral });
}
teams.set(name, { region, captainId: captain.id });
return interaction.reply({
content: `Created **${name}** (${region.toUpperCase()}), captain <@${captain.id}>.`,
allowedMentions: { parse: [] }, // show the mention without pinging
});
}
if (sub === 'remove') {
const name = interaction.options.getString('name', true);
// Autocomplete only suggests. Users can still type anything, so check it.
if (!teams.delete(name)) {
return interaction.reply({ content: `No team called ${name}.`, flags: MessageFlags.Ephemeral });
}
return interaction.reply({ content: `Removed ${name}.`, flags: MessageFlags.Ephemeral });
}
// sub === 'list'
const lines = [...teams].map(([name, t]) => `- ${name} (${t.region.toUpperCase()})`);
return interaction.reply({
content: lines.length ? lines.join('\n') : 'No teams yet.',
flags: MessageFlags.Ephemeral,
});
},
};What each part does:
setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles)is sent to Discord as the string"268435456". Members without Manage Roles don't see/teamat all. More on permissions below.setContexts(InteractionContextType.Guild)andsetIntegrationTypes(ApplicationIntegrationType.GuildInstall)keep the command inside servers: no DMs and no user installs. Both only apply to global commands.addChoicesmakes those three values the only valid input. Members see thename, and your code receives thevalue.setMaxLength(32)is enforced in Discord's input box, so members can't submit a longer name.addUserOptiongives you a picker of server members, andgetUser()returns a fullUserobject.allowedMentions: { parse: [] }shows the captain's mention without pinging them.
The builder's toJSON() validates a lot locally. In my tests it threw on an uppercase name, a missing description, a 26th choice, and choices combined with autocomplete on the same option. Two rules it did not catch:
- Required options must come before optional ones. The builder accepted them in the wrong order, so you'd only find out when Discord rejected the registration.
- A command with subcommands can't be run on its own. Once
/team createexists, plain/teamis no longer a valid command.
Slow work: defer the reply
Anything that waits on a network call can blow the 3-second window. This /npm command looks up a package on the npm registry, so it defers first:
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('npm')
.setDescription('Look up the latest version of an npm package')
.addStringOption((opt) =>
opt
.setName('package')
.setDescription('Package name, for example discord.js')
.setRequired(true)
.setMaxLength(214),
)
.addBooleanOption((opt) =>
opt.setName('private').setDescription('Only show the answer to me'),
),
async execute(interaction) {
const name = interaction.options.getString('package', true).trim().toLowerCase();
const hidden = interaction.options.getBoolean('private') ?? false;
// Acknowledge now. Discord shows "thinking..." and gives us 15 minutes.
// Ephemeral or not is decided here and can't be changed by editReply.
await interaction.deferReply(hidden ? { flags: MessageFlags.Ephemeral } : {});
const url = `https://registry.npmjs.org/${name.replace('/', '%2F')}/latest`;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
return interaction.editReply(`Couldn't find a package called \`${name}\`.`);
}
const pkg = await res.json();
return interaction.editReply(`**${pkg.name}** ${pkg.version}\n${pkg.description ?? ''}`);
},
};deferReply() answers the interaction straight away. The member sees "thinking" in the channel, and you have 15 minutes to editReply(). Defer before any await that could be slow, not after.
Three details matter here:
- Ephemeral replies use flags. Pass
flags: MessageFlags.Ephemeral. In discord.js 14.27 the oldephemeral: trueoption is deprecated and prints a warning. - The first response decides visibility. Discord's docs are explicit that an existing message's ephemeral state can't be changed. That's why the command reads the
privateoption before it defers, not when it has the answer. - After a defer, edit. Use
editReply()for the answer andfollowUp()only for additional messages. Discord's docs say a follow-up sent straight after a defer currently edits the loading message for backwards compatibility, but that behavior is deprecated.
Register the commands: guild or global
Registration is a separate script that you run when command definitions change:
const fs = require('node:fs');
const path = require('node:path');
const { REST, Routes } = require('discord.js');
const { DISCORD_TOKEN, CLIENT_ID, GUILD_ID } = process.env;
const target = process.argv[2] ?? 'guild'; // guild | global | clear-guild | list
const commandsDir = path.join(__dirname, 'commands');
const commands = fs
.readdirSync(commandsDir)
.filter((file) => file.endsWith('.js'))
.map((file) => require(path.join(commandsDir, file)).data.toJSON());
const rest = new REST().setToken(DISCORD_TOKEN);
const globalRoute = Routes.applicationCommands(CLIENT_ID);
const guildRoute = Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID);
async function main() {
// PUT replaces the whole list in that scope: commands you leave out are deleted.
if (target === 'global') {
const data = await rest.put(globalRoute, { body: commands });
console.log(`Registered ${data.length} global commands.`);
} else if (target === 'clear-guild') {
await rest.put(guildRoute, { body: [] });
console.log(`Removed all guild commands from ${GUILD_ID}.`);
} else if (target === 'list') {
const globalCommands = await rest.get(globalRoute);
const guildCommands = await rest.get(guildRoute);
console.log('Global:', globalCommands.map((c) => `/${c.name}`).join(', ') || '(none)');
console.log(`Guild ${GUILD_ID}:`, guildCommands.map((c) => `/${c.name}`).join(', ') || '(none)');
} else {
const data = await rest.put(guildRoute, { body: commands });
console.log(`Registered ${data.length} commands in guild ${GUILD_ID}.`);
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});npm run deploy # your test server: shows up at once
npm run deploy -- global # every server the app is installed in
npm run deploy -- clear-guild
npm run deploy -- list # what Discord has registered right nowHow registration behaves, per the current docs:
PUTreplaces the whole list in that scope. Any command you leave out is deleted, and that includes user and message commands. Commands that already exist don't count toward the limit of 200 creations per day, so re-running the script with unchanged definitions is harmless.- Guild commands update instantly. Discord recommends them for testing, and that's the default here.
- Global commands have "read-repair". If a member's client still holds an old version when they run the command, Discord rejects that attempt and reloads the command. Many tutorials still say global commands take up to an hour to appear, including a comment in discord.py's own example file. The current docs don't give a delay. If your picker still shows an old version, reloading the Discord client (Ctrl+R, or Cmd+R on a Mac) usually refreshes it.
- Registering both ways shows duplicates. Discord allows a global and a guild command with the same name, so your test server will show
/teamtwice after you go global. Runclear-guildto remove the guild copies.
Keep registration out of the bot's startup code. Restarts stay fast, and a crash loop can't hammer the commands endpoint.
The list mode is also the quickest answer to "which commands does my bot actually have?". It prints what Discord has stored, which is what members see, whatever your code says.
Handle the interactions
index.js loads every command module, logs in, and routes each interaction to the right file:
const fs = require('node:fs');
const path = require('node:path');
const { Client, Collection, Events, GatewayIntentBits, MessageFlags } = require('discord.js');
// Slash commands need no privileged intents. Guilds is enough.
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.commands = new Collection();
const commandsDir = path.join(__dirname, 'commands');
for (const file of fs.readdirSync(commandsDir).filter((f) => f.endsWith('.js'))) {
const command = require(path.join(commandsDir, file));
client.commands.set(command.data.name, command);
}
client.once(Events.ClientReady, (c) => console.log(`Logged in as ${c.user.tag}`));
client.on(Events.InteractionCreate, async (interaction) => {
const command = client.commands.get(interaction.commandName);
if (interaction.isAutocomplete()) {
try {
await command?.autocomplete?.(interaction);
} catch (err) {
console.error(err);
}
return;
}
if (!interaction.isChatInputCommand()) return;
if (!command) {
console.warn(`No handler for /${interaction.commandName}. Re-run the deploy script?`);
return;
}
try {
await command.execute(interaction);
} catch (err) {
console.error(err);
const content = 'Something went wrong running that command.';
try {
if (interaction.replied) {
await interaction.followUp({ content, flags: MessageFlags.Ephemeral });
} else if (interaction.deferred) {
await interaction.editReply({ content });
} else {
await interaction.reply({ content, flags: MessageFlags.Ephemeral });
}
} catch (replyErr) {
console.error(replyErr); // for example, the 15-minute token already expired
}
}
});
client.login(process.env.DISCORD_TOKEN);npm startAutocomplete is checked first because it's a different interaction type with its own response. isChatInputCommand() then filters out buttons, modals and context menu commands. The error handler picks the one response method that's still valid (reply, editReply or followUp) and is wrapped in its own try, so a late failure can't crash the process.
I tested this without a real token. I ran the files unchanged against a local mock of Discord's REST API and fed index.js real interaction payloads through discord.js's own interaction handling. The test checked the exact HTTP calls: a type 4 reply with flag 64 for ephemeral messages, a type 5 defer followed by a PATCH to @original, a type 8 autocomplete response, and the PUT routes for guild and global registration.
Autocomplete
Choices are capped at 25 and fixed at registration. Autocomplete is for everything else: team names from a database, a product catalog, a list of maps that changes every season. As the member types, Discord sends your bot the partial input with focused: true, and you answer with up to 25 suggestions.
Things to know:
- There's nothing to defer. An autocomplete interaction has one response type, the list of suggestions, and it has to arrive inside the same 3-second window. Keep the lookup in memory or cached.
- Suggestions don't restrict input. Discord's docs say options that use autocomplete aren't limited to the values you suggest. A member can type anything and press Enter, which is why
/team removechecks that the team exists. - Choices and autocomplete are exclusive. One option can't have both.
- Partial input is only checked by the client. You may get half-typed strings, but not invalid numbers. Don't treat autocomplete input as validated.
Permissions and where a command appears
default_member_permissions sets which permission a member needs to see and use a command:
- It's a default, not a lock. Server admins can override it per role, member or channel under Server Settings > Integrations, with up to 100 overrides per command, and members with Administrator can use every command. If an action must never run without a permission, whatever a server's settings say, check it in your code too. discord.py's docs call default permissions "a hint" for this reason.
- Admin-only is
0.setDefaultMemberPermissions(0)hides a command from everyone except admins until a server grants it. - It applies to the whole command. Subcommands can't have their own default permissions. If
/team listshould be public and/team createstaff-only, make them separate top-level commands.
Two more fields control where a global command shows up:
| Field | Values | Meaning |
|---|---|---|
contexts |
Guild (0), BotDM (1), PrivateChannel (2) |
Where it can be run: servers, DMs with your bot, or group DMs and other DMs. PrivateChannel only matters for user-installed commands |
integration_types |
GuildInstall (0), UserInstall (1) |
Whether it's available when the app is added to a server, to a user's account, or both |
Discord's change log says apps created since June 27, 2024 have both install types enabled by default (check the Installation tab in the Developer Portal for yours). A command without integration_types gets the app's install types, and changing them later doesn't update existing commands until you register them again. That's why /team sets both fields explicitly. /npm sets neither, so it follows the app's defaults. dm_permission and setDMPermission() are deprecated in favor of contexts.
The same bot in discord.py
discord.py 2.x handles slash commands through app_commands and a CommandTree. tree.sync() is its registration step. This single file is the Python version of the bot above, trimmed to /team create, /team remove with autocomplete, and a deferred /pypi lookup:
import os
import aiohttp
import discord
from discord import app_commands
teams: dict[str, str] = {} # name -> region. Use a database in a real bot.
class Bot(discord.Client):
def __init__(self) -> None:
super().__init__(intents=discord.Intents.default())
self.tree = app_commands.CommandTree(self)
async def setup_hook(self) -> None:
# Register only when asked: SYNC=guild (test server, instant) or SYNC=global.
sync = os.environ.get("SYNC")
if sync == "guild":
guild = discord.Object(id=int(os.environ["GUILD_ID"]))
self.tree.copy_global_to(guild=guild)
await self.tree.sync(guild=guild)
elif sync == "global":
await self.tree.sync()
bot = Bot()
team = app_commands.Group(
name="team",
description="Manage teams in this server",
default_permissions=discord.Permissions(manage_roles=True),
allowed_contexts=app_commands.AppCommandContext(guild=True),
allowed_installs=app_commands.AppInstallationType(guild=True),
)
@team.command(name="create", description="Register a new team")
@app_commands.describe(name="Team name", region="Where the team plays")
@app_commands.choices(region=[
app_commands.Choice(name="North America", value="na"),
app_commands.Choice(name="Europe", value="eu"),
app_commands.Choice(name="Asia-Pacific", value="apac"),
])
async def team_create(
interaction: discord.Interaction,
name: app_commands.Range[str, 1, 32],
region: app_commands.Choice[str],
) -> None:
if name in teams:
await interaction.response.send_message(f"{name} already exists.", ephemeral=True)
return
teams[name] = region.value
await interaction.response.send_message(f"Created **{name}** ({region.name}).")
@team.command(name="remove", description="Delete a team")
@app_commands.describe(name="Start typing a team name")
async def team_remove(interaction: discord.Interaction, name: str) -> None:
if teams.pop(name, None) is None:
await interaction.response.send_message(f"No team called {name}.", ephemeral=True)
return
await interaction.response.send_message(f"Removed {name}.", ephemeral=True)
@team_remove.autocomplete("name")
async def team_name_autocomplete(
interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
matches = [n for n in teams if current.lower() in n.lower()]
return [app_commands.Choice(name=n, value=n) for n in matches[:25]]
bot.tree.add_command(team)
@bot.tree.command(name="pypi", description="Look up the latest version of a PyPI package")
@app_commands.describe(package="Package name, for example discord.py", private="Only show the answer to me")
async def pypi(
interaction: discord.Interaction,
package: app_commands.Range[str, 1, 100],
private: bool = False,
) -> None:
# Acknowledge within 3 seconds; we can then edit the reply for up to 15 minutes.
await interaction.response.defer(ephemeral=private, thinking=True)
url = f"https://pypi.org/pypi/{package.strip()}/json"
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
await interaction.edit_original_response(content=f"Couldn't find a package called `{package}`.")
return
info = (await resp.json())["info"]
await interaction.edit_original_response(content=f"**{info['name']}** {info['version']}\n{info['summary'] or ''}")
if __name__ == "__main__":
bot.run(os.environ["DISCORD_TOKEN"])python3 -m venv .venv
.venv/bin/pip install -U discord.py
# first run, or after changing commands: register them in your test server
SYNC=guild GUILD_ID=your-test-server-id DISCORD_TOKEN=your-bot-token .venv/bin/python bot.py
# every other run
DISCORD_TOKEN=your-bot-token .venv/bin/python bot.pyHow it maps to the discord.js version:
- Type hints become options.
app_commands.Range[str, 1, 32]sets the length limits,app_commands.Choicewith@app_commands.choicesgives fixed choices, and a parameter with a default value becomes optional. - Registration only runs when you ask for it.
SYNC=guildusescopy_global_toplussync(guild=...), discord.py's shortcut for putting every command in one test server.SYNC=globalregisters them everywhere. WithoutSYNCthe bot just starts, which is the same split as the separate deploy script in the discord.js version. To remove the test-server copies after going global, calltree.clear_commands(guild=...)and thenawait tree.sync(guild=...). defer()thenedit_original_response()mirrorsdeferReply()andeditReply(). Many discord.py examples usefollowup.send()after a defer. That still works, but it relies on the deprecated behavior described above.default_permissions,allowed_contextsandallowed_installson theGroupare the equivalents of the three builder calls.
Two things I hit while testing. First, discord.py 2.7.1 still refuses a sixth user or message command with CommandLimitReached, even though Discord raised that limit to 15 in March 2026. Second, on macOS with the python.org installer, the /pypi request failed with CERTIFICATE_VERIFY_FAILED until I pointed Python at a certificate bundle. Running the "Install Certificates" command that ships with that installer fixes it.
For a full Python bot beyond commands, see how to make a Discord bot in Python.
When commands don't show up or fail
| Symptom | Likely cause | Fix |
|---|---|---|
| Command missing from the picker | Registered in a different scope, or your client holds an old list | npm run deploy -- list, then reload the client |
| Command shows twice | Registered globally and in this server | npm run deploy -- clear-guild |
| Only admins see it | default_member_permissions, or an override under Server Settings > Integrations |
Check both |
| "The application did not respond" | No bot process is running, or nothing answered within 3 seconds | Start the bot; defer anything slow |
Unknown interaction (10062) |
The first response came after the 3-second window | Call deferReply() before slow work |
Interaction has already been acknowledged (40060) |
Two copies of the bot are running (say, your laptop and your host) and both answered | Stop the extra process |
Missing Access (50001) when registering |
The bot isn't in that server, or the app isn't authorized there | Invite it again with a current link |
| Old options still appear | Definitions changed but weren't re-registered | Run the deploy script again |
More failure modes, from tokens to hosting, are in Discord bot not working?. If your bot is on a host that sleeps, hosting a Discord bot 24/7 covers the options.
When you'd rather not do this yourself
If your bot's commands stopped working after a library upgrade, or the bot is offline or stuck on an old version, Bot Rescue is a fixed $149, and you pay nothing if I can't fix it. If you want commands that create Trello cards, read a Google Sheet or call your own API, that's what I build under Discord integrations. Either way, the brief form is the place to start.