Build a Discord Bot with Claude Code: discord.js Slash Command Guide
Create a practical Discord support bot with Claude Code, slash commands, permissions, env vars, and deployment checks.
A Discord Bot should be an operations path, not a toy demo
A Discord Bot is an application that joins a Discord server and reacts to user actions. In plain terms, it is an automated front desk for your community. It can receive a support request, return a short FAQ answer, notify moderators, format a handoff note, or route an incident before the right human is online.
That is more useful than a clever chat trick. Healthy Discord communities need reliable paths for “I am blocked”, “where is the setup guide?”, “who owns this issue?”, and “does this need escalation?”. If those questions stay in a general channel, they disappear under normal conversation. If they go through application commands, the support process becomes visible, repeatable, and easier to audit.
Claude Code is valuable here because a production-ready bot is not only a client.login() snippet. You need command registration, environment variables, permission boundaries, interaction replies, safe logging, token handling, and a small deployment runbook. If you ask for “a Discord bot” without those constraints, you may get a happy-path demo that fails as soon as it meets a real server.
This guide builds a practical support intake bot with discord.js. It implements /support, /faq, and /handoff, uses slash commands, avoids message-content intent, protects against mention abuse, and includes a deployment checklist. The starter assumes Node.js 22.12.0 or newer, matching the current discord.js documentation.
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 main design choice is intentionally boring: keep the first version as a reliable intake path. Add a database, queue, LLM summarizer, CRM sync, or billing lookup only after the command loop works every time. Claude Code can help with all of that, but it performs better when the first prompt describes the operational boundary instead of only the feature idea.
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, naming rules, guild versus global commands, and command registration are documented in Discord Application Commands. Initial responses, followups, and interaction tokens are covered in Receiving and Responding to Interactions. For library-specific APIs and runtime requirements, keep the discord.js documentation and discord.js guide nearby.
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 | 22.12.0 or newer | Required by current discord.js docs |
| 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 good prompt says: “Create a Node.js 22 discord.js support bot with /support, /faq, and /handoff. Use .env, guild command registration for development, minimum permissions, ephemeral user replies, and a deployment checklist.” That request is specific enough to avoid a generic chatbot scaffold.
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 discord.js dotenv
mkdir src
Add type and start to package.json.
{
"type": "module",
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "latest",
"dotenv": "latest"
}
}
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) {
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.reply({
content: "Thanks. Your request was sent to the support team.",
flags: MessageFlags.Ephemeral,
});
}
async function handleFaq(interaction) {
const topic = interaction.options.getString("topic", true);
const answers = {
setup: "Install Node.js 22.12+, 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.reply({
content: answers[topic],
flags: MessageFlags.Ephemeral,
});
}
async function handleHandoff(interaction) {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.reply({
content: "You need Manage Messages permission to use this command.",
flags: MessageFlags.Ephemeral,
});
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.reply({
content: "Handoff note created.",
flags: MessageFlags.Ephemeral,
});
}
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.replied || interaction.deferred) 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, confirm 22.12.0 or newer, then run npm start. In your test server, run /support first and confirm that the user sees a private response while the internal support channel receives the formatted message. Then test /faq. Finally test /handoff with a moderator account and with a normal account so the permission boundary is not only theoretical.
Use case: three workflows that make the bot worth keeping
The first Use case is support intake. A good intake command asks for a summary, severity, and context. That is enough to triage the request without forcing a long form on every community member. Triage means sorting the next action: ignore, answer, escalate, reproduce, or assign. In a small test server, this structure reduced back-and-forth because the first message already contained the error, urgency, and rough reproduction path.
The second Use case is FAQ routing. Do not make the bot paste a full article into chat. A better pattern is a short answer plus a strong link. For example, setup can point readers to the Claude Code getting started guide, CLI questions can continue into CLI tool development, and operational rules can link to CLAUDE.md templates. The bot becomes a navigation surface for the site, not a second documentation system.
The third Use case is moderator handoff. Paid communities, training cohorts, game servers, and product support spaces often have shift changes. If the current moderator writes “someone please check this” in a busy channel, the next person lacks context. A structured /handoff note says who the target is, who wrote the note, and what still needs to happen. That is more useful than a vague ping.
A fourth Use case is training support. In a Claude Code workshop, many learners hit the same environment error. A bot can ask for Node version, the command that failed, and the last ten lines of output before a human joins. That keeps the instructor focused on diagnosis instead of repeatedly asking for basics. For teams that want this workflow inside their own server, the natural CTA is training and consultation.
Pitfall: production failures to remove early
The first Pitfall is leaking the bot token. A token in Git history, screenshots, CI logs, or a pasted article must be treated as compromised. Rotate it in the Developer Portal and remove the old value. When prompting Claude Code, explicitly say: create .env.example with placeholders, never print secret values, and never include real tokens in docs.
The second Pitfall is overusing global command registration during development. Guild commands are faster to test and scoped to one server. Global commands are for the production surface. Finalize command names, descriptions, permissions, and rollback steps before you promote. Also avoid redeploying commands on every production restart unless that is an intentional release step.
The third Pitfall is leaving an interaction path without a reply. Users experience that as a broken command. Every branch should reply, defer, or follow up, including unknown commands and caught errors. If you add an external API call or LLM summarization, defer first and then send the result. The starter uses immediate replies because its work is short.
The fourth Pitfall is mention abuse. If you forward user input directly to an internal channel, @everyone, @here, user mentions, and role mentions can create notification incidents. The code uses both allowedMentions: { parse: [] } and string cleanup. Keep both. Defense in depth is justified because this input comes from users.
The fifth Pitfall is giving the bot administrator permission because it is convenient. That hides missing permission decisions and increases blast radius if the token is stolen. Start with View Channels and Send Messages, then add only the permissions you can explain in a review.
Security and deployment checklist
- A named owner can rotate the bot token in the Developer Portal
.envis ignored by Git, while.env.examplecontains only placeholders- The invite URL starts with
botandapplications.commandsscopes - Bot permissions begin with View Channels and Send Messages
/handoffis limited to moderator-level users- Guild command registration is tested before global command promotion
- Every interaction branch replies, defers, or follows up
- User input is protected from mention abuse
- Logs never include tokens, payment details, email addresses, or private support data
- The deployment target runs Node.js 22.12.0 or newer
- Rollback and restart steps are written down before launch
For deployment, a small bot can run on Railway, Render, Fly.io, a VPS, or an internal server. The hosting choice matters less than three basics: secrets are separate from code, logs are visible, and the restart path is documented. Ask Claude Code to generate the README with those exact sections.
This article should also connect to a revenue path without feeling forced. Solo builders who want reusable templates can start with ClaudeCodeLab products. Teams that need Discord support design, Claude Code adoption, permission reviews, and repeatable training can use training and consultation. For quality control, connect the bot rollout to code review automation so command changes are reviewed like any other production feature.
Result after trying it
After running this starter in a small test server, the biggest improvement was not the bot’s intelligence. It was the shape of the request. Free-form messages often started with “it does not work”. The /support command asked for summary, severity, and context, so the first moderator response was more often a diagnosis instead of a request for missing details. The risky parts were exactly the unglamorous ones: token handling, guild versus global command registration, mention cleanup, and permission checks. Claude Code was most useful when asked to generate those guardrails alongside the bot code.
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 Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
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.