Slash commands

CommandBuilder produces application command definitions matching Discord's API. Use it with the commands framework or hand the JSON to...

CommandBuilder produces application command definitions matching Discord's API. Use it with the commands framework or hand the JSON to client.createCommand / client.bulkEditCommands.

A basic command

import { CommandBuilder } from 'athena';
 
const cmd = new CommandBuilder('avatar', "Show a user's avatar")
  .addUserOption('user', 'Whose avatar', false);
 
await client.createCommand(cmd.toJSON());

A builder created as new CommandBuilder(name, description) serialises to a chat-input command without any extra call.

Options

new CommandBuilder('search', 'Search the docs')
  .addStringOption({ name: 'query', description: 'Text', required: true, autocomplete: true, min_length: 1, max_length: 100 })
  .addIntegerOption({ name: 'page', description: 'Page', min_value: 1, max_value: 100 })
  .addNumberOption({ name: 'amount', description: 'A decimal' })
  // The rest are positional: (name, description, required?, ...extras)
  .addBooleanOption('verbose', 'Extra detail')
  .addUserOption('user', 'A user')
  .addChannelOption('channel', 'A channel', false, [ChannelType.GuildText])
  .addRoleOption('role', 'A role')
  .addMentionOption('target', 'User or role')
  .addAttachmentOption('file', 'Upload');

Watch the two calling styles: string, integer, and number options take an options object, because they have many named extras. Every other option builder is positional - name, description, then optional required, then any type-specific extra. Passing an object to a positional builder makes the object itself the option name, which Discord rejects.

String, integer, and number options accept up to 25 choices:

.addStringOption({ name: 'lang', description: 'Language', choices: [
  { name: 'English', value: 'en' },
  { name: 'Espanol', value: 'es' }
]})

Restricting what people can upload

Attachment options take a file_types filter as their fourth argument, so Discord's file picker only offers matching files:

new CommandBuilder('report', 'File a report')
  // a media category (image / video / audio) or a dot-prefixed extension
  .addAttachmentOption('evidence', 'Screenshot or clip', true, ['image', 'video'])
  .addAttachmentOption('invoice', 'The invoice', false, ['.pdf', '.csv']);

Up to 10 entries. Discord matches on the file extension only, so treat this as a convenience for the person uploading, not as validation: someone who renames virus.exe to photo.png still gets through. Keep checking anything you actually open or forward.

Athena throws while you build the command if you pass a MIME type such as image/png, or more than 10 entries, because Discord silently ignores a filter it cannot parse and an upload box that quietly accepts everything is much harder to notice than an error.

Subcommands and groups

new CommandBuilder('settings', 'Server settings')
  .addSubcommand((s) => s.setName('view').setDescription('View settings'))
  .addSubcommandGroup((g) =>
    g.setName('notifications').setDescription('Manage notifications')
      .addSubcommand((s) => s.setName('enable').setDescription('Turn on'))
      .addSubcommand((s) => s.setName('disable').setDescription('Turn off'))
  );

Permissions and contexts

new CommandBuilder('purge', 'Bulk delete')
  .setMemberPermission(PermissionFlagsBits.ManageMessages)
  .setNSFW(false)
  .setContexts([InteractionContextType.Guild, InteractionContextType.BotDM])
  .setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall]);
  • setMemberPermission(bits) sets the default required permission.
  • setContexts([...]) replaces the deprecated setDMPermission. Use Guild, BotDM, PrivateChannel.
  • setIntegrationTypes([...]) distinguishes guild installs from user installs.

Context menu commands

new CommandBuilder('Report message', '').setCommandType(ApplicationCommandType.Message);
new CommandBuilder('View profile', '').setCommandType(ApplicationCommandType.User);

No options, no description text. Since March 2026, Discord allows up to 15 USER and 15 MESSAGE context menu commands per app (up from 5 each).

Entry point commands

Apps with Activities get one entry point command (the command that launches the Activity from the App Launcher):

new CommandBuilder('launch', 'Launch the game').setHandler(EntryPointCommandHandlerType.AppHandler);

setHandler(handler) marks the command PRIMARY_ENTRY_POINT (type 4). Handler 1 (AppHandler): your app receives the interaction and responds, typically with interaction.launchActivity(). Handler 2 (DiscordLaunchActivity): Discord launches the Activity itself and your app receives nothing. Entry point commands are global-only and limited to 1 per app.

Localization

new CommandBuilder('ping', 'Replies with pong')
  .setNameLocalizations({ 'es-ES': 'ping' })
  .setDescriptionLocalizations({ 'es-ES': 'Responde pong' });

Autocomplete

Mark an option autocomplete: true and implement handleAutocomplete:

async handleAutocomplete(context, interaction) {
  const focused = interaction.focused();
  const matches = await search(focused.value as string);
  await interaction.acknowledge(matches.slice(0, 25).map((m) => ({ name: m.title, value: m.id })));
}

Deploying

await client.createCommand(builder.toJSON());                 // one global command
await client.bulkEditCommands([builder.toJSON()]);            // replace all global
await client.bulkEditGuildCommands(guildID, [builder.toJSON()]); // instant, per guild

With the framework, call deployCommands() once on ready and it picks guild or global scope from NODE_ENV and DEV_GUILD. See Commands framework.

Receiving a command without the framework

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isCommand() || interaction.data.name !== 'avatar') return;
  const user = interaction.getUser('user') ?? interaction.user;
  await interaction.createMessage({ embeds: [{ title: user.username, image: { url: user.dynamicAvatarURL('png', 1024) } }] });
});

Checking your permissions in a channel they picked

When someone picks a channel with a channel option, the interaction tells you what your bot can do in that channel. Read it from channelAppPermissions, keyed by channel ID:

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isCommand() || interaction.data.name !== 'announce') return;
 
  const target = interaction.getRequiredChannel('channel');
  const perms = interaction.channelAppPermissions.get(target.id);
 
  if (perms && !perms.has(PermissionFlagsBits.SendMessages)) {
    return interaction.createMessage({ content: `I can't post in ${target.mention}.`, flags: MessageFlags.Ephemeral });
  }
 
  await client.createMessage(target.id, { content: 'Announcement!' });
});

Prefer this over target.permissionsOf(client.user.id). It arrives on the interaction itself, so it needs no cached permission overwrites and keeps working on big bots that turn overwrite caching off to save memory (see Caching and memory).

Discord omits the field when your bot user is not a member of the guild, which leaves the map empty. Check for a miss and treat it as "I don't know" rather than "denied", exactly as the example above does.

The same property exists on ComponentInteraction for channel select menus and on ModalSubmitInteraction for channel selects inside modals.

Option getter quick map

Builder methodReceiver
addStringOptiongetString
addIntegerOptiongetInteger
addNumberOptiongetNumber
addBooleanOptiongetBoolean
addUserOptiongetUser / getMember
addChannelOptiongetChannel
addRoleOptiongetRole
addMentionOptiongetMentionable
addAttachmentOptiongetAttachment