Discord Bots skill for Claude Code
Slash-command bots that never miss the 3-second window. Claude builds discord.js v14 bots with only the intents they use, a separate deploy script for slash commands, and handlers that defer before slow work so Unknown interaction errors stop. Buttons and selects encode record IDs in customId so they survive restarts and shards, and serverless endpoints verify Ed25519 signatures before answering PING.
- name
discord-bots - size238 lines · ≈2,800 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Discord bots with discord.js v14, covering gateway intents including privileged ones, slash command definition and deployment, interaction replies within the 3-second window, buttons, select menus, modals, autocomplete, permissions, rate limits, HTTP interaction endpoints with Ed25519 verification, and sharding. Use when creating or fixing a Discord bot, adding slash commands or buttons, seeing Unknown interaction or missing intent errors, or preparing a bot to scale past many servers. Try asking
- Create a Discord bot with a /ticket command and a close button
- Our bot keeps throwing Unknown interaction on the /report command
- Move our Discord command handling to a serverless interactions endpoint
Use it when
- Starting a Discord bot or adding slash commands, buttons, or modals
- Seeing Unknown interaction, missing permissions, or disallowed intents errors
- Deciding between a gateway bot and an HTTP interactions endpoint
- Preparing a growing bot for verification and sharding
Skip it when
- You are building a Slack integration — use slack-apps instead
- You need a generic job queue for mass DMs or role edits — use background-jobs instead
How to check the result
Every command and component works in a test guild, a deliberately slow handler still succeeds after deferReply, and buttons posted before a restart keep working afterward.
The common mistake it prevents
Registering commands on every startup, which burns the daily command-creation limit and leaves stale or missing commands.
Done well means: the bot requests only the intents it uses, every interaction is answered or deferred within 3 seconds, commands are deployed by a separate script (not on every boot), component handlers are stateless and survive restarts, and the token never leaves the environment.
Ground rules
DISCORD_TOKEN,DISCORD_CLIENT_ID, and (for HTTP interactions)DISCORD_PUBLIC_KEYcome from env. Discord scans public GitHub for tokens and resets leaked ones; if a token was ever committed, regenerate it in the Developer Portal.- Slash commands over prefix commands. Prefix commands need the privileged MessageContent intent and lose autocomplete, permissions, and discoverability.
- Respond or defer within 3 seconds. An interaction token then stays valid for 15 minutes of follow-ups and edits.
- Never trust
customIdcontents for authorization; check the clicking user’s permissions in the handler.
Choose the shape
| Decision | Default | Switch when |
|---|---|---|
| Transport | Gateway (discord.js Client, long-running process) | HTTP interactions endpoint (serverless) when the bot only answers commands and needs no events |
| Intents | Guilds only | Add GuildMessages for message events, GuildMembers/MessageContent/GuildPresences only with a concrete feature need (privileged) |
| Command scope | Guild commands in development | Global commands in production |
| Multi-step UI | Buttons/selects with encoded customId + modals | Collectors only for short, in-memory flows that can be lost on restart |
| Scale | Single process | ShardingManager before 2,500 guilds (Discord requires sharding there) |
Privileged intents must be toggled in the Developer Portal; once the bot is in 100+ servers they require verification and Discord’s approval with a justification.
Workflow
- [ ] 1. Create the application; note client ID; set intents in the portal
- [ ] 2. Scaffold: commands/ folder, deploy script, client entry, env config
- [ ] 3. Define commands with SlashCommandBuilder (+ default permissions, contexts)
- [ ] 4. Deploy to a test guild with the deploy script
- [ ] 5. Implement interaction routing: commands, buttons, selects, modals, autocomplete
- [ ] 6. Handle errors and slow work with deferReply
- [ ] 7. Invite with minimal permissions; test in a guild
- [ ] 8. Promote commands to global; plan sharding if growth expected
2. Layout
src/
commands/ticket.ts # export data (SlashCommandBuilder) + execute()
components/close.ts # button handler keyed by customId prefix
deploy-commands.ts # run manually / in CI when definitions change
index.ts # Client, intents, routing
3. A command
// src/commands/ticket.ts
import { SlashCommandBuilder, PermissionFlagsBits, InteractionContextType,
ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags,
type ChatInputCommandInteraction } from "discord.js";
export const data = new SlashCommandBuilder()
.setName("ticket")
.setDescription("Open a support ticket")
.addStringOption(o => o.setName("topic").setDescription("What is it about?").setRequired(true).setMaxLength(100))
.setDefaultMemberPermissions(PermissionFlagsBits.SendMessages)
.setContexts(InteractionContextType.Guild);
export async function execute(interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); // buys 15 minutes
const topic = interaction.options.getString("topic", true);
const ticket = await createTicket({ topic, userId: interaction.user.id, guildId: interaction.guildId! });
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId(`ticket-close:${ticket.id}`).setLabel("Close").setStyle(ButtonStyle.Danger),
);
await interaction.editReply({ content: `Ticket #${ticket.id} opened.`, components: [row] });
}
flags: MessageFlags.Ephemeral replaces the deprecated ephemeral: true option.
4. Deploy script (not on startup)
// src/deploy-commands.ts — run: node dist/deploy-commands.js [guildId]
import { REST, Routes } from "discord.js";
import * as ticket from "./commands/ticket.js";
const body = [ticket.data.toJSON()];
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
const guildId = process.argv[2];
const route = guildId
? Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID!, guildId)
: Routes.applicationCommands(process.env.DISCORD_CLIENT_ID!);
await rest.put(route, { body }); // bulk overwrite: adds, updates, and removes
console.log(`Deployed ${body.length} commands to ${guildId ?? "global"}`);
Command creation is rate limited per day; deploying on every boot burns the budget and can lock you out. Deploy only when definitions change.
5. Client and routing
// src/index.ts
import { Client, Events, GatewayIntentBits, MessageFlags, type Interaction } from "discord.js";
import * as ticket from "./commands/ticket.js";
const commands = new Map([[ticket.data.name, ticket]]);
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, c => console.log(`Ready as ${c.user.tag}`));
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
try {
if (interaction.isChatInputCommand()) {
await commands.get(interaction.commandName)?.execute(interaction);
} else if (interaction.isButton()) {
const [kind, id] = interaction.customId.split(":");
if (kind === "ticket-close") await closeTicket(interaction, id);
} else if (interaction.isAutocomplete()) {
const focused = interaction.options.getFocused();
await interaction.respond((await searchTopics(focused)).slice(0, 25).map(t => ({ name: t, value: t })));
} else if (interaction.isModalSubmit()) {
// interaction.fields.getTextInputValue("field_id")
}
} catch (err) {
console.error(err);
if (!interaction.isRepliable()) return;
const msg = { content: "Something went wrong.", flags: MessageFlags.Ephemeral } as const;
interaction.deferred || interaction.replied ? await interaction.followUp(msg) : await interaction.reply(msg);
}
});
process.on("SIGTERM", () => { client.destroy(); process.exit(0); });
await client.login(process.env.DISCORD_TOKEN);
Component rules:
customIdmax 100 chars; encodeaction:recordIdand look the record up. Stateless handlers keep working after restarts and across shards.- Up to 5 action rows per message, 5 buttons per row (or one select menu per row).
- Buttons:
interaction.update(...)edits the original message;deferUpdate()if slow. - Modals:
interaction.showModal(modal)must be the first response (cannot follow a defer). - Autocomplete must answer within 3 seconds with at most 25 choices; it cannot be deferred.
- discord.js also supports the newer display components (Components V2, enabled per message with a flag); use them only after confirming the installed version supports them.
6. Slow work and errors
| Error | Meaning | Fix |
|---|---|---|
Unknown interaction (10062) | Responded after 3 s, or the token expired | deferReply() first; move slow I/O after it |
Interaction has already been acknowledged (40060) | Replied twice | Use editReply/followUp after the first response |
Missing Access / Missing Permissions (50001/50013) | Bot role lacks channel permission or sits below the target role | Fix role order/permissions; check with channel.permissionsFor(client.user) |
Used disallowed intents | Privileged intent requested but not enabled in portal | Enable it or drop it |
Message content is empty | MessageContent intent not granted | Use slash commands, or justify the intent |
Rate limits
- discord.js’s REST manager queues per-route buckets and honors 429
retry_afterautomatically. Do not add your own parallel fan-out around it. - Global limit is about 50 requests per second per bot.
- Too many invalid requests (401/403/429) from one IP, roughly 10,000 per 10 minutes, trigger a temporary Cloudflare ban. Stop retrying on 403s; fix permissions instead.
- Bulk DMs and mass role edits belong in a throttled job queue.
HTTP interactions endpoint (serverless option)
Discord signs each request with Ed25519 (X-Signature-Ed25519, X-Signature-Timestamp).
Verify against the raw body with the app’s public key, reply to PING (type: 1) with
{ type: 1 }, and return 401 on failure; Discord sends deliberately invalid signatures when
you save the endpoint URL and rejects endpoints that accept them.
import { verifyKey, InteractionType, InteractionResponseType } from "discord-interactions";
export async function POST(req: Request) {
const raw = await req.text();
const ok = await verifyKey(raw, req.headers.get("x-signature-ed25519")!, req.headers.get("x-signature-timestamp")!, process.env.DISCORD_PUBLIC_KEY!);
if (!ok) return new Response("bad signature", { status: 401 });
const body = JSON.parse(raw);
if (body.type === InteractionType.PING) return Response.json({ type: InteractionResponseType.PONG });
// Commands: return DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, then PATCH the original via webhook later
return Response.json({ type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: "pong" } });
}
Sharding
// src/shard.ts
import { ShardingManager } from "discord.js";
const manager = new ShardingManager("./dist/index.js", { token: process.env.DISCORD_TOKEN, totalShards: "auto" });
manager.on("shardCreate", s => console.log(`Shard ${s.id} launched`));
await manager.spawn();
Cross-shard data (guild counts, caches) goes through client.shard.fetchClientValues /
broadcastEval or an external store. Keep per-guild state in a database, not process memory.
Gotchas
- Requesting
GatewayIntentBits.MessageContent“just in case”: blocks verification later. - Using global commands during development and waiting on stale definitions; use a guild.
- Collectors holding flow state: a deploy mid-flow strands every open button.
- Bot role ordered below the roles it must manage.
interaction.guildis null in DMs and user-installed contexts; guard it.- Logging the whole interaction object: it contains the interaction token.
Verification
- Deploy to a test guild; run each command, button, select, modal, and autocomplete path.
- Add an artificial 4-second delay inside a handler after
deferReplyand confirm noUnknown interaction. - Restart the bot, then click a button posted before the restart: it still works.
- Remove a permission from the bot role and confirm a clear ephemeral error, not a crash.
- HTTP mode: a request with a bad signature returns 401 and PING returns PONG.
Output contract
**Bot:** triage-bot (discord.js 14.x, Node 20)
**Intents:** Guilds (no privileged intents)
**Commands:** /ticket (guild-only, SendMessages), deployed via src/deploy-commands.ts
**Components:** ticket-close:<id> button (stateless), topic autocomplete
**Invite scopes:** bot applications.commands, permissions: Send Messages, Embed Links
**Tested:** all paths in test guild, restart survival, slow-handler defer
**Scale plan:** ShardingManager at ~1,500 guilds About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.