Creating custom slash commands on Discord
Adding custom slash commands for the Moonlight Game Streaming official Discord server
For context, I’m an administrator on the Moonlight Game Streaming official Discord server, which I created back in 2018.
In this blog post, I will explain our journey from using popular Discord bots for our custom commands to building our own, and why we did so.
What’s the problem?
A lot of times, people come asking the same kinds of questions with the same types of answers, or otherwise, we find ourselves repeating ourselves a lot.
So we needed a way to quickly reply to those questions with pre-defined guides, links, and other resources for our members. We needed a saved-replies feature, in a way.
Not the first, not the last
Of course, we’re not the first ones to have this problem or want this feature; lots of Discord bots have a custom commands feature, where you register a name members can use to invoke that command, and what action to execute when that command is called (most of the time, send a custom reply to the member).
Indeed, we used multiple of these bots to do exactly that (and more): Dyno, Mee6, YAGPDB, and even, at some point, our self-hosted Nadko bot.
They all worked as expected, but each has its own way of doing things: its own dashboard, its own command prefix, its own various limitations. And after a while, we started hitting those limitations.
For example, Dyno limited you to about a hundred commands when we first started using it. However, after some time, they actually lowered that limit. Thankfully, we didn’t lose access to those commands; they could still be invoked, but we couldn’t create new ones anymore.
Where are you?
They also have a discovery problem: since they’re invoked by a member sending a message with a prefix (like ?) and the command’s name, they need to know that name in advance, or get it from some list in some channel or using a ?help command to get it.
Discord’s built-in slash commands don’t have this issue, as when you start typing /, the client automatically lists available commands and allows you to search through them: typing “wiki” automatically suggests the /wiki command. It also provides autocomplete for more complex commands with options. For example, our /setup-guide command has an optional option to link to a specific section of the setup guide.
Do it yourself?
So, considering the limitations of our current solution(s) and managing multiple bots, I decided to start tinkering with Discord’s command interactions feature to see if I could build it myself.
Please register for more
Before you can receive and handle commands, you need to register them with Discord first. This is done by sending an API request to Discord with a JSON body defining properties about that command, like its type, name and description.
This was easy to do for global, static commands like the /setup-guide mentioned above, as it will always be there and return a static message defined in the code logic.
Here’s a simple example with registering the /wiki command:
{
"type": 1,
"name": "wiki",
"description": "Get a link to the wiki"
}
Commands can get complex quickly, though. Here is an example with the /setup-guide command, complete with options and translations:
{
"type": 1,
"name": "setup-guide",
"name_localizations": {
"fr": "guide-installation"
},
"description": "Get a link to the setup guide",
"description_localizations": {
"fr": "Obtenir un lien vers le guide d'installation"
},
"options": [
{
"type": 3,
"name": "section",
"name_localizations": {
"fr": "section"
},
"description": "The section of the setup guide to link to",
"description_localizations": {
"fr": "La section du guide à inclure dans le lien"
},
"choices": [
{
"name": "Quick Setup Instructions",
"value": "quick-setup-instructions"
},
{
"name": "Streaming over the Internet",
"value": "streaming-over-the-internet"
},
{
"name": "Moonlight Client Setup Instructions",
"value": "moonlight-client-setup-instructions"
},
{
"name": "Additional Requirements for HDR Streaming",
"value": "additional-requirements-for-hdr-streaming"
},
{
"name": "Keyboard/Mouse/Gamepad Input Options",
"value": "keyboardmousegamepad-input-options"
},
{
"name": "Adding custom programs that are not automatically found",
"value": "adding-custom-programs-that-are-not-automatically-found"
},
{
"name": "Using Moonlight to stream your entire desktop",
"value": "using-moonlight-to-stream-your-entire-desktop"
}
]
}
]
}
Dynamic commands are harder, though, as they need to be registered on creation with Discord’s API. I won’t go into the details here, but it took me a few tries to get it right.
// Try creating the command on Discord
try {
const response = await fetch(
`https://discord.com/api/v10/applications/${env.DISCORD_APPLICATION_ID}/guilds/${interaction.guild_id}/commands`,
{
method: "POST",
body: JSON.stringify({ name, description }),
headers: {
Authorization: "Bot " + env.DISCORD_TOKEN,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
return messageResponse({
content: "An error occurred while creating the command on Discord",
});
}
// Saving the command in Cloudflare KV goes here
return messageResponse({
content: `Your command has been successfully created: </${command.name}:${command.id}>`,
});
} catch (error) {
return messageResponse({
content: "An error occurred while creating the command",
});
}
Once that’s done, you can now browse available commands, autocomplete them, and send them away for processing.
Interactions are hard
Now that we’ve registered our commands, we’re ready to receive and handle invocations of them.
Slash commands are part of what Discord calls Interactions. You can invoke an interaction using a “chat input” or a context action on a message or a member.
There are two options to receive those interactions: through the real-time gateway or through a webhook. I chose the webhook method because of the hosting choice I talk about below. This means that every time a command is invoked, an interaction payload is sent as an HTTP request to the webhook URL defined inside Discord’s developer portal.
When you get a request, you need to parse the JSON blob attached to the body to know which command has been invoked.
After parsing the right command to execute, you can simply return an interaction response to Discord’s webhook request. For us, by asking it to reply with a message with that command’s contents, as defined in the database or in code.
For static commands, checking the name of the command is the easiest:
// Wiki command
if (interaction.data.name === "wiki") {
return Response.json({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: "https://github.com/moonlight-stream/moonlight-docs/wiki",
},
});
}
Dynamic commands, meanwhile, require checking the database:
// Custom commands
const command = await env.DISCORD_CUSTOM_COMMANDS.get(
interaction.guild_id + ":" + interaction.data.id
);
if (command) {
return Response.json({
type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
data: { content: command },
});
}
Custom commands are stored in Cloudflare KV, a key-store database (D1, a SQL database, wasn’t released while making this project).
I’m your host
The application is hosted on Cloudflare Workers on my personal, free account. Deployments are automated using GitHub Actions, specifically Cloudflare’s official Wrangler action. When a new commit is pushed, a new deployment is made.
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy
steps:
- uses: actions/checkout@v4
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
wranglerVersion: "4.38.0"
Using the free plan means we are limited to about 100000 requests per day, but that should be more than enough, even as right now the commands can only be executed by our small staff, not our nearly 50000 members.
Things as they are
Today, at the end of the year 2025, we have our Discord app running and responding to commands from staff only, as a beta test of sorts. The system has been stable and seems now bug-free, though as we plan to open up the commands to everybody, no doubt some unforeseen consequences will come up.
The code source for the app is open-sourced here, but be warned, the code is messy and ugly (but it works!): https://github.com/jorys-paulin/moonlight-discord
Thanks for reading!