Commands & modals
How a slash command's options, a context-menu command, and a popup form each turn into values
your script can read. This is the piece that connects the dashboard's editor UI to
ctx.args and ctx.interaction.values.
Slash command options
On the dashboard's script editor, a SlashCommand (or
SlashCommand: Modal) script has a "Command Options" section: each row has
Name (the helper text warns "no hyphens: used as
ctx.args.name in scripts", since it becomes a JS identifier),
Type, Description, and a Req switch.
That editor UI is a form over one JSON object stored on the script:
{
"options": [
{ "name": "user", "type": "User", "description": "The user to ban", "required": true },
{ "name": "reason", "type": "String", "description": "Reason for the ban", "required": true },
{ "name": "duration_min", "type": "Integer", "description": "Duration in minutes (0 = permanent)", "required": false },
{ "name": "delete_message_days", "type": "Integer", "description": "Days of messages to delete", "required": false }
]
}
Discord enforces a 32-character name limit (lowercase, no spaces; the editor replaces hyphens with underscores as you type) and a 100-character description limit, and caps a single command at 25 options. Required options are automatically reordered before optional ones at registration time, regardless of the order you added them in. Discord requires that ordering, but the editor doesn't enforce it as you type.
Not supported: choices (a fixed dropdown of values), autocomplete,
min/max numeric bounds, channel-type filters, subcommands, or the
mentionable/attachment option kinds. If you need one of
those, validate inside the script body instead: e.g. read ctx.args.seconds
and editResponse() an error if it's out of range (the seeded
/slowmode does exactly this for its 0 to 21600 second cap).
Option types → ctx.args
Whatever the Discord option type, the value that lands in ctx.args is
always a plain string. There's no native JS number, boolean, or
object. ScriptArgs is typed as { [name: string]: string }
for exactly this reason.
| Option type | What arrives in ctx.args.<name> |
|---|---|
| String | the text, verbatim |
| Integer | the number, as a string; parseInt() it |
| Number | the number, as a string; parseFloat() it (not offered in the dashboard's Type dropdown; reachable only by hand-editing or importing a script's config) |
| Boolean | "true" or "false" as a string; compare with === "true" |
| User | the target user's Discord snowflake ID as a string; call getMember(id) if you need their username/roles |
| Channel | the target channel's snowflake ID as a string; no channel-type filter is supported, any channel can be picked |
| Role | the target role's snowflake ID as a string |
Message & user context-menu commands
MessageCommand and UserCommand (right-click a message or a
user → Apps) have no options at all; there's nothing to configure beyond the command's
own Name and Description. Unlike slash commands,
their names can include spaces and capital letters, and are matched verbatim rather
than lowercased (the seeded examples are literally named Report Message
and View User Notes). The right-clicked target arrives as
ctx.message (MessageCommand) or ctx.member (UserCommand,
where ctx.actorId is the invoking moderator, not the target). See
the ctx object.
The four "…: Modal" triggers
SlashCommand: Modal, MessageCommand: Modal,
UserCommand: Modal and ButtonClick: Modal each open a popup
form immediately. Discord requires a modal to be the interaction's first
response, so the script's own body doesn't run until the form is submitted. On the
dashboard, this is the script's "Form (Modal) Definition" section: a JSON object with
title and up to 5 inputs:
{
"title": "Report this message",
"inputs": [
{ "id": "details", "label": "What's wrong with it?", "style": "paragraph", "required": true }
]
}
Each input has id (the key it's read back under), label,
style ("short" for a single line or
"paragraph" for multi-line; short is the default), required
(defaults to true, unlike command options which default to
false), and an optional placeholder. On submit, every answer
lands in ctx.interaction.values, keyed by id:
ctx.interaction.values["details"].
SlashCommand: Modal is the one case where a script's config carries both
shapes at once. The registrar reads options (to build the
slash command) and the modal builder reads title/inputs (to
build the form) from the same JSON object, each ignoring what it doesn't recognize. So
on submit, the script sees both the command's own options in
ctx.args and the form's answers in
ctx.interaction.values. The worked example below shows this combination.
ButtonClick: Modal is the odd one out: it has no command options at all
(a button has no slash-command surface), so only title/inputs
apply. The button's own payload, set by whichever script sent it via the
sendMessage/editResponse button array, is still available as
ctx.interaction.payload alongside the form's answers.
Worked example: /ban
Adapted from the seeded /ban command (the mod-role permission guard it's
normally wrapped in is omitted here for brevity). Shows one required User
option, one required String, and two optional Integer
options, with the parseInt() conversion and the
ctx.args.reason || "No reason provided" fallback idiom used throughout
the seeded scripts.
{
"options": [
{ "name": "user", "type": "User", "description": "The user to ban", "required": true },
{ "name": "reason", "type": "String", "description": "Reason for the ban", "required": true },
{ "name": "duration_min", "type": "Integer", "description": "Duration in minutes (0 = permanent)", "required": false },
{ "name": "delete_message_days", "type": "Integer", "description": "Days of messages to delete", "required": false }
]
}
Script body
var userId = ctx.args.user;
var reason = ctx.args.reason || "No reason provided";
var durationMin = ctx.args.duration_min ? parseInt(ctx.args.duration_min) : 0;
var deleteDays = ctx.args.delete_message_days ? parseInt(ctx.args.delete_message_days) : 0;
var isPermanent = durationMin <= 0;
var target = getMember(userId);
var targetName = target ? target.username : userId;
// DM before the ban: afterwards there's no mutual guild left to DM through.
// false means their DMs are closed — the ban still goes ahead.
var dmSent = sendDm(userId, "", [{
title: isPermanent ? "Permanent Ban" : "Temporary Ban",
description: "Reason: " + reason,
}]);
ban(userId, reason, deleteDays);
if (!isPermanent) {
// Actions can't be delayed, so log the unban due time to the KV store;
// a Scheduled script sweeps due entries on its next run.
var pending = JSON.parse(getKV("mod:pending_unbans") || "[]");
pending.push({ userId: userId, unbanAt: Date.now() + durationMin * 60000, reason: reason });
setKV("mod:pending_unbans", JSON.stringify(pending));
}
editResponse("Banned <@" + userId + ">. Reason: " + reason
+ (dmSent ? "" : " (could not DM them the appeal link)"));Worked example: /report
The seeded SlashCommand: Modal script. One command option
(user) plus a one-field form. The script reads ctx.args.user
and ctx.interaction.values["details"] together on submit.
{
"options": [
{ "name": "user", "type": "User", "description": "Who are you reporting?", "required": true }
],
"title": "Report a user",
"inputs": [
{ "id": "details", "label": "What happened?", "style": "paragraph", "required": true }
]
}
Script body
// Runs when the form is submitted: both the command's own option and the
// form's answer are available together.
var reportedUserId = ctx.args.user;
var details = ctx.interaction.values["details"];
if (!ctx.env.mod_alert_channel_id) {
editResponse("Reporting isn't configured yet — ask a mod to set mod_alert_channel_id.");
} else {
sendMessage(ctx.env.mod_alert_channel_id, "", [{
title: "User report",
description: "Reported: <@" + reportedUserId + ">\nReporter: <@" + ctx.actorId + ">\n\n" + details,
}]);
editResponse("Report sent to the mods. Thank you.");
}Buttons
ButtonClick and ButtonClick: Modal carry no options or form
config of their own for the plain case; a button's data comes from whichever script
sent it, via the buttons array on
sendMessage/sendDm/editResponse/editMessage:
each button is { label, style, payload, handler }, where
handler names the script that runs on click and payload
(max 50 chars) comes back to it as ctx.interaction.payload. Whether the
click replies immediately or opens a form is decided by the handler script's own
trigger type, not by anything set on the button. See
sendMessage for the full
button shape.