Scripting Guide
Lesson 7: Buttons and Interactions
Posting buttons from one agent and handling clicks in another: the interactionCreate event, custom IDs, and reply modes.
The two-agent pattern
Interactive UI in GuildScript is always split in two, because one agent handles one event:
- An agent on any event (often
messageCreate) posts a message containing components such as buttons. - A second agent on
interactionCreatehandles what happens when someone uses them.
The two agents are linked by the component's customId, a string you choose. The handler reads interaction.customId to know which button was pressed.
Custom IDs must not start with database:, agents:, agents_uninit:, llm:, standard_button:, docs:, help:, or vibe:. These belong to GuildScript's own UI and sends using them are rejected. Pick your own short namespace, for example rolepick: or quiz:.
Agent 1: posting buttons
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord";
export async function onMessage(message) {
if (message.author.bot) return;
if (message.content !== "!pizzapoll") return;
// Buttons live inside an ActionRow (max 5 buttons per row, 5 rows per message).
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId("pizza:yes")
.setLabel("Pineapple yes")
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId("pizza:no")
.setLabel("Pineapple no")
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setLabel("Why is this a debate?")
.setStyle(ButtonStyle.Link)
.setURL("https://en.wikipedia.org/wiki/Hawaiian_pizza"),
);
await message.channel.send({
content: "Pineapple on pizza?",
components: [row],
});
}Agent 2: handling clicks
export async function onInteraction(interaction) {
// interactionCreate receives ALL component interactions from YOUR agents,
// so filter by type and custom ID first.
if (!interaction.isButton()) return;
if (!interaction.customId.startsWith("pizza:")) return;
const vote = interaction.customId.split(":")[1]; // "yes" or "no"
// reply() posts a new message. With deferReply({ ephemeral: true }) +
// editReply() the answer is private to the clicker.
await interaction.reply(
interaction.user.username + " voted " + vote + "!"
);
}Expected behavior: !pizzapoll posts the question with three buttons. Clicking one posts <name> voted yes! (or no). The link button opens the URL and never reaches your handler.
The interaction object
| Member | Meaning |
|---|---|
isButton() / isStringSelectMenu() / isModalSubmit() / ... | Type guards; always check before handling. |
customId | The ID you set on the component. |
values | Selected values (select menus, Lesson 8). |
user / member | Who clicked. |
message | The message the component is attached to. |
channel / guild | Where it happened. |
Ways to respond
| Method | Effect |
|---|---|
reply(content) | Posts a new message in response. |
deferReply({ ephemeral: true }) then editReply(content) | Buys time (and optionally privacy) for slow work like db or llm calls. |
update(content) | Edits the message the button is attached to, e.g. to disable buttons or show results. |
deferUpdate() | Acknowledges silently with no visible response. |
followUp(content) | Additional messages after the first response. |
showModal(modal) | Opens a form (Lesson 8). |
Discord voids an interaction that gets no acknowledgment within about 3 seconds. If your handler does anything slow (database, AI), call deferReply() or deferUpdate() first, then finish with editReply().
Updating the original message
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord";
// Replace the row with a disabled copy after the first click.
const doneRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId("claim:done")
.setLabel("Claimed by " + interaction.user.username)
.setStyle(ButtonStyle.Secondary)
.setDisabled(true),
);
await interaction.update({
content: "This reward has been claimed.",
components: [doneRow],
});Common pitfalls
- Forgetting the type guard. Without
if (!interaction.isButton()) return;your handler also fires for menus and modals from your other agents. - No filtering by custom ID. Every
interactionCreateagent in the server sees every component interaction; namespace your IDs and return early on everything else. - Double-acknowledging. Calling
reply()afterupdate()(or vice versa) fails; pick one primary response, then usefollowUp()for extras. - Using a reserved prefix. A
customIdlikehelp:1is rejected when sending the message, not when clicking. - Link buttons with custom IDs. Link-style buttons take
setURL()and nocustomId; they never produce interactions.
Exercise
Build a role-claim pair: !getrole posts a button role:claim; the handler adds a role to interaction.user.id via interaction.guild.members.fetch() and member.roles.add(), replies ephemerally (deferReply({ ephemeral: true }) then editReply), and says Role added or You already have it based on member.roleIds.