Discord Message Components V2
本頁內容尚未翻譯。
Since 2025, message components got a lot more powerful, allowing for more interactive and dynamic user experiences. This document outlines the key features and improvements introduced in this version.
Overview
Section titled “Overview”- Layout Components - For organizing and structuring content (Action Rows, Sections, Containers)
- Content Components - For displaying static text, images, and files (Text Display, Media Gallery, Thumbnails)
- Interactive Components - For user interactions (Buttons, Select Menus, Text Input)
Component Types
Section titled “Component Types”The following is a complete table of available message components. Details about each component are in the sections below.
| Type | Name | Description | Style |
|---|---|---|---|
| 1 | Action Row | Container to display a row of interactive components | Layout |
| 2 | Button | Button object | Interactive |
| 3 | String Select | Select menu for picking from defined text options | Interactive |
| 5 | User Select | Select menu for users | Interactive |
| 6 | Role Select | Select menu for roles | Interactive |
| 7 | Mentionable Select | Select menu for mentionables (users and roles) | Interactive |
| 8 | Channel Select | Select menu for channels | Interactive |
| 9 | Section | Container to display text alongside an accessory component | Layout |
| 10 | Text Display | Markdown text | Content |
| 11 | Thumbnail | Small image that can be used as an accessory | Content |
| 12 | Media Gallery | Display images and other media | Content |
| 13 | File | Displays an attached file | Content |
| 14 | Separator | Component to add vertical padding between other components | Layout |
| 17 | Container | Container that visually groups a set of components | Layout |
Anatomy of a Component
Section titled “Anatomy of a Component”All components have the following fields:
type(integer) | The type of the componentid(integer, optional) | 32 bit integer used as an optional identifier for component
The id field is optional and is used to identify components in the response from an interaction. The id must be unique within the message and is generated sequentially if left empty. Generation of ids won’t use another id that exists in the message if you have one defined for another component. Sending components with an id of 0 is allowed but will be treated as empty and replaced by the API.
Custom ID
Section titled “Custom ID”Additionally, interactive components like buttons and selects must have a custom_id field. The developer defines this field when sending the component payload, and it is returned in the interaction payload sent when a user interacts with the component. For example, if you set custom_id: click_me on a button, you’ll receive an interaction containing custom_id: click_me when a user clicks that button.
custom_id is only available on interactive components and must be unique per component. Multiple components on the same message must not share the same custom_id. Maximum length is 100 characters.
Action Row
Section titled “Action Row”An Action Row is a top-level layout component.
Action Rows can contain one of the following:
- Up to 5 contextually grouped buttons
- A single select component (string select, user select, role select, mentionable select, or channel select)
Action Row Structure
Section titled “Action Row Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 1 for action row component |
| id? | integer | Optional identifier for component |
| components | array of action row child components | Up to 5 interactive button components or a single select component |
Example
Section titled “Example”{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 2, // ComponentType.BUTTON "custom_id": "click_yes", "label": "Accept", "style": 1 }, { "type": 2, // ComponentType.BUTTON "label": "Learn More", "style": 5, "url": "http://watchanimeattheoffice.com/" }, { "type": 2, // ComponentType.BUTTON "custom_id": "click_no", "label": "Decline", "style": 4 } ] } ]}Button
Section titled “Button”A Button is an interactive component that can only be used in messages.
Buttons must be placed inside an Action Row or a Section’s accessory field.
Button Structure
Section titled “Button Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 2 for a button |
| id? | integer | Optional identifier for component |
| style | integer | A button style |
| label? | string | Text that appears on the button; max 80 characters |
| emoji? | partial emoji | name, id, and animated (id and animated are optional) |
| custom_id | string | Developer-defined identifier for the button; max 100 characters |
| sku_id? | snowflake | Identifier for a purchasable SKU (stock keeping unit), only available when using premium-style buttons |
| url? | string | URL for link-style buttons; max 512 characters |
| disabled? | boolean | Whether the button is disabled (defaults to false) |
Buttons come in various styles to convey different types of actions. These styles also define what fields are valid for a button.
- Non-link and non-premium buttons must have a
custom_id, and cannot have aurlor asku_id. - Link buttons must have a
url, and cannot have acustom_id - Link buttons do not send an interaction to the app when clicked
- Premium buttons must contain a
sku_id, and cannot have acustom_id,label,url, oremoji. - Premium buttons do not send an interaction to the app when clicked
Button Styles
Section titled “Button Styles”| Name | Value | Action | Required Field |
|---|---|---|---|
| Primary | 1 | The most important or recommended action in a group of options | custom_id |
| Secondary | 2 | Alternative or supporting actions | custom_id |
| Success | 3 | Positive confirmation or completion actions | custom_id |
| Danger | 4 | An action with irreversible consequences | custom_id |
| Link | 5 | Navigates to a URL | url |
| Premium | 6 | Purchase | sku_id |
Examples
Section titled “Examples”Just a button:
{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 2, // ComponentType.BUTTON "custom_id": "click_yes", "label": "Accept", "style": 1 } ] } ]}Premium Buttons
Section titled “Premium Buttons”Premium buttons will automatically have the following:
- Shop Icon
- SKU name
- SKU price
String Select
Section titled “String Select”A String Select is an interactive component that allows users to select one or more provided options.
String Selects can be configured for both single-select and multi-select behavior.
String Selects must be placed inside an Action Row in messages.
String Select Structure
Section titled “String Select Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 3 for string select |
| id? | integer | Optional identifier for component |
| custom_id | string | ID for the select menu; max 100 characters |
| options | array of select options | Specified choices in a select menu; max 25 |
| placeholder? | string | Placeholder text if nothing is selected or default; max 150 characters |
| min_values? | integer | Minimum number of items that must be chosen (defaults to 1); min 0, max 25 |
| max_values? | integer | Maximum number of items that can be chosen (defaults to 1); max 25 |
| disabled? | boolean | Whether select menu is disable in a message (defaults to false) |
String Select Option Structure
Section titled “String Select Option Structure”| Field | Type | Description |
|---|---|---|
| label | string | User-facing name of the option; max 100 characters |
| value | string | Dev-defined value of the option; max 100 characters |
| description? | string | Additional description of the option; max 100 characters |
| emoji? | partial emoji object | id, name, and animated (id and animated are optional) |
| default? | boolean | Will show this option as selected by default |
Note that the number of options must be within the range of min_values and max_values.
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW, "id": 1, "components": [ { "type": 3, // ComponentType.STRING_SELECT "id": 2, "custom_id": "favorite_bug", "placeholder": "Favorite bug?", "options": [ { "label": "Ant", "value": "ant", "description": "(best option)", "emoji": { "name": "🐜" } }, { "label": "Butterfly", "value": "butterfly", "emoji": { "name": "🦋" } }, { "label": "Caterpillar", "value": "caterpillar", "emoji": { "name": "🐛" } } ] } ] } ]}User Select
Section titled “User Select”A User Select is an interactive component that allows users to select one or more users in a message. Options are automatically populated based on the server’s available users.
User Selects can be configured for both single-select and multi-select behavior.
User Selects must be placed inside an Action Row.
User Select Structure
Section titled “User Select Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 5 for user select |
| id? | integer | Optional identifier for component |
| custom_id | string | ID for the select menu; max 100 characters |
| placeholder? | string | Placeholder text if nothing is selected; max 150 characters |
| default_values? | array of default value objects | List of default values; number of default values must be in the range defined by min_values and max_values |
| min_values? | integer | Minimum number of items that must be chosen (defaults to 1); min 0, max 25 |
| max_values? | integer | Maximum number of items that can be chosen (defaults to 1); max 25 |
| disabled? | boolean | Whether select menu is disabled (defaults to false) |
Select Default Value Structure
Section titled “Select Default Value Structure”| Field | Type | Description |
|---|---|---|
| id | snowflake | ID of a user, role, or channel |
| type | string | Type of value that id represents. Either "user", "role", or "channel" |
Examples
Section titled “Examples”Regular user select
{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 5, // ComponentType.USER_SELECT "custom_id": "user_select", "placeholder": "Select a user" } ] } ]}User select with default values
{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 5, // ComponentType.USER_SELECT "custom_id": "user_select", "placeholder": "Select a user", "default_values": [ { "id": "123456789012345678", "type": "user" } ] } ] } ]}Role Select
Section titled “Role Select”The same as the User Select, but for the roles within a guild.
The type of a role select is 6.
Note, that the default values have to have the type "role".
Examples
Section titled “Examples”Regular role select
{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 6, // ComponentType.ROLE_SELECT "custom_id": "role_select", "placeholder": "Select a role" } ] } ]}Role select with default values
{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 6, // ComponentType.ROLE_SELECT "custom_id": "role_select", "placeholder": "Select a role", "default_values": [ { "id": "123456789012345678", "type": "role" } ] } ] } ]}Mentionable Select
Section titled “Mentionable Select”Mentionable selects are uniting user selects and roles selects.
The type of a mentionable select is 7.
Note that the default values can accept both types "user" and "role".
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 7, // ComponentType.MENTIONABLE_SELECT "custom_id": "who_to_ping", "placeholder": "Who?" } ] } ]}With default values:
{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 7, // ComponentType.MENTIONABLE_SELECT "custom_id": "who_to_ping", "placeholder": "Who?", "default_values": [ { "id": "123456789012345678", "type": "user" }, { "id": "987654321098765432", "type": "role" } ] } ] } ]}Channel Select
Section titled “Channel Select”A Channel Select is an interactive component that allows users to select one or more channels in a message.
Channel Selects can be configured for both single-select and multi-select behavior.
Channel Select Structure
Section titled “Channel Select Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 8 for channel select |
| id? | integer | Optional identifier for component |
| custom_id | string | ID for the select menu; max 100 characters |
| channel_types? | array of channel types | List of channel types to include in the channel select component |
| placeholder? | string | Placeholder text if nothing is selected; max 150 characters |
| default_values? | array of default value objects | List of default values; number of default values must be in the range defined by min_values and max_values |
| min_values? | integer | Minimum number of items that must be chosen (defaults to 1); min 0, max 25 |
| max_values? | integer | Maximum number of items that can be chosen (defaults to 1); max 25 |
| disabled? | boolean | Whether select menu is disabled (defaults to false) |
Channel Types
Section titled “Channel Types”- GUILD_TEXT:
0- a text channel within a server - DM:
1- a direct message between users - GUILD_VOICE:
2- a voice channel within a server - GROUP_DM:
3- a direct message between multiple users - GUILD_CATEGORY:
4- an organizational category that contains up to 50 channels - GUILD_ANNOUNCEMENT:
5- a channel that users can follow and crosspost into their own server (formerly news channels) - ANNOUNCEMENT_THREAD:
10- a temporary sub-channel within a GUILD_ANNOUNCEMENT channel - PUBLIC_THREAD:
11- a temporary sub-channel within a GUILD_TEXT or GUILD_FORUM channel - PRIVATE_THREAD:
12- a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission - GUILD_STAGE_VOICE:
13- a voice channel for hosting events with an audience - GUILD_DIRECTORY:
14- the channel in a hub containing the listed servers - GUILD_FORUM:
15- channel that can only contain threads - GUILD_MEDIA:
16- channel that can only contain threads, similar to GUILD_FORUM channels
Missing values are legacy channel types.
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 8, // ComponentType.CHANNEL_SELECT "custom_id": "notification_channel", "channel_types": [0], // ChannelType.TEXT "placeholder": "Which text channel?" } ] } ]}Section
Section titled “Section”A Section is a top-level layout component that allows you to contextually associate content with an accessory component. The typical use-case is to contextually associate text content with an accessory.
Sections MUST include the accessory component.
Section Structure
Section titled “Section Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 9 for section component |
| id? | integer | Optional identifier for component |
| components | array of section child components | 1 - 3 child components representing the content of the section that is contextually associated to the accessory |
| accessory | section accessory component | A component that is contextually associated to the content of the section |
Section Child Components
Section titled “Section Child Components”Section Accessory Components
Section titled “Section Accessory Components”Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 9, // ComponentType.SECTION "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "The game is out now! Check it out on our website." } ], "accessory": { "type": 11, // ComponentType.THUMBNAIL "media": { "url": "https://websitewithopensourceimages/gamepreview.webp" } } } ]}Text Display
Section titled “Text Display”A Text Display is a content component that allows you to add markdown formatted text, including mentions (users, roles, etc) and emojis.
The behavior of this component is extremely similar to the content field of a message, but allows you to add multiple text components, controlling the layout of your message.
When sent in a message, pingable mentions (@user, @role, etc) present in this component will ping and send notifications based on the value of the allowed mention object set in message.allowed_mentions.
Text Display Structure
Section titled “Text Display Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 10 for text display |
| id? | integer | Optional identifier for component |
| content | string | Text that will be displayed similar to a message |
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "# Real Game v7.3" }, { "type": 10, // ComponentType.TEXT_DISPLAY "content": "Hope you're excited, the update is finally here! Here are some of the changes:\n- Fixed a bug where certain treasure chests wouldn't open properly\n- Improved server stability during peak hours\n- Added a new type of gravity that will randomly apply when the moon is visible in-game\n- Every third thursday the furniture will scream your darkest secrets to nearby npcs" }, { "type": 10, // ComponentType.TEXT_DISPLAY "content": "-# That last one wasn't real, but don't use voice chat near furniture just in case..." } ]}TomoriBot convention: container titles
Section titled “TomoriBot convention: container titles”TomoriBot renders the leading “title” line of every Components V2 container (status, confirmation, persona picker, persona results, memory/task notices) as a Markdown H3 heading via the shared formatContainerTitle helper in src/utils/discord/ui/interactionCore.ts. Keep title locale strings plain text (an emoji prefix is fine); do not embed ### or ** in them, or the heading will double up. Body text, section sub-headings, and footers are unaffected.
Memory and scheduled-task notices use buildNoticeContainer in src/utils/discord/ui/interactionCore.ts. The old embed title maps to the H3 title, the old embed description maps to a Text Display, and the old embed footer maps to muted -# subtext after a separator. When the memory/task body is truncated, the Secondary “Expand” button is rendered as an Action Row inside the same container; the ephemeral full-content reveal remains a separate classic embed reply.
TomoriBot convention: panel text hierarchy
Section titled “TomoriBot convention: panel text hierarchy”Persistent and categorized control panels follow a standardized four-tier text hierarchy to maintain consistent structure across desktop and mobile clients:
- Page and Major-Section Headings (
###): Used for page titles, category headers, and major section counts (for example### Whitelisted Channels \(3)`). Heading depth does not exceed###`. - Nested Subsection Labels (
**Bold**): Used for named sub-sections within a page where additional heading tags would create excessive vertical spacing or hit Discord client heading limitations. - Plain Text Prose: Used for section descriptions, guidance explanations, populated-section semantic descriptions, and empty-state copy. Explanatory prose is never quoted, keeping it distinct from configured values. Populated list sections explain what their entries mean before rendering quote rows. Avoid prose em dashes in panel copy; use parentheticals where compact metadata is useful.
- Quote Rows (
>): Used for current configuration values, semantic status lines (such as> 🟢 ...), and populated entity rows (such as channels, users, or roles) instead of bulleted lists. Because Discord mentions (<@id>,<#id>,<@&id>) already encode snowflake IDs, entity rows omit redundant raw IDs beside mentions. Metadata such as persona interaction restrictions uses parentheticals (for example(mute)or(block)) instead of em dashes. Multi-line entity details (such as per-channel cooldown settings) remain inside the same quote block on subsequent lines.
Persistent categorized control panels place a real divider separator ({ type: ComponentType.Separator, divider: true, spacing: 1 }) immediately after the top category-button action row and before the selected page title or body to clearly separate navigation controls from content. When a read fails and cached state is displayed, stale-read warnings appear as the bottommost footer element, preceded by a separate real divider separator and formatted as subdued subtext (-# ...).
The startup grace marker used to disambiguate an empty workspace read is not a failed-read signal when panel data loaded successfully. Only a recorded database failure or an unavailable panel repository read disables write actions. Stale panels keep writes disabled but expose an enabled Retry action that forces a state refresh; the warning remains the bottommost footer.
Thumbnail
Section titled “Thumbnail”A Thumbnail is a content component that displays visual media in a small form-factor. It is intended as an accessory for to other content, and is primarily usable with sections. The media displayed is defined by the unfurled media item structure, which supports both uploaded media and externally hosted media.
Thumbnails currently only support images, including animated formats like GIF and WEBP. Videos are not supported at this time.
Thumbnail Structure
Section titled “Thumbnail Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 11 for thumbnail component |
| id? | integer | Optional identifier for component |
| media | unfurled media item | A url or attachment provided as an unfurled media item |
| description? | string | Alt text for the media, max 1024 characters |
| spoiler? | boolean | Whether the thumbnail should be a spoiler (or blurred out). Defaults to false |
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 9, // ComponentType.SECTION "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "Please visit our website for more information." } ], "accessory": { "type": 11, // ComponentType.THUMBNAIL "media": { "url": "https://websitewithopensourceimages/gamepreview.webp" } } } ]}Media Gallery
Section titled “Media Gallery”A Media Gallery is a top-level content component that allows you to display 1-10 media attachments in an organized gallery format. Each item can have optional descriptions and can be marked as spoilers.
Media Gallery Structure
Section titled “Media Gallery Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 12 for media gallery component |
| id? | integer | Optional identifier for component |
| items | array of media gallery items | 1 to 10 media gallery items |
Media Gallery Item Structure
Section titled “Media Gallery Item Structure”| Field | Type | Description |
|---|---|---|
| media | unfurled media item | A url or attachment provided as an unfurled media item |
| description? | string | Alt text for the media, max 1024 characters |
| spoiler? | boolean | Whether the media should be a spoiler. Defaults to false |
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "Live webcam shots as of 18-04-2025 at 12:00 UTC" }, { "type": 12, // ComponentType.MEDIA_GALLERY "items": [ { "media": { "url": "https://livevideofeedconvertedtoimage/webcam1.webp" }, "description": "An aerial view looking down on older industrial complex buildings. The main building is white with many windows and pipes running up the walls." }, { "media": { "url": "https://livevideofeedconvertedtoimage/webcam2.webp" }, "description": "An aerial view of old broken buildings. Nature has begun to take root in the rooftops. A portion of the middle building's roof has collapsed inward. In the distant haze you can make out a far away city." }, { "media": { "url": "https://livevideofeedconvertedtoimage/webcam3.webp" }, "description": "A street view of a downtown city. Prominently in photo are skyscrapers and a domed building" } ] } ]}TomoriBot Generated Image Pattern
Section titled “TomoriBot Generated Image Pattern”Generated image tool output uses Components V2 so the timing subtext appears visually below the rendered image instead of above the attachment. The message sends the image as an attachment, exposes that attachment through a Media Gallery item, then follows it with a Text Display using Discord subtext markdown.
{ "flags": 32768, "components": [ { "type": 12, "items": [ { "media": { "url": "attachment://generated_1770000000000.png" } } ] }, { "type": 10, "content": "-# Generated after 4.2 seconds." } ]}Implementation notes:
- The attachment filename must exactly match the
attachment://<filename>URL. - Components V2 messages must set
MessageFlags.IsComponentsV2and must not includecontentorembeds. - Keep a message’s whole edit lifecycle in one mode. If a command will eventually render a Components V2 result, its intermediate processing and terminal error states should also be Components V2; otherwise
editReply()can patch a legacy embed/content message into a V2 message and Discord rejects the final payload. - In discord.js edits, omitting
embedsorcontentdoes not reliably clear an existing legacy field beforeMessageFlags.IsComponentsV2validation. Prefer sending a V2 status container from the first visible state instead of converting an existing legacy reply in place. - Attachments on Components V2 messages do not render unless they are referenced by a component.
- Webhook sends and edits through discord.js must also pass
withComponents: true. - Image delivery should fall back to the legacy
files-only payload if the Components V2 send fails. - Re-fetching a generated image by message/media ID (for image-to-image, inpaint, or vision analysis) must scan
message.componentsforMediaGallery/Thumbnail/Filemedia, not just top-level attachments/embeds; the generated file is referenced only inside the component. This discovery is centralized incollectImageUrlsFromMessage(src/utils/image/imageExtractor.ts), which reusesappendComponentMediaFromMessagefromsrc/utils/chat/contextMedia.ts.generate_image,generate_image_nai, andanalyze_imageall go through it, so a Components V2 image found in context can also be reloaded by any tool that accepts a media reference.
Persistent panels and bounded workflows
Section titled “Persistent panels and bounded workflows”Components V2 does not determine interaction lifetime. TomoriBot supports two ownership models:
- Bounded anchor workflows use collectors and deliberately reach a timeout or terminal state.
- Persistent panels use the versioned global interaction router, so each click or select is handled as a new
interactionCreateevent without an invocation-owned collector.
The /help panel is the first persistent panel. Its four category buttons, section select, subsection select, navigation buttons, and provider modal IDs use the help:v2:* namespace and carry the panel locale as harmless render state. The original message stays Components V2 for every repaint. The section select doubles as the section heading: a closed select renders its default option’s label, so a separate title line above it would repeat the active section’s name, and the section description follows the select instead. Every page places a separate top-level Action Row below the content Container for its web-documentation and support-server link buttons. The text provider select acknowledges its interaction only with showModal(), never with a preceding defer. Submitting the text-only modal receives a silent deferUpdate(); dismissing it emits no event and requires no cleanup.
Persistent routing does not make Discord messages permanent. Message deletion, access changes, and client presentation remain outside the router’s control. It only removes TomoriBot’s in-memory collector timeout as the control-lifetime boundary.
Anchor message workflow: one message
Section titled “Anchor message workflow: one message”A same-visibility anchor workflow is one ephemeral Components V2 message for its complete lifecycle. Provider or persona cards, page navigation, loading states, secondary selectors, validation, progress, success, errors, timeouts, and final private controls all replace or edit that anchor message. A modal is not a message and does not change this count.
Both entry points behave identically here: runPersonaPickerWorkflow(...) for persona
pickers, and beginAnchorPrivateWorkflow(...) for everything else (imported from
src/utils/discord/ui/anchorWorkflow.ts, which also exports neutral Anchor* aliases
for the types named below). Each captures its message ID and exposes a
PersonaWorkflowMessageController through selection, modal, in-place, and nested-button
phases. Every controller operation verifies that it still targets that ID:
replace(payload)performs a full state transition and clears stale attachments unlessattachmentsis supplied explicitly;edit(payload)performs an incremental edit and retains attachments when the field is omitted;fetchMessage()returns only the verified anchor message;disableControls()preserves the visible state but disables interactive components;delete()closes the workflow and prevents later edits.
The controller accepts only PersonaWorkflowComponentsV2Payload. Its type and runtime
guard both require components plus MessageFlags.IsComponentsV2, and reject legacy
content or embeds. files, attachment retention, and explicit attachment clearing are
supported. Operations return the edited Message when a collector needs it.
In-place update failures are represented by PersonaWorkflowUpdateError codes such as
message-mismatch, non-message-backed-interaction, already-acknowledged,
anchor-message-unavailable, and discord-update-failed. They are logged and returned
or thrown; the controller never silently falls back to another ephemeral reply,
followUp, or webhook.send.
Attachment lifecycle
Section titled “Attachment lifecycle”Persona cards can reference local avatars through attachment://avatar_<index>.png. A
later status or selector normally does not reference those files, so replace supplies an
empty attachment set automatically and removes the stale avatars. A destination that still
uses an existing attachment calls edit or supplies the retained attachment IDs. A
destination with new files passes both files and matching attachment://... references in
its Components V2 tree.
Explicit visibility change
Section titled “Explicit visibility change”The only normal two-message policy is separate-public. Calling
selection.beginSeparatePublicReply(compactPrivatePayload) first updates the selected
button and compacts the anchor private picker. The returned phase can then create exactly
one public follow-up. Its public payload type forbids the ephemeral option, and a second
reply() call fails with public-reply-already-sent.
This policy is used by intentionally public results such as persona /stats generate.
A private workflow must use the default replace-picker policy instead.
In-place modal range bridge
Section titled “In-place modal range bridge”Discord modal string selects hold at most 25 options. Persona workflows keep larger sets on the anchor message:
- With at most 25 preloaded options, the selected persona button opens the modal directly.
- With more than 25 options, that button updates the anchor message to localized range
buttons (
1-25,26-50, and so on). - The chosen range button opens a modal containing only that slice. The submitted modal
phase exposes
optionOffsetfor converting page-local indexes to absolute indexes. - Previous/next range-page navigation, cancel, timeout, and retry all replace the same message.
When options require asynchronous loading, openModal(async () => options) first
update-defers the persona button and displays an in-place loading state. For a small result it
then displays a new Open Form button; for a large result it displays the range selector.
The fresh button performs showModal() as its first acknowledgment. The workflow never
defers a button and then attempts to open a modal from that already-acknowledged interaction.
Migration guidance
Section titled “Migration guidance”When modernizing an embed workflow to Components V2, migrate the complete reply flow together:
- Initial deferred reply edits, “processing” messages, success messages, timeout messages, cancellation messages, and post-processing errors should all use Components V2 components with
MessageFlags.IsComponentsV2. - Files attached to V2 messages must be referenced by
MediaGallery,Thumbnail, orFile; an unreferenced attachment is not displayed. - Shared helpers such as status containers are preferred for repeated states. Local builders are acceptable for command-specific payloads, but do not mix them with legacy embeds on the same original interaction reply after the V2 state has been sent.
- Commands that need an interactive persona picker must use
runPersonaPickerWorkflow(...); direct use of the low-level persona renderer is mechanically rejected bytests/unit/checks/personaWorkflowBoundary.test.ts.
A File is a top-level content component that allows you to display an uploaded file as an attachment to the message and reference it in the component. Each file component can only display 1 attached file, but you can upload multiple files and add them to different file components within your payload.
Info: The File component only supports using the attachment:// protocol in unfurled media item.
File Structure
Section titled “File Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 13 for a file component |
| id? | integer | Optional identifier for component |
| file | unfurled media item | This unfurled media item is unique in that it only supports attachment references using the attachment://<filename> syntax |
| spoiler? | boolean | Whether the media should be a spoiler (or blurred out). Defaults to false |
| name | string | The name of the file. This field is ignored and provided by the API as part of the response |
| size | integer | The size of the file in bytes. This field is ignored and provided by the API as part of the response |
Examples
Section titled “Examples”Info: This example makes use of the attachment:// protocol functionality in unfurled media item.
{ "flags": 32768, "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "# New game version released for testing!\nGrab the game here:" }, { "type": 13, // ComponentType.FILE "file": { "url": "attachment://game.zip" } }, { "type": 10, // ComponentType.TEXT_DISPLAY "content": "Latest manual artwork here:" }, { "type": 13, // ComponentType.FILE "file": { "url": "attachment://manual.pdf" } } ]}Separator
Section titled “Separator”A Separator is a top-level layout component that adds vertical padding and visual division between other components.
Separator Structure
Section titled “Separator Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 14 for separator component |
| id? | integer | Optional identifier for component |
| divider? | boolean | Whether a visual divider should be displayed in the component. Defaults to true |
| spacing? | integer | Size of separator padding - 1 for small padding, 2 for large padding. Defaults to 1 |
Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "It's dangerous to go alone!" }, { "type": 14, // ComponentType.SEPARATOR "divider": true, "spacing": 1 }, { "type": 10, // ComponentType.TEXT_DISPLAY "content": "Take this." } ]}Container
Section titled “Container”A Container is a top-level layout component. Containers offer the ability to visually encapsulate a collection of components and have an optional customizable accent color bar.
Container Structure
Section titled “Container Structure”| Field | Type | Description |
|---|---|---|
| type | integer | 17 for container component |
| id? | integer | Optional identifier for component |
| components | array of container child components | Child components that are encapsulated within the Container |
| accent_color? | ?integer | Color for the accent on the container as RGB from 0x000000 to 0xFFFFFF |
| spoiler? | boolean | Whether the container should be a spoiler (or blurred out). Defaults to false. |
Container Child Components
Section titled “Container Child Components”Examples
Section titled “Examples”{ "flags": 32768, "components": [ { "type": 17, // ComponentType.CONTAINER "accent_color": 703487, "components": [ { "type": 10, // ComponentType.TEXT_DISPLAY "content": "# You have encountered a wild coyote!" }, { "type": 12, // ComponentType.MEDIA_GALLERY "items": [ { "media": { "url": "https://websitewithopensourceimages/coyote.webp" } } ] }, { "type": 10, // ComponentType.TEXT_DISPLAY "content": "What would you like to do?" }, { "type": 1, // ComponentType.ACTION_ROW "components": [ { "type": 2, // ComponentType.BUTTON "custom_id": "pet_coyote", "label": "Pet it!", "style": 1 }, { "type": 2, // ComponentType.BUTTON "custom_id": "feed_coyote", "label": "Attempt to feed it", "style": 2 }, { "type": 2, // ComponentType.BUTTON "custom_id": "run_away", "label": "Run away!", "style": 4 } ] } ] } ]}Unfurled Media Item
Section titled “Unfurled Media Item”An Unfurled Media Item is a piece of media, represented by a URL, that is used within a component. It can be constructed via either uploading media to Discord, or by referencing external media via a direct link to the asset.
Info: While the structure below is the full representation of an Unfurled Media Item, only the url field is settable by developers when making requests that utilize this structure.
All other fields will be automatically populated by Discord.
Unfurled Media Item Structure
Section titled “Unfurled Media Item Structure”| Field | Type | Description |
|---|---|---|
| url | string | Supports arbitrary urls and attachment://<filename> references |
| proxy_url? | string | The proxied url of the media item. This field is ignored and provided by the API as part of the response |
| height? | ?integer | The height of the media item. This field is ignored and provided by the API as part of the response |
| width? | ?integer | The width of the media item. This field is ignored and provided by the API as part of the response |
| content_type? | string | The media type of the content. This field is ignored and provided by the API as part of the response |
| attachment_id?* | snowflake | The id of the uploaded attachment. This field is ignored and provided by the API as part of the response |
* Only present if the media item was uploaded as an attachment.
Uploading a file
Section titled “Uploading a file”To upload a file with your message, you’ll need to send your payload as multipart/form-data (rather than application/json) and include your file with a valid filename in your payload. Details and examples for uploading files can be found in the Discord API Reference.
Message limits and guarded delivery
Section titled “Message limits and guarded delivery”Discord rejects an oversized or grammatically invalid Components V2 message with a 400 at the REST boundary. The payload builds fine, passes every unit test that only inspects it, and then fails against the live API, so these bounds are enforced in code rather than left to authoring discipline.
src/utils/discord/ui/componentsV2Limits.ts owns the constants and the validators. Import the constants; never
retype the numbers.
The bounds TomoriBot enforces
Section titled “The bounds TomoriBot enforces”| Bound | Value | Notes |
|---|---|---|
| Total components | 40 | Counted recursively: top-level layout components, nested children, and Section accessories all count. Top-level array length is not the measure. |
| Text Display total | 4,000 codepoints | Summed across every TextDisplay.content in the message. Button labels and select placeholders do not count toward it; they have their own per-slot ceilings. |
| Action Row | one select, or at most 5 buttons | Never a mixture. |
| Section | 1 to 3 Text Displays, exactly one accessory | The accessory must be a supported Button or Thumbnail. |
| String Select | 1 to 25 options | Option label, value, and description at most 100; placeholder at most 150; minValues/maxValues coherent with the option count. |
| Button | label at most 80 | Interactive custom_id 1 to 100 and unique within the message; link URL at most 512. |
| Media description | at most 1,024 | Where TomoriBot emits one. |
Length is measured in Unicode codepoints, matching Discord’s backend, not in UTF-16 code units. Use
getDiscordTextLength and truncateDiscordText from @/utils/text/discordTextLimits, which is deliberately
free of any discord.js import so tool execution paths can budget text without loading the Discord client
runtime. truncateDiscordText walks grapheme clusters, so it never splits a surrogate pair or a combining
sequence. A .length comparison or a .slice() on user content is a defect: it is wrong for CJK and emoji and
can emit a lone surrogate.
Validation is separate from fitting
Section titled “Validation is separate from fitting”validateComponentsV2MessageLimits(payload) is pure. It never mutates or silently truncates: it returns
{ valid, violations }, where each violation names the payload path, the component type, the observed value,
the limit, and a stable low-cardinality reason code. Builders do the fitting, by paginating a collection or
rendering a bounded preview with a localized hidden or truncated count, while the complete stored value stays
reachable through its existing editor, export, or detail flow.
Never satisfy a bound by lowering a stored limit, removing an action, or clipping a collection without a hidden count. The transport budget is a presentation constraint, not a data constraint.
Page budgets are measured, not guessed
Section titled “Page budgets are measured, not guessed”A page derives its dynamic text budget by subtracting its measured fixed chrome from the message
allowance, rather than subtracting a hand-tuned constant. A guessed reserve cannot be distinguished from a
correct one by a validity test, since both pass, and it rots silently the moment a line of prose is added or a
locale’s wording grows. tests/unit/discord/configPanelTextBudget.test.ts asserts that each page at stored
maxima is either untruncated or within a small named tolerance of the cap, so an over-generous reserve fails.
Guarded delivery
Section titled “Guarded delivery”Every panel transport goes through deliverGuardedPanel in src/utils/discord/ui/interactionCore.ts: initial
reply, editReply, component update, anchor replacement, and the attachment-bearing avatar path. Terminal
notice payloads are validated at construction instead, through validateAndFallbackPanelPayload, so a notice
is checked once wherever it is delivered from.
Behavior differs by environment on purpose:
- Outside production the guard throws
ComponentsV2LimitErrorcarrying the violations, so a broken panel fails loudly in tests and development. - In production it logs a redacted structured diagnostic and delivers a minimal localized Components V2 error payload in place of the invalid one. The diagnostic carries the path, component type, observed value, limit, and code. It never carries stored prompt, tag, memory, receipt, or endpoint text.
The guard must never throw after a successful database write and leave an acknowledged interaction unrepainted: an operation that already committed still has to produce a visible result, which is why the production branch substitutes a payload rather than propagating.
Adding a panel
Section titled “Adding a panel”A new module that emits MessageFlags.IsComponentsV2 must appear in the manifest in
tests/unit/discord/componentsV2ProducerManifest.test.ts. The coverage assertion scans src/ for that flag
and compares the result against the manifest by module name in both directions, so an unregistered producer
fails with a name rather than an arithmetic mismatch. An entry is either suite, naming boundary fixtures that
drive its builder, or delivery, meaning its payload is built inline with no exported builder a fixture can
drive and is validated at construction instead.
Boundary fixtures sweep every runtime locale discovered from src/locales/, receipts on and off, each read
status and page or view kind, and collection sizes around the page-size boundary. They must also vary content
shape, not only collection size: a sweep in which every record carries the same short body passed 1,659
combinations while a fence breakout and an unbounded body both went undetected.
Legacy Message Component Behavior
Section titled “Legacy Message Component Behavior”TomoriBot’s /config > Plugins > MCP Servers page is a persistent-routing example. It uses config:v2
custom IDs. The route family carries only locale, navigation state, stable row IDs, and bounded enum
values. Each interaction reloads the
current workspace and durable registration state when it needs panel state; the Add opener performs
only its permission check before showing the form, and the globally routed submit performs the full
reload. Names, endpoints, credentials, and permission bits are never trusted from the route. The panel
renders the complete supported collection in deterministic order, with stable-ID Enable/Disable and
Remove actions on each row. The structural rewrite registered no config:v1 decoder, so an
already-issued version-one route resolves to the localized outdated-panel response instead of a
silent repaint. Receipts use their own top-level
Container below the authoritative collection Container, keeping status color separate from the panel.
Healthy views omit a routine refresh button because transactions reload and repaint automatically. Only stale or
unavailable reads expose Retry, which performs a configuration read without testing or connecting
to the remote MCP endpoint.
TomoriBot’s /moderation Member Access editor is the first writable page in the moderation panel.
Its moderation:v1 routes handle category navigation and modal transactions. Clicking Edit Permissions
reauthorizes and reads fresh server state before opening a globally routed Checkbox Group modal as the
valid first interaction acknowledgment (showRoutedRawModal). The modal custom ID and component ID carry a nonce
to bind submission lifecycle. Upon modal submit, the router immediately defers update, reauthorizes, reloads
current state, consumes the stored checkbox-group array once (takeRawModalCheckboxGroupValues), filters
against recognized permission definitions, and suppresses writes if the refreshed scope is stale or unavailable.
On changed input, a single repository update is performed, followed by a single cache invalidation on success.
The panel then reloads authoritative state and repaints with updated semantic status rows and a separate
top-level receipt container (success, info, or error), while retaining truthful receipts if a post-write
reload fails.
The /moderation User Blacklist page manages personalization exclusions and persona interaction restrictions.
One paired Add Blacklist and Remove Blacklist action row avoids consuming a component for every entry.
Remove opens nonce-bound Checkbox Groups with every presented entry selected. Unchecking entries and submitting
removes only those entries, with no second confirmation. The submit route reauthorizes, reloads current scope, and
compares the submission with the short-lived presented snapshot, so entries added after the modal opened are not
removed. Canonical operations precisely invalidate affected personalization and persona-block caches after successful
writes.
The Whitelist pages use the same transaction pattern. Channel additions use a native Channel Select and retain the existing cooldown inheritance rules. Role additions use a native Role Select and reject the everyone role. Both lists expose one red bulk Remove action beside Add. Their unchecked-means-remove modals use the same snapshot, reauthorization, fresh-read, and immediate-submit contract as User Blacklist. Duplicate additions and missing removal targets do not write or invalidate; successful changes invalidate the whitelist cache after the write.
The Personas page uses paired Add Persona and Remove Persona actions. Add opens a modal with a String Select for the configured persona catalog and a native text Channel Select, adding one persona-channel mapping. The configured persona limit keeps the String Select within Discord’s 25-option bound. Remove uses the same bulk checkbox contract as the other lists. Canonical full-set replacement preserves concurrent mappings, suppresses unchanged writes, and invalidates the whitelist cache only after a successful transaction.
Before the introduction of the IS_COMPONENTS_V2 flag, message components were sent in conjunction with message content. This means that you could send a message using a subset of the available components without setting the IS_COMPONENTS_V2 flag, and the components would be included in the message content along with content and embeds.
Additionally, components of messages preceding components V2 will contain an id of 0.
Apps using this Legacy Message Component behavior will continue to work as expected, but it is recommended to use the new IS_COMPONENTS_V2 flag for new apps or features as they offer more options for layout and customization.
Info: Legacy messages allow up to 5 action rows as top-level components
Legacy Message Component Example
{ "content": "This is a message with legacy components", "components": [ { "type": 1, "components": [ { "type": 2, "style": 1, "label": "Click Me", "custom_id": "click_me_1" } ] } ]}