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.

Each option's More settings adds what Discord itself enforces before the command even reaches Mallard: choices (a fixed list of { name, value }, up to 25), minValue/maxValue on integer and number options, minLength/maxLength on strings, and channelTypes to limit a channel option to, say, voice channels. Option types are string, integer, number, boolean, user, channel, role, mentionable (a user or a role) and attachment.

Autocomplete. An option with more than 25 choices, or an autocompleteKey naming a key-value entry that holds a JSON array (of strings or { name, value }), suggests matches as the user types. Mallard answers the suggestions itself from that list, fuzzy-matched, without running your script, which is the only way to answer inside Discord's 3-second window. Keep the list current from any script with setKV.

Subcommands. Name a script with spaces to make it a subcommand: role add and role remove register as one /role command with two subcommands, and config channel set adds a subcommand group. Each subcommand is its own script with its own options, Ephemeral switch and required permissions (checked when it's invoked, since Discord only knows one permission setting per top-level command). ctx.commandPath is the full name and ctx.subcommand its last word. A plain role script and role … subcommands can't coexist.

Option types → ctx.args

Whatever the Discord option type, the value that lands in ctx.args is always a plain string, as it always has been. ctx.options holds the same values typed (integer and number options as numbers, booleans as booleans), and ctx.resolved holds the users, members, roles, channels and attachments the options point at, so a user option needs no getMember() call.

Option type What arrives in ctx.args.<name>
String the text, verbatim
Integer the number, as a string (ctx.options has it as a number)
Number the number, as a string (ctx.options has it as a number)
Boolean "true" or "false" as a string (ctx.options has it as a boolean)
User the target user's Discord snowflake ID as a string; their profile and member data are in ctx.resolved.users/members
Channel the target channel's snowflake ID as a string (limit the kinds with channelTypes); detail in ctx.resolved.channels
Role the target role's snowflake ID as a string; detail in ctx.resolved.roles
Mentionable a user's or a role's snowflake ID as a string; look it up in ctx.resolved to tell which
Attachment the uploaded file's attachment ID; its url, name and size are in ctx.resolved.attachments

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 &quot;…: Modal&quot; triggers

SlashCommand: Modal, MessageCommand: Modal, UserCommand: Modal, ButtonClick: Modal and SelectMenu: 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, description, minLength/maxLength, and value to prefill it. A prefill can use {{args.name}} (a command option), {{payload}}, {{message.content}}, {{member.displayName}} or {{kv:key}}, so an "edit your application" form opens with the saved answer. On submit, every answer lands in ctx.interaction.values, keyed by id: ctx.interaction.values["details"].

An input's type can also be select (with options of { label, value, description?, emoji?, default? }), userSelect, roleSelect, channelSelect, mentionableSelect, checkbox, checkboxGroup, radio, file (uploads, with maxValues and fileTypes), or textDisplay (a block of text, with text instead of an id and label). Selects and checkbox groups answer with an array, a checkbox with "true"/"false", and uploaded files land in ctx.interaction.files[id].

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.

Filters in the same config

A script's trigger filters (see Triggers ) are a third member of that same object, written by the editor's Filters card. The registrar reads options, the modal builder reads title/inputs, and the filter evaluator reads filters; each ignores the rest. You only need this shape if you're hand-editing a config or a backup file:

{
  "options": [ … ],
  "filters": {
    "channels": { "mode": "allow", "ids": ["112233445566778899"] },
    "roles":    { "mode": "deny",  "ids": ["998877665544332211"] },
    "users":    { "mode": "deny",  "ids": ["445566778899001122"] },
    "emoji":    { "mode": "allow", "values": ["⭐", "starboard"] },
    "auditActions": { "mode": "allow", "values": ["MemberBanAdd", "MemberKick"] },
    "content":  { "mode": "matches", "regex": "^!ticket\\b", "ignoreCase": true },
    "allowBots": false,
    "attachments": "any",
    "cooldown": { "seconds": 30, "scope": "script" }
  }
}

Every member is optional, and one with an empty list filters nothing. mode is "allow" or "deny"; content.mode is "matches" or "notMatches", and takes either a regex or a contains array of phrases; attachments is "any", "required" or "none"; cooldown.scope is "script" or "user". A filter naming something the trigger doesn't carry is ignored rather than blocking, but a filters member that can't be read at all stops the script running, so a hand-edited config is checked when you save it.

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.

Command options (TriggerConfig)
{
  "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, and 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));
}
var target = getMember(userId);
var targetName = target ? target.username : userId;
editResponse("Banned " + targetName + " (" + 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 + form (TriggerConfig)
{
  "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.
if (!ctx.env.mod_alert_channel_id) {
    editResponse("Reporting isn't configured yet. Ask a mod to set mod_alert_channel_id.");
} else {
    var actor = getMember(ctx.actorId);
    var actorName = actor ? actor.username : ctx.actorId;
    sendEmbed(ctx.env.mod_alert_channel_id, "User report",
        `Reported: ${ctx.args.user}\nReporter: ${actorName} (${ctx.actorId})\n\n${ctx.interaction.values.details}`);
    editResponse("Report sent to the mods. Thank you.");
}

Buttons and select menus

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. A button with a url is a link button instead (no handler), and any button can carry an emoji.

Select menus work the same way through selects on a MessageOptions object: sendMessage(channelId, { content, selects: [{ handler, options: [...] }] }). A SelectMenu script runs with the picks in ctx.interaction.selected; user, role and channel menus pick server entities and resolve them in ctx.resolved. That's how a role picker is built.

Replying vs. updating. By default a click gets its own reply (the "thinking…" message editResponse() fills in). Turn on "Update the clicked message" on a ButtonClick or SelectMenu script and the click is acknowledged silently instead, and editResponse() rewrites the message the button sits on: pagination, toggles and counters without a trail of replies. The clicked message is already in ctx.interaction.message, and followUp() sends anything else, public or ephemeral.

An unhandled error has occurred. Reload 🗙