Pular para o conteúdo

Discord Message Components V2

Este conteúdo não está disponível em sua língua ainda.

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.

  • 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)

The following is a complete table of available message components. Details about each component are in the sections below.

TypeNameDescriptionStyle
1Action RowContainer to display a row of interactive componentsLayout
2ButtonButton objectInteractive
3String SelectSelect menu for picking from defined text optionsInteractive
5User SelectSelect menu for usersInteractive
6Role SelectSelect menu for rolesInteractive
7Mentionable SelectSelect menu for mentionables (users and roles)Interactive
8Channel SelectSelect menu for channelsInteractive
9SectionContainer to display text alongside an accessory componentLayout
10Text DisplayMarkdown textContent
11ThumbnailSmall image that can be used as an accessoryContent
12Media GalleryDisplay images and other mediaContent
13FileDisplays an attached fileContent
14SeparatorComponent to add vertical padding between other componentsLayout
17ContainerContainer that visually groups a set of componentsLayout

All components have the following fields:

  • type (integer) | The type of the component
  • id (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.

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.


An Action Row is a top-level layout component.

Action Rows can contain one of the following:

FieldTypeDescription
typeinteger1 for action row component
id?integerOptional identifier for component
componentsarray of action row child componentsUp to 5 interactive button components or a single select component
{
"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
}
]
}
]
}

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.

FieldTypeDescription
typeinteger2 for a button
id?integerOptional identifier for component
styleintegerA button style
label?stringText that appears on the button; max 80 characters
emoji?partial emojiname, id, and animated (id and animated are optional)
custom_idstringDeveloper-defined identifier for the button; max 100 characters
sku_id?snowflakeIdentifier for a purchasable SKU (stock keeping unit), only available when using premium-style buttons
url?stringURL for link-style buttons; max 512 characters
disabled?booleanWhether 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 a url or a sku_id.
  • Link buttons must have a url, and cannot have a custom_id
  • Link buttons do not send an interaction to the app when clicked
  • Premium buttons must contain a sku_id, and cannot have a custom_id, label, url, or emoji.
  • Premium buttons do not send an interaction to the app when clicked
NameValueActionRequired Field
Primary1The most important or recommended action in a group of optionscustom_id
Secondary2Alternative or supporting actionscustom_id
Success3Positive confirmation or completion actionscustom_id
Danger4An action with irreversible consequencescustom_id
Link5Navigates to a URLurl
Premium6Purchasesku_id

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 will automatically have the following:

  • Shop Icon
  • SKU name
  • SKU price

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.

FieldTypeDescription
typeinteger3 for string select
id?integerOptional identifier for component
custom_idstringID for the select menu; max 100 characters
optionsarray of select optionsSpecified choices in a select menu; max 25
placeholder?stringPlaceholder text if nothing is selected or default; max 150 characters
min_values?integerMinimum number of items that must be chosen (defaults to 1); min 0, max 25
max_values?integerMaximum number of items that can be chosen (defaults to 1); max 25
disabled?booleanWhether select menu is disable in a message (defaults to false)
FieldTypeDescription
labelstringUser-facing name of the option; max 100 characters
valuestringDev-defined value of the option; max 100 characters
description?stringAdditional description of the option; max 100 characters
emoji?partial emoji objectid, name, and animated (id and animated are optional)
default?booleanWill show this option as selected by default

Note that the number of options must be within the range of min_values and max_values.

{
"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": "🐛" }
}
]
}
]
}
]
}

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.

FieldTypeDescription
typeinteger5 for user select
id?integerOptional identifier for component
custom_idstringID for the select menu; max 100 characters
placeholder?stringPlaceholder text if nothing is selected; max 150 characters
default_values?array of default value objectsList of default values; number of default values must be in the range defined by min_values and max_values
min_values?integerMinimum number of items that must be chosen (defaults to 1); min 0, max 25
max_values?integerMaximum number of items that can be chosen (defaults to 1); max 25
disabled?booleanWhether select menu is disabled (defaults to false)
FieldTypeDescription
idsnowflakeID of a user, role, or channel
typestringType of value that id represents. Either "user", "role", or "channel"

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"
}
]
}
]
}
]
}

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".

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 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".

{
"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"
}
]
}
]
}
]
}

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.

FieldTypeDescription
typeinteger8 for channel select
id?integerOptional identifier for component
custom_idstringID for the select menu; max 100 characters
channel_types?array of channel typesList of channel types to include in the channel select component
placeholder?stringPlaceholder text if nothing is selected; max 150 characters
default_values?array of default value objectsList of default values; number of default values must be in the range defined by min_values and max_values
min_values?integerMinimum number of items that must be chosen (defaults to 1); min 0, max 25
max_values?integerMaximum number of items that can be chosen (defaults to 1); max 25
disabled?booleanWhether select menu is disabled (defaults to false)
  • 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.

{
"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?"
}
]
}
]
}

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.

FieldTypeDescription
typeinteger9 for section component
id?integerOptional identifier for component
componentsarray of section child components1 - 3 child components representing the content of the section that is contextually associated to the accessory
accessorysection accessory componentA component that is contextually associated to the content of the section
{
"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"
}
}
}
]
}

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.

FieldTypeDescription
typeinteger10 for text display
id?integerOptional identifier for component
contentstringText that will be displayed similar to a message
{
"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 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.


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.

FieldTypeDescription
typeinteger11 for thumbnail component
id?integerOptional identifier for component
mediaunfurled media itemA url or attachment provided as an unfurled media item
description?stringAlt text for the media, max 1024 characters
spoiler?booleanWhether the thumbnail should be a spoiler (or blurred out). Defaults to false
{
"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"
}
}
}
]
}

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.

FieldTypeDescription
typeinteger12 for media gallery component
id?integerOptional identifier for component
itemsarray of media gallery items1 to 10 media gallery items
FieldTypeDescription
mediaunfurled media itemA url or attachment provided as an unfurled media item
description?stringAlt text for the media, max 1024 characters
spoiler?booleanWhether the media should be a spoiler. Defaults to false
{
"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"
}
]
}
]
}

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.IsComponentsV2 and must not include content or embeds.
  • 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 embeds or content does not reliably clear an existing legacy field before MessageFlags.IsComponentsV2 validation. 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.components for MediaGallery/Thumbnail/File media, not just top-level attachments/embeds; the generated file is referenced only inside the component. This discovery is centralized in collectImageUrlsFromMessage (src/utils/image/imageExtractor.ts), which reuses appendComponentMediaFromMessage from src/utils/chat/contextMedia.ts. generate_image, generate_image_nai, and analyze_image all go through it, so a Components V2 image found in context can also be reloaded by any tool that accepts a media reference.

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 interactionCreate event 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.

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 unless attachments is 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.

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.

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.

Discord modal string selects hold at most 25 options. Persona workflows keep larger sets on the anchor message:

  1. With at most 25 preloaded options, the selected persona button opens the modal directly.
  2. With more than 25 options, that button updates the anchor message to localized range buttons (1-25, 26-50, and so on).
  3. The chosen range button opens a modal containing only that slice. The submitted modal phase exposes optionOffset for converting page-local indexes to absolute indexes.
  4. 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.

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, or File; 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 by tests/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.

FieldTypeDescription
typeinteger13 for a file component
id?integerOptional identifier for component
fileunfurled media itemThis unfurled media item is unique in that it only supports attachment references using the attachment://<filename> syntax
spoiler?booleanWhether the media should be a spoiler (or blurred out). Defaults to false
namestringThe name of the file. This field is ignored and provided by the API as part of the response
sizeintegerThe size of the file in bytes. This field is ignored and provided by the API as part of the response

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"
}
}
]
}

A Separator is a top-level layout component that adds vertical padding and visual division between other components.

FieldTypeDescription
typeinteger14 for separator component
id?integerOptional identifier for component
divider?booleanWhether a visual divider should be displayed in the component. Defaults to true
spacing?integerSize of separator padding - 1 for small padding, 2 for large padding. Defaults to 1
{
"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."
}
]
}

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.

FieldTypeDescription
typeinteger17 for container component
id?integerOptional identifier for component
componentsarray of container child componentsChild components that are encapsulated within the Container
accent_color??integerColor for the accent on the container as RGB from 0x000000 to 0xFFFFFF
spoiler?booleanWhether the container should be a spoiler (or blurred out). Defaults to false.
{
"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
}
]
}
]
}
]
}

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.

FieldTypeDescription
urlstringSupports arbitrary urls and attachment://<filename> references
proxy_url?stringThe proxied url of the media item. This field is ignored and provided by the API as part of the response
height??integerThe height of the media item. This field is ignored and provided by the API as part of the response
width??integerThe width of the media item. This field is ignored and provided by the API as part of the response
content_type?stringThe media type of the content. This field is ignored and provided by the API as part of the response
attachment_id?*snowflakeThe 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.

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.

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.

BoundValueNotes
Total components40Counted recursively: top-level layout components, nested children, and Section accessories all count. Top-level array length is not the measure.
Text Display total4,000 codepointsSummed 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 Rowone select, or at most 5 buttonsNever a mixture.
Section1 to 3 Text Displays, exactly one accessoryThe accessory must be a supported Button or Thumbnail.
String Select1 to 25 optionsOption label, value, and description at most 100; placeholder at most 150; minValues/maxValues coherent with the option count.
Buttonlabel at most 80Interactive custom_id 1 to 100 and unique within the message; link URL at most 512.
Media descriptionat most 1,024Where 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.

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.

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.

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 ComponentsV2LimitError carrying 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.

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.

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"
}
]
}
]
}