Skip to content

Embeds and Replies

Every bot needs to talk to its users, and in Discord that usually means embeds. They’re the rich, colorful message blocks that make your bot look polished instead of like it’s shouting plain text into the void.

Warden gives you a small but powerful set of tools for this: preset embeds with sensible defaults, a reply() helper that removes the guesswork of when to use reply vs editReply vs followUp, and a confirm() one-liner for yes/no prompts. Together, they let you focus on what your bot says rather than the mechanics of how it says it.

The Embed class provides four static methods, each with a preset color and semantic meaning. They all accept a string (the embed description) and return a standard discord.js EmbedBuilder, so you can keep chaining if you need to.

A green embed for positive outcomes — actions completed, tasks finished, things working as expected.

import {Embed} from '@warden/core';
Embed.success('Member has been banned successfully.');

Use this when a moderation action went through, a setting was saved, or anything else your bot did worked.

A red embed for failures — something went wrong, an action couldn’t be completed, or the user made an invalid request.

Embed.error('Could not ban this member. They have a higher role than me.');

Reserve this for actual errors and hard failures. For things that are merely cautionary, use .warning() instead.

A blue embed for neutral information — status updates, instructions, data displays.

Embed.info('This member has 3 active warnings and 1 previous mute.');

This is your everyday workhorse. Any time you’re presenting information without a strong positive or negative connotation, .info() is the right choice.

A yellow/amber embed for caution — something the user should be aware of, a potentially destructive action about to happen, or a soft validation issue.

Embed.warning('This will ban the member and delete 7 days of their messages. Are you sure?');

Warnings sit between info and error. The action isn’t broken, but the user should pay attention before proceeding.

Every preset method returns a standard EmbedBuilder from discord.js. This means you can chain any EmbedBuilder method directly:

Embed.success('Member banned.')
.setTitle('Moderation Action')
.setFields(
{ name: 'Target', value: target.user.tag, inline: true },
{ name: 'Moderator', value: interaction.user.tag, inline: true },
{ name: 'Reason', value: reason },
)
.setTimestamp()
.setThumbnail(target.user.displayAvatarURL());

The preset just gives you a sensible starting point — color and description already set. Everything else is up to you.

Discord interactions have a lifecycle. You can reply() to them once, then editReply() to change that response, or followUp() to send additional messages. Getting the timing wrong leads to “Interaction has already been replied to” errors or, worse, silently dropped messages.

Warden’s reply() helper makes this a non-issue. It automatically picks the right method based on the interaction’s current state:

Interaction StateWhat reply() Does
Not yet replied toCalls interaction.reply()
Already replied or deferredCalls interaction.editReply()
Needs a follow-upCalls interaction.followUp()
import {reply, Embed} from '@warden/core';
// First call -- sends the initial reply
await reply(interaction, Embed.info('Processing your request...'));
// Later in the same execute() -- edits the reply
await reply(interaction, Embed.success('Done! Member has been muted for 1 hour.'));

You can pass reply() an embed, a string, or a full message options object with embeds, components, and other fields:

// Just an embed
await reply(interaction, Embed.success('Banned.'));
// Full message payload
await reply(interaction, {
embeds: [Embed.warning('Are you sure?')],
components: [Row(Btn.danger('Yes', 'confirm'))],
ephemeral: true,
});

Ephemeral messages are only visible to the user who triggered the interaction. They’re perfect for error messages, permission failures, or any response that shouldn’t clutter the channel:

await reply(interaction, {
embeds: [Embed.error('You do not have permission to use this command.')],
ephemeral: true,
});

A surprisingly large number of moderation flows boil down to “Are you sure?” Warden’s confirm() function handles this in a single line. It sends a confirmation prompt with “Confirm” and “Cancel” buttons and returns a boolean:

import {confirm, Embed, reply} from '@warden/core';
const confirmed = await confirm(interaction, 'Are you sure you want to ban this member?');
if (confirmed) {
// Proceed with the ban
await reply(interaction, Embed.success('Member has been banned.'));
} else {
await reply(interaction, Embed.info('Action cancelled.'));
}

That’s the entire flow. No manual button creation, no collector setup, no timeout handling. confirm() takes care of everything and gives you back true or false.

If the user doesn’t click anything before the timeout expires, confirm() returns false — treating inaction as cancellation, which is the safe default for destructive operations.

You can also pass an embed instead of a plain string for a richer prompt:

const confirmed = await confirm(interaction, {
embeds: [
Embed.warning('This will permanently ban **UserTag** and delete 7 days of messages.'),
],
});

When a moderation action is taken, you typically want to show more than just “Done.” Here’s a rich ban notification:

import {command, Command, permissions, CommandInteraction, Params, Embed, reply} from '@warden/core';
@command({ name: 'ban', description: 'Ban a member from the server' })
@permissions({ user: ['BanMembers'], bot: ['BanMembers'] })
export default class BanCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const reason = interaction.options.getString('reason') ?? 'No reason provided';
const days = interaction.options.getInteger('days') ?? 0;
await interaction.guild!.members.ban(target, { deleteMessageSeconds: days * 86400, reason });
const embed = Embed.success(`**${target.tag}** has been banned.`)
.setTitle('Member Banned')
.setThumbnail(target.displayAvatarURL())
.setFields(
{ name: 'Member', value: `${target.tag} (${target.id})`, inline: true },
{ name: 'Moderator', value: interaction.user.tag, inline: true },
{ name: 'Reason', value: reason },
{ name: 'Messages Deleted', value: `${days} day(s)`, inline: true },
)
.setTimestamp();
await reply(interaction, embed);
}
}

Error messages should almost always be ephemeral. They’re relevant only to the person who triggered the interaction, and showing them publicly can be noisy or embarrassing:

import {command, Command, CommandInteraction, Params, Embed, reply} from '@warden/core';
@command({ name: 'warn', description: 'Warn a member' })
export default class WarnCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
if (target.bot) {
await reply(interaction, {
embeds: [Embed.error('You cannot warn a bot.')],
ephemeral: true,
});
return;
}
if (target.id === interaction.user.id) {
await reply(interaction, {
embeds: [Embed.error('You cannot warn yourself.')],
ephemeral: true,
});
return;
}
// ... issue the warning ...
await reply(interaction, Embed.success(`**${target.tag}** has been warned.`));
}
}

Notice how the error cases return early with ephemeral messages, but the success case is public so the channel knows the action was taken.

Here’s the cleanest possible ban-with-confirmation flow:

import {command, Command, permissions, CommandInteraction, Params, Embed, reply, confirm} from '@warden/core';
@command({ name: 'ban', description: 'Ban a member from the server' })
@permissions({ user: ['BanMembers'], bot: ['BanMembers'] })
export default class BanCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const reason = interaction.options.getString('reason') ?? 'No reason provided';
const confirmed = await confirm(
interaction,
`Are you sure you want to ban **${target.tag}**?\nReason: ${reason}`,
);
if (!confirmed) {
await reply(interaction, Embed.info('Ban cancelled.'));
return;
}
await interaction.guild!.members.ban(target, { reason });
await reply(interaction, Embed.success(`**${target.tag}** has been banned. Reason: ${reason}`));
}
}

Three lines for the confirmation flow: ask, check, act. Everything else — the buttons, the collector, the timeout, the cleanup — is handled for you.

A common pattern is to reply to the moderator in the command channel and also log the action to a dedicated moderator log channel. Since Embed returns a standard EmbedBuilder, you can reuse the same embed in both places:

import {command, Command, permissions, CommandInteraction, Params, Embed, reply, Config} from '@warden/core';
@command({ name: 'mute', description: 'Timeout a member' })
@permissions({ user: ['ModerateMembers'], bot: ['ModerateMembers'] })
export default class MuteCommand implements Command {
constructor(private config: Config) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const duration = interaction.options.getInteger('duration')!;
const reason = interaction.options.getString('reason') ?? 'No reason provided';
const member = await interaction.guild!.members.fetch(target.id);
await member.timeout(duration * 60_000, reason);
// Reply to the moderator
await reply(interaction, Embed.success(`**${target.tag}** has been muted for ${duration} minutes.`));
// Log to the mod channel
const logChannelId = this.config.get('moderation.logChannel');
const logChannel = interaction.guild!.channels.cache.get(logChannelId);
if (logChannel?.isTextBased()) {
const logEmbed = Embed.info(`**${target.tag}** was muted.`)
.setTitle('Moderation Log: Mute')
.setFields(
{ name: 'Member', value: `<@${target.id}>`, inline: true },
{ name: 'Moderator', value: `<@${interaction.user.id}>`, inline: true },
{ name: 'Duration', value: `${duration} minutes`, inline: true },
{ name: 'Reason', value: reason },
)
.setTimestamp();
await logChannel.send({ embeds: [logEmbed] });
}
}
}

The reply in the command channel is a quick success message. The log channel gets a more detailed embed with all the context a moderator reviewing logs later would need.