Build a Discord Bot with Claude Code: discord.js Slash Command Guide
Build a safe discord.js Discord Bot with /support, /faq, /handoff, least privilege, and testable slash commands.
The command worked locally, then failed in front of a user
You type /support, Discord shows “the application did not respond”, and the user posts the same problem in a busy channel. A moderator copies the message elsewhere, but the error details and urgency are already missing. This is a common first-bot failure: the demo can log in, yet it does not define a dependable support path.
This guide builds that path with Claude Code, discord.js 14.27.0, and three slash commands. /support collects a structured request, /faq returns a short approved answer, and /handoff leaves a note that only moderators can create. The example acknowledges every command with deferReply() before doing other work, then completes the private response with editReply().
You do not need to understand every Discord API type before starting. An application command is the command Discord displays in its interface. An interaction is the event delivered to your Bot after a user runs that command. The important beginner rule is simpler: acknowledge the interaction first, validate the input, perform the work, and always finish the reply.
What you will build
- A Node 24 LTS project with exact dependency versions, not
latest /support,/faq, and/handoffwith clear human ownership- Guild-only registration for fast development and safer review
- No Message Content intent and no Administrator permission
- Token protection, mention neutralization, and a repeatable local check
flowchart LR
A["User runs /support"] --> B["Discord interaction"]
B --> C["discord.js bot"]
C --> D["Ephemeral user reply"]
C --> E["Support channel message"]
E --> F["Moderator handoff"]
The first version deliberately avoids a database, an LLM, and a CRM integration. Those can be added after the command contract is reliable. A small Bot with a clear boundary is easier to review than an impressive demo with hidden permissions and no failure path.
Application commands and interactions in plain English
Discord application commands are native commands shown in the Discord client. Slash commands such as /support are the most familiar type. They are a better fit for support workflows than old prefix commands like !help because Discord can show names, descriptions, options, choices, and permission behavior before the user submits anything.
Interactions are the events your app receives when a user invokes an application command, clicks a button, uses a select menu, or submits a modal. With discord.js over the Gateway, you usually handle them through Events.InteractionCreate. Discord also supports receiving interactions through an HTTP endpoint, but a Gateway bot is easier to run locally and debug for a small team.
Use official documentation as the source of truth. Command types, command contexts, and registration are in Discord Application Commands. Response timing, deferred replies, and interaction tokens are in Receiving and Responding to Interactions. The APIs used below are pinned to the discord.js 14.27.0 reference.
Permissions, env vars, and the smallest useful architecture
Create a Discord application in the Developer Portal, add a bot user, and generate an invite URL with the bot and applications.commands scopes. Do not start with administrator permission. This bot needs to view the support channel and send messages. The /handoff command should be limited to members who have a moderator-level permission such as Manage Messages.
| Item | Value | Production note |
|---|---|---|
| Node.js | 24 LTS for this guide | Use one runtime locally and in production |
| OAuth2 scopes | bot, applications.commands | Required for bot user and slash commands |
| Bot permissions | View Channels, Send Messages | Add more only after review |
DISCORD_TOKEN | Bot token | Never commit, screenshot, or log it |
DISCORD_CLIENT_ID | Application ID | Used for command registration |
DISCORD_GUILD_ID | Test server ID | Use guild commands during development |
SUPPORT_CHANNEL_ID | Internal support channel | Bot must be able to send there |
Ask Claude Code to build to this table. A useful prompt is: “Create a Node 24 discord.js 14.27.0 support Bot with /support, /faq, and /handoff. Use exact package versions, .env, guild command registration, minimum permissions, deferred ephemeral replies, mention protection, and local tests. Do not connect to Discord while testing.” This gives the agent a security boundary instead of only a feature name.
For related implementation discipline, pair this article with environment variable management, error handling patterns, and code review checklists. The bot is small, but the habits are the same ones you need in larger Claude Code projects.
Runnable discord.js starter
The following starter is intentionally JavaScript, not TypeScript, so a beginner can paste it into a new folder and run it. It registers guild commands when DISCORD_GUILD_ID is set, and global commands only when you deliberately remove the guild ID. Keep DEPLOY_COMMANDS=true during local setup, then turn it off in normal production restarts unless your deploy step is responsible for command registration.
mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install --save-exact [email protected] [email protected]
mkdir src
Add type and start to package.json.
{
"name": "discord-support-bot",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "14.27.0",
"dotenv": "17.2.3"
}
}
Create .env.
DISCORD_TOKEN=replace_with_bot_token
DISCORD_CLIENT_ID=replace_with_application_id
DISCORD_GUILD_ID=replace_with_test_guild_id
SUPPORT_CHANNEL_ID=replace_with_support_channel_id
DEPLOY_COMMANDS=true
Create src/bot.js.
import "dotenv/config";
import {
Client,
Events,
GatewayIntentBits,
MessageFlags,
PermissionFlagsBits,
REST,
Routes,
SlashCommandBuilder,
} from "discord.js";
const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const supportChannelId = process.env.SUPPORT_CHANNEL_ID;
for (const [name, value] of Object.entries({ token, clientId, supportChannelId })) {
if (!value) throw new Error(`${name} is required.`);
}
const commands = [
new SlashCommandBuilder()
.setName("support")
.setDescription("Send a support request to the team")
.addStringOption((option) =>
option
.setName("summary")
.setDescription("What happened?")
.setMaxLength(900)
.setRequired(true),
)
.addStringOption((option) =>
option
.setName("severity")
.setDescription("How urgent is it?")
.setRequired(true)
.addChoices(
{ name: "low", value: "low" },
{ name: "normal", value: "normal" },
{ name: "high", value: "high" },
),
)
.addStringOption((option) =>
option
.setName("context")
.setDescription("Steps, links, or error messages")
.setMaxLength(1500),
),
new SlashCommandBuilder()
.setName("faq")
.setDescription("Show a short answer for a common topic")
.addStringOption((option) =>
option
.setName("topic")
.setDescription("FAQ topic")
.setRequired(true)
.addChoices(
{ name: "setup", value: "setup" },
{ name: "permissions", value: "permissions" },
{ name: "rollout", value: "rollout" },
),
),
new SlashCommandBuilder()
.setName("handoff")
.setDescription("Create a moderator handoff note")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addUserOption((option) =>
option.setName("target").setDescription("User to hand off").setRequired(true),
)
.addStringOption((option) =>
option
.setName("note")
.setDescription("What should the next moderator know?")
.setMaxLength(1500)
.setRequired(true),
),
].map((command) => command.toJSON());
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
try {
if (!interaction.inGuild()) {
await interaction.reply({
content: "Please use this command inside the server.",
flags: MessageFlags.Ephemeral,
});
return;
}
if (interaction.commandName === "support") await handleSupport(interaction);
else if (interaction.commandName === "faq") await handleFaq(interaction);
else if (interaction.commandName === "handoff") await handleHandoff(interaction);
else await safeReply(interaction, "Unknown command.");
} catch (error) {
console.error("Interaction failed:", error);
await safeReply(interaction, "Something went wrong. Please contact a moderator.");
}
});
async function handleSupport(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const summary = interaction.options.getString("summary", true);
const severity = interaction.options.getString("severity", true);
const context = interaction.options.getString("context") ?? "No extra context.";
const channel = await fetchSupportChannel();
await channel.send({
content: [
"**New support request**",
`Reporter: ${interaction.user.tag} (${interaction.user.id})`,
`Severity: ${severity}`,
`Channel: <#${interaction.channelId}>`,
`Summary: ${neutralizeMentions(summary)}`,
`Context: ${neutralizeMentions(context)}`,
].join("\n"),
allowedMentions: { parse: [] },
});
await interaction.editReply("Thanks. Your request was sent to the support team.");
}
async function handleFaq(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const topic = interaction.options.getString("topic", true);
const answers = {
setup: "Install Node.js 24 LTS, invite the bot with bot and applications.commands scopes, then run npm start.",
permissions: "Start with View Channels and Send Messages. Reserve Manage Messages for moderator-only commands.",
rollout: "Use guild commands for testing. Promote to global commands only after rollback and logging are checked.",
};
await interaction.editReply(answers[topic]);
}
async function handleHandoff(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.editReply("You need Manage Messages permission to use this command.");
return;
}
const target = interaction.options.getUser("target", true);
const note = interaction.options.getString("note", true);
const channel = await fetchSupportChannel();
await channel.send({
content: [
"**Moderator handoff**",
`Target: ${target.tag} (${target.id})`,
`From: ${interaction.user.tag} (${interaction.user.id})`,
`Note: ${neutralizeMentions(note)}`,
].join("\n"),
allowedMentions: { parse: [] },
});
await interaction.editReply("Handoff note created.");
}
async function fetchSupportChannel() {
const channel = await client.channels.fetch(supportChannelId);
if (!channel || !channel.isTextBased() || typeof channel.send !== "function") {
throw new Error("SUPPORT_CHANNEL_ID must be a text channel the bot can send to.");
}
return channel;
}
function neutralizeMentions(value) {
return value
.replaceAll("@everyone", "@ everyone")
.replaceAll("@here", "@ here")
.replace(/<@!?(\d+)>/g, "user:$1")
.replace(/<@&(\d+)>/g, "role:$1");
}
async function safeReply(interaction, content) {
const payload = { content, flags: MessageFlags.Ephemeral };
if (interaction.deferred && !interaction.replied) await interaction.editReply({ content });
else if (interaction.replied) await interaction.followUp(payload);
else await interaction.reply(payload);
}
async function deployCommands() {
const rest = new REST({ version: "10" }).setToken(token);
const route = guildId
? Routes.applicationGuildCommands(clientId, guildId)
: Routes.applicationCommands(clientId);
await rest.put(route, { body: commands });
console.log(guildId ? "Guild commands deployed." : "Global commands deployed.");
}
if (process.env.DEPLOY_COMMANDS === "true") {
await deployCommands();
}
await client.login(token);
Run node --version and confirm that it reports Node 24 LTS, then run npm start only after completing the local checks described below. The engines field records >=18 as the project floor, while this article standardizes development and deployment on Node 24 LTS so both environments use the same runtime.
Three use cases with an explicit human decision
The Bot should collect and format information. It should not decide whether a customer is entitled to support, whether an incident is severe, or whether a moderator’s action is appropriate. The following scenarios make that boundary visible.
1. Support intake
Input: A member runs /support summary:"Login returns 403" severity:high context:"Started after today's release".
Bot output: The member gets a private confirmation. The support channel receives the reporter, severity, source channel, summary, and context. Mentions are neutralized before the message is sent.
Human decision: A moderator checks whether the issue is reproducible, changes the severity if necessary, and chooses the owner. The Bot must not promise a response time or classify the report as a security incident.
2. Approved FAQ routing
Input: A member runs /faq topic:permissions.
Bot output: The Bot returns one short, maintained answer. In a real project, that answer should link to the canonical guide rather than duplicate a full document. The Claude Code permissions guide is an example of a page that can remain the source of truth.
Human decision: The documentation owner approves each answer and updates it when the operating policy changes. The Bot does not generate policy text on demand.
3. Moderator handoff
Input: A moderator runs /handoff target:@member note:"Waiting for a sanitized error log; do not request the token".
Bot output: The support channel receives the target, author, and neutralized note. Discord also hides the command from members without the configured permission, while the handler repeats the permission check.
Human decision: The next moderator verifies the note, contacts the member, and decides whether to close or escalate the case. A handoff record provides context; it is not an approval system.
Before and after introducing slash commands
Before this Bot, a typical workflow starts with a free-form message, followed by a moderator asking for the error, environment, and urgency. Information is copied manually when another person takes over. After introduction, Discord validates the command options and the Bot puts the same fields in the same order. Humans still diagnose and prioritize the issue, but they begin from a consistent packet of information.
Do not describe that difference as guaranteed time savings. A simple ROI estimate needs assumptions. Suppose a server receives 80 support requests per month, and structured intake avoids two minutes of clarification on 40% of them. The assumed saving is 80 × 0.40 × 2 = 64 minutes per month. If building, reviewing, and maintaining the Bot takes six hours, the first-month ROI is negative. The case improves only when request volume, reuse across communities, or error reduction justifies the maintenance cost. Measure your own request count and clarification time before promising a return.
What Claude Code owns and what a person owns
Claude Code can scaffold the project, produce command definitions, add environment validation, write local fixtures, and review the diff for accidental secret logging. It can also explain why a permission is requested and generate a rollback checklist.
A person must create the Discord application, choose the production channel, approve permissions, store and rotate the token, review FAQ wording, and decide when guild commands are promoted globally. A person also owns incident response. Never ask an agent to paste a production token into a prompt or terminal transcript.
Use this review prompt after the first implementation:
Review this discord.js 14.27.0 Bot as a security-sensitive change.
Check every interaction path for deferReply followed by editReply.
Reject Administrator permission, Message Content intent, latest dependencies,
unbounded user input, enabled mentions, and any token in source or logs.
List the exact file and line for each issue. Do not connect to Discord.
Pitfall: failures to remove before deployment
The token appears in Git or a screenshot. Treat it as compromised, rotate it in the Developer Portal, replace the deployed secret, and inspect logs. Deleting one commit does not make a leaked token safe. Commit only .env.example with placeholders and ensure .env is ignored.
The Bot asks for Administrator. Convenience is not a permission model. This example needs View Channels and Send Messages in the support channel. /handoff uses Manage Messages as a member-level gate; change it only after documenting the reason.
A slow handler replies too late. External APIs, database queries, and channel lookup can take time. Call deferReply({ flags: MessageFlags.Ephemeral }) before that work and finish with editReply(). Keep the error path compatible with both deferred and completed interactions.
User text creates notifications. @everyone, role mentions, and encoded user mentions are input, not trusted formatting. Keep allowedMentions: { parse: [] } on outbound messages and neutralize stored or forwarded text as a second layer.
Development changes global commands. Register commands to one test guild first. Move to global registration only through a reviewed release step. Re-registering on every restart makes command changes harder to audit and roll back.
FAQ answers become stale policy. Give each answer an owner and a review date. If the authoritative document changes, update the Bot in the same pull request or return only a link.
Security and deployment checklist
- Node 24 LTS is used in development, CI, and hosting
discord.jsis exactly14.27.0;dotenvis exactly17.2.3.envis ignored, and repository history contains no token- The invite uses only the
botandapplications.commandsscopes - The Bot does not have Administrator or Message Content intent
- The support channel allows only View Channel and Send Messages as needed
/handoffhas a default permission and a runtime permission check- All three commands defer privately and finish with
editReply() - Forwarded input has length limits and cannot trigger mentions
- Logs omit tokens and private support content
- Guild registration, restart, rollback, and token rotation are documented
- A human has reviewed the commands before global registration
Once the checklist passes, deploy to a host that can keep a Gateway process alive and inject secrets without committing them. The provider is less important than observable logs, a documented restart command, and a named token owner. Developers who want reusable checklists and implementation templates can continue with ClaudeCodeLab products.
What was actually tested
The article was checked with a local fixture only. The pinned package.json was parsed, the JavaScript example was syntax-checked, the three command definitions were inspected, and the mention-neutralization cases were exercised without a real token. The source was also checked for deferReply() and editReply() paths, minimum intents, exact dependency versions, and the three official documentation URLs.
No live Discord application, test guild, Gateway connection, command registration, channel permission, or production deployment was used for this verification. Therefore this result does not claim that the Bot has handled real users or reduced support work. Before launch, an operator must use a separate test guild to run /support, /faq, and /handoff with both moderator and ordinary member accounts.
Related Posts
Build a Claude Code Slack Bot: Triage, Incident First Response, and Daily Reports
Build a Slack bot with Claude Code and Bolt JS: Socket Mode, slash commands, security, tests, and production checks.
Choose a Chart Library with Claude Code: Recharts, Chart.js, and D3
Use Claude Code to choose Recharts, Chart.js, or D3 and build dashboards that survive real data.
Claude Code for Vue 3 Development: Practical TypeScript Guide
Use Claude Code with Vue 3, TypeScript, Pinia, composables, forms, and Vitest in real project workflows.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.