Bỏ qua để đến nội dung

Modal Input Components

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

Discord introduced new interactive input components for modals beyond the original Text Input. These components enable richer form-like experiences with structured selection inputs.

TypeNameDescriptionContainer
4Text InputFree-form text entry (original modal input)Action Row or Label
5User SelectSelect a user from the server or client contextLabel
6Role SelectSelect a role from the server contextLabel
8Channel SelectSelect a channel from the server contextLabel
18LabelWrapper component for new modal inputs
21Radio GroupSelect exactly one option from a listLabel
22Checkbox GroupSelect one or many options from a listLabel
23CheckboxSingle yes/no toggleLabel
  • Label wrapper required for structured inputs: Radio Group, Checkbox Group, Checkbox, User Select, Role Select, and Channel Select must be placed inside a Label component (type 18), not an Action Row. Text Inputs can use the older Action Row layout; TomoriBot’s raw modal path wraps them in Labels so the same form can carry descriptions and structured inputs.
  • Modal-only: These components are only available in modals; they cannot be used in message payloads.
  • Submit data structure: The interaction response nests the input component inside the Label’s component field, not in an ActionRow.components array.

A Label is a container component for wrapping new modal input types. It provides a visible label and optional description above the input.

Labels are analogous to Action Rows for Text Inputs, but designed specifically for the newer input components.

FieldTypeDescription
typeinteger18 for label
id?integerOptional identifier for component
labelstringText displayed above the input
description?stringOptional description text displayed below label
componentcomponentThe input component (Radio Group, Checkbox Group, or Checkbox)

A Radio Group allows the user to select exactly one option from a defined list. Useful for mutually exclusive choices like provider selection, mode switches, or preference settings.

FieldTypeDescription
typeinteger21 for radio group
id?integerOptional identifier for component
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
optionsarray of radio group optionsList of options to show; min 2, max 10
required?booleanWhether a selection is required to submit the modal (default: true)
FieldTypeDescription
valuestringDev-defined value of the option; max 100 characters
labelstringUser-facing label of the option; max 100 characters
description?stringOptional description for the option; max 100 characters
default?booleanShows the option as selected by default
FieldTypeDescription
typeinteger21 for a Radio Group
idintegerUnique identifier for the component
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
value?stringThe value of the selected option, or null if no option is selected
{
"type": 9,
"data": {
"custom_id": "class_selection_modal",
"title": "Class Selection",
"components": [
{
"type": 18,
"label": "Choose your class",
"description": "Your class determines the style of play for your character.",
"component": {
"type": 21,
"custom_id": "class_radio",
"options": [
{"value": "warrior", "label": "Warrior", "description": "Strong and brave"},
{"value": "rogue", "label": "Rogue", "description": "Weak and squishy"},
{"value": "wizard", "label": "Wizard", "description": "Nerd"},
{"value": "bard", "label": "Bard", "description": "Annoys everyone"},
{"value": "witch_doctor", "label": "Witch Doctor", "description": "Actually a pretty cool option"}
]
}
}
]
}
}
{
"type": 5,
"data": {
"custom_id": "class_selection_modal",
"components": [
{
"id": 1,
"type": 18,
"component": {
"custom_id": "class_radio",
"id": 2,
"type": 21,
"value": "warrior"
}
}
]
}
}

A Checkbox Group allows the user to select one or many options from a list. Ideal for multi-select scenarios like capability toggles, feature flags, or day-of-week selection.

FieldTypeDescription
typeinteger22 for checkbox group
id?integerOptional identifier for component
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
optionsarray of checkbox group optionsList of options to show; min 1, max 10
min_values?integerMinimum items that must be chosen; min 0, max 10 (default: 1); if 0, required must be false
max_values?integerMaximum items that can be chosen; min 1, max 10 (default: number of options)
required?booleanWhether selecting within the group is required (default: true)
FieldTypeDescription
valuestringDev-defined value of the option; max 100 characters
labelstringUser-facing label of the option; max 100 characters
description?stringOptional description for the option; max 100 characters
default?booleanShows the option as selected by default
FieldTypeDescription
typeinteger22 for a Checkbox Group
idintegerUnique identifier for the component
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
valuesarray of stringsThe values of the selected options, or [] if no options are selected
{
"type": 9,
"data": {
"custom_id": "day_selection_modal",
"title": "Study Days",
"components": [
{
"type": 18,
"label": "Which days are you free?",
"description": "Choose all of the days you're able to meet up.",
"component": {
"type": 22,
"custom_id": "event_checkbox",
"options": [
{"value": "march-4", "label": "March 4th"},
{"value": "march-5", "label": "March 5th"},
{"value": "march-7", "label": "March 7th", "description": "I know this is a Saturday and is tough"},
{"value": "march-9", "label": "March 9th"},
{"value": "march-10", "label": "March 10th"}
]
}
}
]
}
}
{
"type": 5,
"data": {
"custom_id": "day_selection_modal",
"components": [
{
"id": 1,
"type": 18,
"component": {
"custom_id": "event_checkbox",
"id": 2,
"type": 22,
"values": [
"march-5",
"march-10",
"march-4"
]
}
}
]
}
}

A Checkbox is a single toggle for simple yes/no questions. Unlike Checkbox Group (which provides a list of options), a standalone Checkbox is a single binary input.

FieldTypeDescription
typeinteger23 for checkbox
id?integerOptional identifier for component
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
default?booleanWhether the checkbox is selected by default

Note: Checkboxes cannot be set as required. To achieve required single-option behavior, use a Checkbox Group with one option and required: true.

FieldTypeDescription
typeinteger23 for a Checkbox
idintegerUnique identifier for the component
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
valuebooleanThe state of the checkbox (true if checked, false if unchecked)
{
"type": 9,
"data": {
"custom_id": "secret_note_modal",
"title": "Secret Note",
"components": [
{
"type": 18,
"label": "Do you like me?",
"component": {
"type": 23,
"custom_id": "like_checkbox"
}
}
]
}
}
{
"type": 5,
"data": {
"custom_id": "secret_note_modal",
"components": [
{
"id": 1,
"type": 18,
"component": {
"custom_id": "like_checkbox",
"id": 2,
"type": 23,
"value": true
}
}
]
}
}

A User Select modal component allows picking a Discord user from the client/guild context. In modals, it is wrapped in a Label component (type 18).

FieldTypeDescription
typeinteger5 for User Select
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
min_values?integerMinimum number of users that must be selected (default: 1)
max_values?integerMaximum number of users that can be selected (default: 1)
required?booleanWhether a selection is required before submitting (default: true)
{
"type": 9,
"data": {
"custom_id": "moderation:v1:user-blacklist-add-submit:en-US:nonce123",
"title": "Add Blacklist",
"components": [
{
"type": 18,
"label": "Member",
"description": "Choose a member to exclude from personalization.",
"component": {
"type": 5,
"custom_id": "userblacklist_add_user_nonce123",
"min_values": 1,
"max_values": 1,
"required": true
}
}
]
}
}
{
"type": 5,
"data": {
"custom_id": "moderation:v1:user-blacklist-add-submit:en-US:nonce123",
"components": [
{
"id": 1,
"type": 18,
"component": {
"custom_id": "userblacklist_add_user_nonce123",
"id": 2,
"type": 5,
"values": ["123456789012345678"]
}
}
]
}
}

TomoriBot’s /moderation removal actions combine up to five Checkbox Groups with ten options each. Every entry is initially checked. Unchecking an entry expresses removal on submit, without a second confirmation. The route stores the presented values under the modal nonce and consumes that snapshot once, so it can distinguish an unchecked entry from an entry added concurrently after the modal opened. Lists above 50 entries must use a bounded fallback instead of silently truncating the modal.

The /moderation Personas add modal combines a String Select for the configured persona with a native Channel Select restricted to text channels. The server persona limit is below Discord’s 25-option String Select limit.


A Channel Select modal component allows picking a Discord channel from the server context, with optional channel type filtering (e.g. GuildText). In modals, it is wrapped in a Label component (type 18).

FieldTypeDescription
typeinteger8 for Channel Select
custom_idstringDeveloper-defined identifier for the input; 1-100 characters
channel_types?array of integerAllowed channel type integers (e.g. [0] for GuildText)
min_values?integerMinimum number of channels that must be selected (default: 1)
max_values?integerMaximum number of channels that can be selected (default: 1)
required?booleanWhether a selection is required before submitting (default: true)
{
"type": 9,
"data": {
"custom_id": "moderation:v1:whitelist-channel-add-submit:en-US:nonce123",
"title": "Add or Edit Channel",
"components": [
{
"type": 18,
"label": "Channel",
"description": "Choose a text channel to whitelist.",
"component": {
"type": 8,
"custom_id": "whitelist_channel_add_channel_nonce123",
"channel_types": [0],
"min_values": 1,
"max_values": 1,
"required": true
}
}
]
}
}
{
"type": 5,
"data": {
"custom_id": "moderation:v1:whitelist-channel-add-submit:en-US:nonce123",
"components": [
{
"id": 1,
"type": 18,
"component": {
"custom_id": "whitelist_channel_add_channel_nonce123",
"id": 2,
"type": 8,
"values": ["123456789012345678"]
}
}
]
}
}

A Role Select modal component picks one or more roles from the current server. It uses component type 6 and is wrapped in a Label component (type 18) in modal payloads. Routed Role Select values use the same nonce-bounded, consume-once transport as User and Channel Select fields.

{
"type": 18,
"label": "Role",
"description": "Choose a role whose members can trigger me.",
"component": {
"type": 6,
"custom_id": "whitelist_role_add_role_nonce123",
"min_values": 1,
"max_values": 1,
"required": true
}
}

Submission data carries the selected role snowflake in the nested component’s values array. The handler must resolve it again through the current guild role manager and reject the everyone role before writing.


Use this decision guide when choosing between modal input types.

ComponentUse WhenAvoid When
Text InputFree-form text entry: names, prompts, tags, API keys, URLsThe input is a choice from a known set of options
String SelectLarge option sets (11+), dynamic/growing lists, options needing emoji or rich descriptionsSmall fixed set of mutually exclusive options (use Radio Group instead)
Radio GroupSmall fixed set of mutually exclusive options (2-10), unlikely to grow beyond 10Option list is dynamic or may exceed 10 items (use String Select instead)
Checkbox GroupMultiple items can be selected from a list (1-10 options), OR a single required yes/no toggleMutually exclusive choice (use Radio Group)
CheckboxSingle optional yes/no or on/off toggle questionThe answer is required (use Checkbox Group with 1 option instead)

Many TomoriBot modals include yes/no, enable/disable, or true/false string selects. These should be migrated to:

  • Optional booleanCheckbox: Unchecked submits as false, checked as true. The user can leave it unchecked and still submit.
  • Required booleanCheckbox Group with 1 option: Set required: true and provide a single option. This forces the user to explicitly check it before submitting; acting as a required confirmation or acknowledgment.
// Required boolean workaround: Checkbox Group with 1 option
{
"type": 18,
"label": "Enable this server?",
"component": {
"type": 22,
"custom_id": "enable_toggle",
"required": true,
"options": [
{"value": "true", "label": "Yes, enable"}
]
}
}
Is the input free-form text?
└─ Yes → Text Input
└─ No → Is it a single yes/no question?
└─ Yes → Is the answer required?
└─ Yes → Checkbox Group (1 option, required: true)
└─ No → Checkbox
└─ No → Can the user select multiple options?
└─ Yes → Checkbox Group (if ≤10 options)
└─ No → Is the option set small and fixed (≤10)?
└─ Yes → Radio Group
└─ No → String Select (supports 25+ via pagination)
  • Radio Group: 2-10 options. No emoji support. No placeholder text.
  • Checkbox Group: 1-10 options. Supports min_values/max_values for range control. Also serves as the workaround for required single-boolean inputs.
  • Checkbox: Cannot be required. Use a Checkbox Group with 1 option if required behavior is needed.
  • String Select: Up to 25 options natively. Flows on the anchor message workflow must use selection.openModal(...) / openAnchorModal(...), whose >25 bridge keeps the range selector on the anchor Components V2 message. Flows still on promptWithPaginatedModal() may pass selectorStyle: "componentsV2" to render that same >25 selector (1-25/26-50 + Previous/Cancel/Next), or omit it for the legacy numbered page-button embed (default). Both selectors share buildRangeSelectorPayload. Supports emoji, descriptions, and placeholder text.
  • All new components must be wrapped in a Label (type 18), not an Action Row.

Inside a anchor message workflow, do not call promptWithPaginatedModal() or promptWithRawModal() directly because they would open the modal outside the anchor message and strand its controls. Open the modal from the workflow instead. After a persona is selected that means the selection phase from runPersonaPickerWorkflow(...):

const modal = await selection.openModal(modalOptions);

Non-persona commands reach the same bridge through openAnchorModal(...) (src/utils/discord/ui/anchorModelFlow.ts), which wraps the call below and renders the error terminal for transport failures.

When modalOptions are already in memory and the select has at most 25 choices, openModal calls showModal() as the selected button’s first acknowledgment. Do not call beginInPlaceWork() first; a deferred button cannot open a modal.

If building the options requires DB, filesystem, or network work, pass an asynchronous factory instead:

const modal = await selection.openModal(async () => {
const rows = await repository.loadOptions(selection.persona.persona_id);
return buildModalOptions(rows);
});

The factory form immediately update-defers the persona button, replaces the picker with a localized loading state, and only then runs the factory. Because that button is now consumed, the workflow renders a fresh Open Form button for at most 25 choices or a range selector for more than 25. The fresh button opens the modal as its first acknowledgment.

For larger sets, range buttons represent absolute slices of 25 (1-25, 26-50, …). Range navigation, cancellation, and timeout replace the same anchor message. The chosen range button opens the sliced modal, and a submitted phase reports optionOffset; add it to page-local indexes when the option values themselves are not stable IDs.

The bridge slices exactly one select component and treats every entry as a selectable option. A modal that breaks either assumption (several selects sharing one option list, or a reserved entry such as an explicit “None” that must appear on every page) cannot use it. Those pick a range up front with acquireModalOptionRange(...), passing a pageSize below 25 to leave room for the reserved entries, and then open a modal whose list is already sliced to 25 or fewer.

After submission, call modal.phase.beginInPlaceWork() before any slow validation or write. It update-defers the message-backed modal submission and returns the anchor-message controller. For an already-built terminal payload, modal.phase.replace(payload) instead uses the unacknowledged message-backed submission’s update() as the first acknowledgment. Do not call both operations for one submission. If openModal(...) returns fatal, stop the callback (the normal pattern is throw modal.error). The runner also records that fatal state, so even an accidental retry directive cannot reopen the picker.

PERSONA_WORKFLOW_COMPONENT_TIMEOUT_MS controls how long the in-place launcher/range buttons remain active (default 120000 ms). It does not alter Discord’s modal lifetime.

When a modal is editing an existing list of configured items, prefer Checkbox Groups over a one-at-a-time String Select when the full set fits in a single modal.

  • Pre-check every current entry and treat unchecked items as “remove” or “disable”.
  • Use min_values: 0 and required: false so users can submit with every item unchecked.
  • Chunk one category across multiple groups of 10 options, or split different entity types into separate groups.
  • Give the first group a domain title such as Whitelisted Personas and a short instruction such as “Uncheck box then submit to remove whitelist.” Name later groups Continuation (1), Continuation (2), and so on, without repeating the description.
  • Respect Discord’s modal ceiling: 5 checkbox groups, 10 options each, 50 total entries.
  • If the list exceeds 50, keep the originating control panel visible and show a temporary yellow selection receipt above it with bounded range buttons such as 1-50 and 51-100. A range button opens a modal containing only that snapshot. Repaint or remove the receipt after the bounded workflow ends; never silently truncate.
  • For persistent setting commands, treat checked items as the stored enabled-set and write the full checked set back on submit.

Implemented examples:

  • /moderation manages blacklist, channel, persona, and role removals through domain-specific checklist modals.
  • /config > Channels > Channel Rules manages the full saved private-channel set in one modal, with paginated fallback beyond 50 channels.
  • /config > Channels > Channel Rules manages the full saved RP-channel set in one modal, with paginated fallback beyond 50 channels.
  • /config > Channels > Channel Rules manages a persistent channel blocklist with saved check states and paginated fallback beyond 50 channels.
  • /config > Engine > Notices manages visible notice embed types in one modal.
  • /model override remove remains a combined aggregate managing channel and persona overrides together, opening direct raw modal batches up to 50, and offering page selection above that.
  • MCP registrations are deliberately excluded from this pattern: the /config > Plugins > MCP Servers page removes one registration at a time behind an explicit confirmation, so selection never becomes destructive consent.
  • /config > Models > Fallbacks & Randomizer manages the fallback chain in one modal, and each slot can be cleared directly with the built-in None option.
  • /config > Behavior > Trigger removes random triggers in one modal when the set fits; beyond 50 schedules, Remove first repaints the page into an explicit removal-range state whose selector opens each batch.
  • /config > Behavior > Trigger adds a random trigger for any persona page: past 24 selectable personas the Add button becomes a range select that opens the modal on the chosen page, because the modal repeats its fixed Random entry on every page and that entry spends one of Discord’s 25 option slots.
  • /server trigger remove manages trigger words for the selected persona in one modal when the set fits, with paginated fallback beyond modal limits.

The setup wizard builds every modal in src/utils/discord/ui/setupPanel.ts from the draft it is editing, and each one is a working example of the nesting rule above. Copy the shape from here rather than from memory, because a raw component’s type is a bare number and a wrong one still compiles, still submits, and reads back as nothing:

ModalBuilderRows
Provider API KeybuildSetupCatalogModalLabel (18) around a provider select (3), Label (18) around an API key input (4)
Custom Endpoint connectionbuildSetupEndpointConnectionModalLabel (18) around an API style select (3), then one Label (18) each around the label, URL, and optional auth token inputs (4)
Custom Endpoint text modelbuildSetupEndpointModelModalLabel (18) around the model code input (4), Label (18) around the context size input (4), Label (18) around a capability checkbox group (22)
User BYOKbuildSetupByokModalA Text Display (10) explaining the mode, then a required yes/no Radio Group (21) in a Label (18)
Starting SettingsbuildSetupSettingsModalFour Labels (18): a persona select (3), a reply style select (3), a timezone input (4), and a system prompt select (3)
Policies acceptancebuildSetupPoliciesModalA Text Display (10) carrying both documentation links, then a required Checkbox Group (22) in a Label (18) with one option per document

Two consequences are load-bearing. Every value the wizard needs to read back sits inside a type: 18 Label, because the raw-modal interception is gated on the packet carrying a type-18 component and walks only type-18 children: a control at the modal root renders, submits, and arrives empty. And a modal string select cannot be pre-filled, so the wizard’s three selects reopen on their placeholders while its timezone text input comes back carrying the stored value, which has to parse as the value the editor wrote rather than as the label the panel shows.


A full survey of all modals in the codebase, categorized by migration eligibility.

These modals use a String Select with a small, fixed, mutually exclusive option set that is unlikely to grow beyond 10:

CommandFileCustom IDCurrent InputOptionsWhy Radio Group
/config > Engine > Generalconfig/humanizer.tshumanizer_selectString Select4-5 (none/light/moderate/heavy; + inherit with scope: Persona)Fixed set of mutually exclusive degrees
/setuputils/discord/ui/setupPanel.tshumanizer_{nonce}String Select4 (none/light/default/heavy)Same fixed humanizer degree set as above; shipped inside the Starting Settings modal’s type: 18 Label
/personal configutils/discord/ui/personalConfigPanel.tsprivacy_selectString Select3 (minimal/partial/full)Fixed set of 3 mutually exclusive levels
/generate imagegenerate/image.tsaspect_ratio_selectString Select10 (1:1, 2:3, 3:2, 3:4, 4:3, etc.)Fixed set of 10 aspect ratios (at the limit)
/config > Plugins > MCP Servers Add formdiscord/ui/mcpsPanel.tsserver-type_{nonce}Radio Group3 (General Purpose/Web Search/URL Fetcher)Already migrated: required routed field with General Purpose selected by default
/compactcompact.tssummary_typeString Select2 (conversation/roleplay)Fixed binary mode selection

Strong Candidates: Checkbox / Checkbox Group (Boolean Selects)

Section titled “Strong Candidates: Checkbox / Checkbox Group (Boolean Selects)”

These modals currently use a 2-option String Select (yes/no, true/false, enable/disable) that should become a Checkbox or Checkbox Group depending on whether the answer is required:

CommandFileCustom IDCurrent OptionsRequiredMigration Target
/config > Engine > Triggerconfig/randomtrigger/add.tsrespond_to_selfYes / NoYesCheckbox Group (1 option, required)
/compactcompact.tsrefresh_contextYes / NoYesCheckbox Group (1 option, required)
/compactcompact.tsanalyze_imagesYes / NoYesCheckbox Group (1 option, required)
/config provider switchconfig/provider/switch.tssave_current_selectYes / No (default: Yes)NoCheckbox (default: true, rarely unchecked)
/respondrespond.tsuse_reasoningYes / NoNoCheckbox (optional toggle)
/persona exportpersona/export.tsexport_json_selectFalse / TrueNoCheckbox (optional toggle)

Note on /config provider switch: This modal has two migration candidates: the save-current-config toggle becomes a Checkbox (default checked, since users almost always want to save). The provider select itself is dynamic (loaded from DB via loadUniqueProviders()), so it stays as a String Select.

Note on /compact: This modal has three migration candidates: summary_type becomes a Radio Group, while refresh_context and analyze_images both become required Checkbox Groups.

Strong Candidates: Checkbox Group Bulk Management

Section titled “Strong Candidates: Checkbox Group Bulk Management”

These commands still remove one dynamic item at a time, but the data shape is a good fit for the unchecked-means-remove pattern:

CommandFileCurrent InputWhy Checkbox Groups FitNotes
/config > Persona > Identity & Personalitypersona/attribute/remove.tsPersona picker + single paginated selectPersonality attributes are usually reviewed and pruned in batchesNeeds index-safe array rewrite if duplicate attributes must be preserved
/scheduled-task removescheduled-task/remove.tsPersona picker + single paginated selectReminder cleanup is often batch-oriented, especially for stale schedulesManager-only reminder views may need concise descriptions
/config > Persona > Identity & Personalitypersona/sample-dialogue/remove.tsPersona picker + single paginated selectDialogue cleanup is often batch-oriented and already has index-safe removalGood fit for index-valued checkbox groups
/persona removepersona/remove.tsSingle paginated selectAlter persona cleanup could be batch-managedShould pair the bulk UI with stronger destructive-action messaging

These modals have dynamic or large option sets that exceed Radio Group/Checkbox Group limits:

CommandFileReason
/config provider switchconfig/provider/switch.tsProvider list is dynamic (DB via loadUniqueProviders())
/config > Models > Switch Modelsconfig/model/text.tsDynamic model list, often 25+, uses pagination
/config > Models > Switch Modelsconfig/model/image.tsDynamic model list, uses pagination
/config > Models > Switch Modelsconfig/model/vision.tsDynamic model list, uses pagination
/config > Models > Switch Modelsconfig/model/embedding.tsDynamic model list, uses pagination
/config > Models > Fallbacks & Randomizerconfig/model/fallback.tsDynamic model list, uses pagination
/config > Engine > Generalconfig/system-prompt/preset.tsDynamic preset list from DB
/config provider addconfig/provider/add.tsProvider select + text input combo; list may grow
/config > Persona > Advancedpersona/prompt/set.tsComponents V2 persona workflow first, then a prefilled free-form prompt modal (up to 16000 chars, 4 fields)
/config > Persona > Identity & Personalitypersona/attribute/add.tsDynamic persona list, uses pagination
/config > Persona > Identity & Personalitypersona/sample-dialogue/add.tsDynamic persona list, uses pagination
/config > Persona > Appearancepersona/image-tags.tsComponents V2 persona workflow, then a prefilled free-form tag modal
/config > Persona > Identity & Personalitypersona/attribute/remove.tsDynamic attribute list, uses pagination
/scheduled-task removescheduled-task/remove.tsDynamic reminder list
/config > Channels > Logs & Welcomeserver/welcome-channel/set.tsChannel option + dynamic persona list

These modals collect free-form text and have no structured option set:

CommandFileReason
/config > Engine > Generalconfig/system-prompt/set.tsFree-form paragraph text (up to 16000 chars, 4 fields)
/config > Engine > Triggerconfig/random-trigger/add.tsFree-form trigger word/phrase (text input portion stays)
/config > Persona > Advancedutils/discord/ui/configModals.ts5 free-form text fields (author, title, tags, etc.)
/personal configutils/discord/ui/personalConfigPanel.tsFree-form physical appearance image tag text
/config > Models > Image Generation Defaultsconfig/image-tags/default-negative.tsFree-form default negative tag text
/config > Models > Image Generation Defaultsconfig/image-tags/default-positive.tsFree-form default positive tag text
/persona createpersona/create.tsFree-form text fields + file upload
/persona generatepersona/generate.tsFree-form name + file upload
/server trigger addserver/trigger/add.tsFree-form text fields (word, response, cooldown)
/server avatarserver/avatar.tsPersona select + optional file upload
/commentcomment.tsFree-form paragraph text
/import personal memoriesimport/personal/memories.tsFile upload only

/config > Models > Switch Models and /personal config > Models > Switch Models share one shape, built from buildModelRoutingControl and buildProviderPageEntries in src/utils/discord/ui/modelRoutingControls.ts:

  1. One String Select per capability, whose options are the eligible providers.
  2. Choosing a provider whose catalog fits a modal page opens a modal holding the model select.
  3. Choosing a provider whose catalog does not fit expands it in place into one option per page (Google (page 2)), and choosing a page opens the modal on that slice.

Two constraints force this shape rather than a second panel page. A modal select holds 25 options and cannot page inside itself, and a prev/next row cannot open the page it is parked on, so the range has to be chosen before the modal opens. Meanwhile a modal’s components do not count against the forty-component panel budget, so moving the catalog there is what lets six capability selectors share one page.

The two surfaces differ only where the domain differs. A user inherits from the server, so the leading option is Using Server Default on every capability; a server has nothing to inherit from, so the leading option is a clear, present only on the slots whose absorbed command offered one (CONFIG_CLEARABLE_MODEL_CAPABILITIES) and only while something is assigned.

Range navigation also differs by budget. /personal config renders a prev/next pagination row beneath each selector. /config cannot: six selectors would spend eighteen components on navigation alone, so its provider entries carry their own advance option (encodeConfigProviderRangeValue) which wraps at the last window. Fallbacks keeps the pagination row, because that page has one selector and the room for it.

A Discord text input holds 4,000 characters, so a long prompt is stored whole and split across several fields on open (splitPromptIntoModalParts) and rejoined on submit (combineModalPromptParts). Every such field must be labelled through promptPartLabel/promptPartDescription in src/utils/discord/ui/modalPromptPartLabels.ts, which renders Channel Prompt (Part 2 of 4) plus a description naming the part it continues. Identical labels on consecutive fields read as separate prompts and invite people to retype the whole thing in each one.

The Channel Prompt, System Prompt, and Persona Prompt modals all use it. Two-field editors that already carry an explicit (Part 2) label, such as attribute and sample-dialogue editing, are unaffected.

When a flow needs both a selection modal and a later prefilled edit modal, use:

  1. selection modal
  2. confirmation embed with buttons
  3. showModal() from the confirm button interaction

This is the pattern used by the attribute and sample-dialogue edit flows on /config > Persona > Identity & Personality.

For persona-scoped flows that already have a persistent ephemeral picker message, prefer replacing that same message with the confirmation embed and later success state instead of spawning a second ephemeral thread.

Do not use promptWithConfirmation() for this case. It eagerly deferUpdate()s the button click in its collector filter, which consumes the interaction and prevents the next showModal() call.

Use promptWithUnacknowledgedConfirmation() instead so the confirm button interaction stays available for the edit modal.


The current discord.js builder/data surface does not model every one of these inputs. TomoriBot sends Label-wrapped raw modal payloads through Discord REST and intercepts the gateway submission before discord.js parsing, preserving Radio Group, Checkbox Group, Checkbox, select, and file-upload values. Most command-local flows use promptWithRawModal() and its bounded collector. The persistent MCP Add form, reached from /config > Plugins > MCP Servers, reuses the raw send and value interception without that collector: nonce-bounded config:v2 modal submissions go through the global interaction router, and interception is installed during startup so an already-open supported modal remains routable after a process restart. The gateway wrapper forwards packetless readiness calls unchanged. Discord.js uses these calls to drain packets queued before the client became ready.