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:

  1. An agent on any event (often messageCreate) posts a message containing components such as buttons.
  2. A second agent on interactionCreate handles 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.

Reserved custom ID prefixes

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

poll-post.js (event: messageCreate)
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

poll-handle.js (event: interactionCreate)
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

MemberMeaning
isButton() / isStringSelectMenu() / isModalSubmit() / ...Type guards; always check before handling.
customIdThe ID you set on the component.
valuesSelected values (select menus, Lesson 8).
user / memberWho clicked.
messageThe message the component is attached to.
channel / guildWhere it happened.

Ways to respond

MethodEffect
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).
Respond within 3 seconds

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

self-disabling button (inside the interactionCreate handler)
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 interactionCreate agent in the server sees every component interaction; namespace your IDs and return early on everything else.
  • Double-acknowledging. Calling reply() after update() (or vice versa) fails; pick one primary response, then use followUp() for extras.
  • Using a reserved prefix. A customId like help:1 is rejected when sending the message, not when clicking.
  • Link buttons with custom IDs. Link-style buttons take setURL() and no customId; 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.