Skip to content
CreateDiscordBot
Menu

Discord slash commands: how they work and how to add them to your bot

How Discord slash commands work and how to add them to your bot, with tested discord.js v14 and discord.py code for options, autocomplete and deferReply.

By Adam Peleback. Updated . 11 min read.

On this page
  1. How Discord slash commands work
  2. Limits that shape your design
  3. Set up the project
  4. Define a command with options, choices and subcommands
  5. Slow work: defer the reply
  6. Register the commands: guild or global
  7. Handle the interactions
  8. Autocomplete
  9. Permissions and where a command appears
  10. The same bot in discord.py
  11. When commands don't show up or fail
  12. When you'd rather not do this yourself

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:

  1. 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.
  2. 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.
  3. 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-Ed25519 header on every request, and Discord sends invalid signatures on purpose to check that you do.
  4. 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:

slash-bot/
├── .env
├── package.json
├── deploy-commands.js
├── index.js
└── commands/
    ├── npm.js
    └── team.js

Put the three values in .env and add .env to .gitignore so the token never reaches a repository:

.env
DISCORD_TOKEN=your-bot-token
CLIENT_ID=your-application-id
GUILD_ID=your-test-server-id

The package.json uses Node's built-in --env-file flag (Node.js 20.6 or newer), so there's no dotenv dependency:

package.json
{
  "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"
  }
}
terminal
npm install

Define 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:

commands/team.js
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 /team at all. More on permissions below.
  • setContexts(InteractionContextType.Guild) and setIntegrationTypes(ApplicationIntegrationType.GuildInstall) keep the command inside servers: no DMs and no user installs. Both only apply to global commands.
  • addChoices makes those three values the only valid input. Members see the name, and your code receives the value.
  • setMaxLength(32) is enforced in Discord's input box, so members can't submit a longer name.
  • addUserOption gives you a picker of server members, and getUser() returns a full User object.
  • 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 create exists, plain /team is 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:

commands/npm.js
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 old ephemeral: true option 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 private option before it defers, not when it has the answer.
  • After a defer, edit. Use editReply() for the answer and followUp() 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:

deploy-commands.js
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;
});
terminal
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 now

How registration behaves, per the current docs:

  • PUT replaces 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 /team twice after you go global. Run clear-guild to 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:

index.js
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);
terminal
npm start

Autocomplete 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 remove checks 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 list should be public and /team create staff-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:

bot.py
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"])
terminal
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.py

How it maps to the discord.js version:

  • Type hints become options. app_commands.Range[str, 1, 32] sets the length limits, app_commands.Choice with @app_commands.choices gives fixed choices, and a parameter with a default value becomes optional.
  • Registration only runs when you ask for it. SYNC=guild uses copy_global_to plus sync(guild=...), discord.py's shortcut for putting every command in one test server. SYNC=global registers them everywhere. Without SYNC the 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, call tree.clear_commands(guild=...) and then await tree.sync(guild=...).
  • defer() then edit_original_response() mirrors deferReply() and editReply(). Many discord.py examples use followup.send() after a defer. That still works, but it relies on the deprecated behavior described above.
  • default_permissions, allowed_contexts and allowed_installs on the Group are 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.

Questions

How do I add slash commands to a Discord bot in Python?

Use discord.py 2.x. Define the commands on an app_commands.CommandTree, then call await tree.sync() (or tree.sync(guild=...) for one test server) so Discord registers them. The discord.py section has a tested example.

How do I see a list of a bot's slash commands?

Type / in a server where the bot is: the command picker lists the commands you can use and filters as you type. Server admins can see each app's commands under Server Settings > Integrations, and developers can ask the API, which is what npm run deploy -- list does in this guide.

How do you use slash commands in Discord?

Type / in the message box, choose a command, fill in the options it asks for and press Enter. Commands you don't have permission to use don't appear in the list at all.

Do slash commands need the Message Content intent?

No. Option values arrive inside the interaction, so a bot that only uses slash commands runs with the Guilds intent. Message Content is a privileged intent that verified bots (100 or more servers) have to apply for.

Can a slash command have more than 25 choices?

No, 25 is the maximum per option. Use autocomplete instead: the bot suggests up to 25 matches for what the user has typed so far, drawn from a list of any size.

Sources

Prices and features were checked on September 27, 2026.

  1. Discord Developer Docs, Application Commands (limits, registration, contexts, permissions, autocomplete)
  2. Discord Developer Docs, Receiving and Responding (3-second deadline, 15-minute token, follow-ups)
  3. Discord Developer Docs, Interactions overview (Interactions Endpoint URL, Ed25519 signatures)
  4. Discord Developer Docs, Change Log (March 3, 2026 context menu limits; September 11, 2026 command improvements; June 27, 2024 user-installed apps)
  5. Discord Developer Docs, Gateway (Message Content intent)
  6. Discord Developer Docs, Opcodes and Status Codes (JSON error codes)
  7. discord.js docs, SlashCommandBuilder (@discordjs/builders 1.14.1)
  8. discord.js Guide, Command response methods
  9. discord.py API reference, Interactions
  10. discord.py example, app_commands/basic.py

Tell me what your server needs

Send a short brief. You get a reply within one business day with questions or a price range, and a fixed quote before any work starts.