Skip to content

Your first command

Every moderation bot needs a way to warn users. Let’s build one. Start by generating a new command:

Terminal window
pnpm warden make:command WarnCommand

Open the generated file and fill it in:

import {command, Command, Options, CommandInteraction, Params, reply, Embed} from '@warden/core';
@command({name: 'warn', description: 'Warn a user'})
export default class WarnCommand implements Command {
public options = [
Options.user('user', 'User to warn').required(),
Options.string('reason', 'Reason for the warning').required().maxLength(500),
];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const reason = interaction.options.getString('reason')!;
await reply(interaction, Embed.success(`${target.username} has been warned.`)
.addFields(
{name: 'Reason', value: reason},
{name: 'Moderator', value: interaction.user.username},
)
.setTimestamp()
);
}
}

Save the file, wait for the dev server to restart, and try /warn @someone Being disruptive in Discord. You should see a nicely formatted embed confirming the warning.

The Options builder provides a fluent, type-safe way to define what input your command accepts. You’ve already seen Options.user() and Options.string() — here’s the full picture:

Options.string('reason', 'Reason for the action') // text input
Options.integer('duration', 'Duration in minutes') // whole number
Options.number('score', 'Trust score') // decimal number
Options.boolean('silent', 'Perform silently') // true/false
Options.user('target', 'Target user') // user picker
Options.channel('channel', 'Log channel') // channel picker
Options.role('role', 'Mute role') // role picker
Options.mentionable('who', 'User or role') // user or role
Options.attachment('evidence', 'Screenshot') // file upload

Each option type supports relevant modifiers that you can chain fluently:

Options.string('reason', 'Reason')
.required() // must be provided
.minLength(5) // at least 5 characters
.maxLength(500) // at most 500 characters
Options.integer('duration', 'Minutes')
.required()
.min(1) // at least 1
.max(1440) // at most 1440 (24 hours)
Options.string('severity', 'Severity level')
.choices([ // present fixed choices
{name: 'Low', value: 'low'},
{name: 'Medium', value: 'medium'},
{name: 'High', value: 'high'},
])

Plain text replies work fine, but embeds give your moderation actions a professional look. Warden ships with preset embeds for the most common scenarios:

import {Embed} from '@warden/core';
Embed.success('User has been warned.') // green
Embed.error('You lack permission.') // red
Embed.info('Showing case #42.') // blue
Embed.warning('Are you sure?') // yellow

These are just convenience wrappers around discord.js’s EmbedBuilder, so you can chain any method you’d normally use:

await reply(interaction, Embed.success(`Warned ${target.username}`)
.setTitle('Moderation Action')
.addFields(
{name: 'Reason', value: reason},
{name: 'Case', value: `#${caseNumber}`},
)
.setFooter({text: `Moderator: ${interaction.user.username}`})
.setTimestamp()
);

You may have noticed we’ve been using reply() instead of calling interaction.reply() directly. This is a small but important convenience — the reply() helper automatically picks the right method based on the interaction’s current state:

import {reply} from '@warden/core';
await reply(interaction, 'Done!');
await reply(interaction, {content: 'Only you can see this', ephemeral: true});
await reply(interaction, Embed.success('Warning issued.'));

If the interaction hasn’t been replied to yet, it calls reply(). If it’s been deferred, it calls editReply(). If it’s already been replied to, it calls followUp(). This means you never have to think about the interaction’s reply state — and you’ll never see “interaction has already been replied to” errors again.

For destructive actions like banning a user, it’s good practice to ask for confirmation first. The confirm() helper makes this a one-liner:

import {confirm} from '@warden/core';
@command({name: 'ban', description: 'Ban a user'})
export default class BanCommand implements Command {
public options = [
Options.user('user', 'User to ban').required(),
Options.string('reason', 'Reason').required(),
];
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const reason = interaction.options.getString('reason')!;
const confirmed = await confirm(interaction, `Are you sure you want to ban ${target.username}?`);
if (confirmed) {
await interaction.guild?.members.ban(target, {reason});
await reply(interaction, Embed.success(`${target.username} has been banned.`));
}
}
}

Under the hood, confirm() sends a warning embed with Confirm and Cancel buttons, waits for the user’s response, cleans up the buttons, and returns a simple boolean. The timeout defaults to 30 seconds.

Moderation commands should obviously be restricted to users with the right permissions. Warden makes this declarative with the @permissions() decorator:

import {command, permissions} from '@warden/core';
@command({name: 'ban', description: 'Ban a user'})
@permissions({
user: ['BanMembers'], // the user running the command needs this
bot: ['BanMembers'], // the bot needs this too
})
export default class BanCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
// this only runs if both the user AND the bot have BanMembers
}
}

If either check fails, the framework replies with a clear error message and execute() is never called. You don’t need any permission checking logic in your command.

To prevent users from spamming moderation commands, you can add a cooldown with the @cooldown() decorator:

import {command, cooldown} from '@warden/core';
@command({name: 'warn', description: 'Warn a user'})
@cooldown({duration: 5, scope: 'user'})
export default class WarnCommand implements Command {
// can only be used once every 5 seconds, per user
}

The available scopes are user (per user), channel (per channel), guild (per server), and global (everyone). You may also customize the message shown when the cooldown is active:

@cooldown({
duration: 5,
scope: 'user',
message: 'Easy there! You can warn again in {remaining}s.',
})

As your mod bot grows, you’ll probably want to group related commands together: /mod warn, /mod mute, /mod ban, /mod history. Warden supports this through the entrypoint pattern.

First, create the parent command. This class defines the subcommand structure but has no execute() method itself:

import {command, Options, CommandGroup} from '@warden/core';
@command({name: 'mod', description: 'Moderation commands'})
export default class ModCommand extends CommandGroup {
public options = [
Options.subcommand('warn', 'Warn a user', [
Options.user('user', 'User to warn').required(),
Options.string('reason', 'Reason').required().maxLength(500),
]),
Options.subcommand('mute', 'Mute a user', [
Options.user('user', 'User to mute').required(),
Options.integer('duration', 'Duration in minutes').required().min(1).max(1440),
Options.string('reason', 'Reason').required(),
]),
Options.subcommand('ban', 'Ban a user', [
Options.user('user', 'User to ban').required(),
Options.string('reason', 'Reason').required(),
]),
Options.subcommand('history', 'View moderation history', [
Options.user('user', 'User to look up'),
]),
];
}

Then, each subcommand gets its own class. The parent field in the decorator tells Warden which entrypoint it belongs to — the wiring happens automatically at boot:

@command({parent: 'mod', name: 'warn', description: 'Warn a user'})
@permissions({user: ['ModerateMembers']})
export default class ModWarnCommand extends Subcommand {
constructor(private caseService: CaseService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const reason = interaction.options.getString('reason')!;
const caseNumber = await this.caseService.createWarning(
interaction.guildId!, target.id, reason, interaction.user.id,
);
await reply(interaction, Embed.success(`Warned ${target.username}`)
.addFields(
{name: 'Reason', value: reason},
{name: 'Case', value: `#${caseNumber}`},
)
.setTimestamp()
);
}
}

The main difference from regular commands is that subcommands extend Subcommand instead of implementing Command. The entrypoint calls deferReply() before routing, but the reply() helper handles this transparently — you don’t need to think about it.

Discord also supports an extra level of nesting through subcommand groups (/mod config set-log-channel). The Commands reference covers the full pattern, plus a couple of edge cases like opting out of the auto-deferral when your subcommand needs to show a modal.

Sometimes it’s more natural to right-click a user than to type a command. Context menu commands let you add actions to Discord’s right-click menu:

import {context, ContextMenu, Params} from '@warden/core';
import {UserContextMenuCommandInteraction} from 'discord.js';
@context({name: 'Warn User', type: 'user'})
@permissions({user: ['ModerateMembers']})
export default class WarnUserContext implements ContextMenu {
public async execute(interaction: UserContextMenuCommandInteraction, params: Params): Promise<void> {
const target = interaction.targetUser;
// you might show a modal here asking for the reason
}
}

You can also add context menus to messages — great for a “Flag Message” action:

@context({name: 'Flag Message', type: 'message'})
@permissions({user: ['ModerateMembers']})
export default class FlagMessageContext implements ContextMenu {
constructor(private caseService: CaseService) {}
public async execute(interaction: MessageContextMenuCommandInteraction, params: Params): Promise<void> {
const message = interaction.targetMessage;
await this.caseService.createFlag(message);
await reply(interaction, Embed.success('Message flagged for review.'));
}
}

For commands where users need to search through existing data — like looking up past moderation cases — you can enable autocomplete. This shows suggestions as the user types:

@command({parent: 'mod', name: 'history', description: 'View moderation history'})
export default class ModHistoryCommand extends Subcommand {
public options = [
Options.string('query', 'Search cases').autocomplete(),
];
constructor(private caseService: CaseService) {}
public async autocomplete(interaction: AutocompleteInteraction): Promise<void> {
const query = interaction.options.getFocused();
const cases = await this.caseService.search(query);
await interaction.respond(
cases.map(c => ({
name: `#${c.id} - ${c.type} - ${c.targetUsername}`,
value: String(c.id),
})),
);
}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const caseId = interaction.options.getString('query');
// show case details...
}
}

Just mark the option with .autocomplete() and add an autocomplete() method to your command. The framework handles routing the autocomplete interaction to the right method. If you forget to add the method, Warden will log a helpful warning at boot.

You’ve stacked @command, @permissions, and @cooldown on the same class throughout this guide. That’s the design — each decorator owns one concern, and the order you stack them in doesn’t matter. The shape of a command’s responsibilities is visible at a glance, right above the class name. The Commands reference walks through every built-in decorator and what it gives you.

You’ve now built a command that accepts input, shows rich embeds, asks for confirmation, requires permissions, has rate limiting, supports subcommands, and provides autocomplete. That’s a lot of ground covered!

Next, let’s make your bot reactive: