spearkit
Guides

Contexts

Every spearkit handler — command, button, select, modal — receives a context object. They all share BaseContext, which smooths over discord.js' reply/defer/edit/follow-up state…

import { command, option } from "spearkit";

export default command({
  name: "hello",
  description: "Say hello",
  options: { name: option.string({ description: "Name", required: true }) },
  run: (ctx) => ctx.reply(`Hi, ${ctx.options.name}!`),
});

CommandContext, ButtonContext, StringSelectContext, modal contexts and the rest extend BaseContext, adding their own specifics (e.g. ctx.options, ctx.params, ctx.fields) on top of everything below.

Reply helpers

MethodReturnsBehaviour
reply(input)Promise<InteractionResponse>Send the initial response.
replyEphemeral(input)Promise<InteractionResponse>Reply, hidden to everyone but the invoking user.
defer({ ephemeral })Promise<InteractionResponse>Acknowledge now, respond later via editReply.
editReply(input)Promise<Message>Edit the original (or deferred) response.
followUp(input)Promise<Message>Add a message after the initial response.
send(input)Promise<void>State-aware: replies, edits, or follows up automatically.
error(input, options?)Promise<void>State-aware preset error embed; ephemeral by default.
success / info / warn (input, options?)Promise<void>State-aware preset embeds.
replyError / replySuccess / replyInfo / replyWarn (input, options?)Promise<InteractionResponse>Initial-reply preset embeds.
import { command } from "spearkit";

export default command({
  name: "demo",
  description: "Reply helpers",
  run: async (ctx) => {
    await ctx.reply("Working on it…");
    await ctx.followUp("…almost done.");
  },
});

send is the one most handlers need

send inspects the interaction state and does the right thing:

  • not yet answered → reply
  • already deferred → editReply
  • already replied → followUp

This means you can call send without tracking whether you deferred, which is ideal for shared helpers that may run before or after a defer.

import { command } from "spearkit";

export default command({
  name: "report",
  description: "Generate a report",
  run: async (ctx) => {
    await ctx.defer(); // acknowledge while we do slow work
    const data = await buildReport();
    await ctx.send(data); // sees the deferred state → edits the reply
  },
});

error for ephemeral failures

error(input, options?) sends a state-aware preset error embed — ephemeral by default (pass { ephemeral: false } to make it public) — perfect for validation failures that only the invoking user should see.

import { command, option } from "spearkit";

export default command({
  name: "kick",
  description: "Kick a member",
  options: { who: option.user({ description: "Member", required: true }) },
  run: async (ctx) => {
    if (!ctx.guild) return ctx.error("This command only works in a server.");
    await ctx.reply(`Kicked ${ctx.options.who}.`);
  },
});

Preset embeds

BaseContext builds consistent, colored embeds from client.embeds (or a shared default). Each takes an EmbedPresetInput — a plain string, or a structured body ({ title?, description?, fields?, footer?, ... }) — and an optional { ephemeral? }.

MethodSends viaDefault visibility
success(input, options?)send (state-aware)public
info(input, options?)send (state-aware)public
warn(input, options?)send (state-aware)public
error(input, options?)send (state-aware)ephemeral
replySuccess / replyInfo / replyWarn (input, options?)reply (initial only)public
replyError(input, options?)reply (initial only)ephemeral
import { command } from "spearkit";

export default command({
  name: "save",
  description: "Save settings",
  run: async (ctx) => {
    await ctx.success("Settings saved.");            // green embed, public
    await ctx.warn({ title: "Heads up", description: "Quota is almost full." });
    // error defaults to ephemeral; make it public with { ephemeral: false }:
    // await ctx.error("Failed to save.", { ephemeral: false });
  },
});

Configure the colors/icons with the client embeds option; see the API reference.

The { ephemeral: true } shortcut

discord.js represents an ephemeral reply with flags: MessageFlags.Ephemeral. spearkit lets you write the more obvious { ephemeral: true } on any reply payload and maps it to that flag for you. The input type is ReplyInput (string | ReplyData), where ReplyData is discord.js' InteractionReplyOptions plus the optional ephemeral boolean.

import { command, EmbedBuilder } from "spearkit";

export default command({
  name: "secret",
  description: "Only you can see this",
  run: (ctx) =>
    ctx.reply({
      embeds: [new EmbedBuilder().setTitle("Just for you")],
      ephemeral: true, // mapped to MessageFlags.Ephemeral
    }),
});

replyEphemeral(input) is sugar for the same thing, accepting either a string or a payload:

await ctx.replyEphemeral("Saved.");
await ctx.replyEphemeral({ embeds: [embed] });

If you set flags yourself, spearkit preserves them and adds the ephemeral flag rather than overwriting it.

Exported helpers

spearkit exports the two functions it uses internally, so you can normalise reply input yourself (e.g. in a plugin or shared utility):

  • normalizeReply(input: ReplyInput): InteractionReplyOptions — converts a string or ReplyData into a discord.js reply payload, applying the ephemeral flag mapping.
  • asEphemeral(input: ReplyInput): ReplyData — marks any input ephemeral, regardless of how it was passed.
import { normalizeReply, asEphemeral } from "spearkit";

normalizeReply("hi");
// → { content: "hi" }

normalizeReply({ content: "hi", ephemeral: true });
// → { content: "hi", flags: MessageFlags.Ephemeral }

asEphemeral("hidden");
// → { content: "hidden", ephemeral: true }

Accessors

BaseContext forwards the common interaction fields so you do not reach through ctx.interaction for everyday data:

AccessorDescription
interactionThe raw discord.js interaction.
clientThe SpearClient (typed as the interaction's client).
userThe invoking User.
memberThe invoking guild member (or null outside a guild).
guildThe Guild, or null in DMs.
guildIdThe guild id, or null.
channelThe channel the interaction came from.
channelIdThe channel id.
localeThe user's locale.
deferredWhether the interaction is already deferred.
repliedWhether the interaction already received an initial response.
botPermissionsThe bot's resolved permissions in the channel (PermissionsBitField, zero-fetch).
import { command } from "spearkit";

export default command({
  name: "whereami",
  description: "Report context",
  run: (ctx) =>
    ctx.reply(
      ctx.guild
        ? `In ${ctx.guild.name} (#${ctx.channelId}), locale ${ctx.locale}.`
        : "We're in a DM.",
    ),
});

deferred and replied let you branch when you are not using send:

import { button } from "spearkit";

export default button({
  id: "refresh",
  label: "Refresh",
  run: async (ctx) => {
    if (ctx.replied || ctx.deferred) await ctx.followUp("Refreshed.");
    else await ctx.reply("Refreshed.");
  },
});

Permission preflights

BaseContext reads the permissions Discord already attached to the interaction — no extra fetches — so you can check before attempting a privileged action:

import { command, PermissionFlagsBits } from "spearkit";

export default command({
  name: "slowmode",
  description: "Set slowmode",
  run: async (ctx) => {
    const missing = ctx.botMissing(PermissionFlagsBits.ManageChannels);
    if (missing.length > 0) return ctx.error(`I'm missing: ${missing.join(", ")}`);
    // …apply slowmode…
  },
});
  • ctx.botPermissions — the bot's PermissionsBitField in the current channel.
  • ctx.botMissing(required) — permission names the bot lacks here ([] if none).
  • ctx.userMissing(required) — permission names the invoking user lacks here.

For role-hierarchy and moderation preflights (acting on self/owner, comparing top roles) see moderationCheck and the permission helpers in the API reference.

Awaiting input

When a flow needs a follow-up message or a modal, the context wraps discord.js collectors so you skip the boilerplate. Both resolve to null on timeout.

import { command, modal, textInput } from "spearkit";

const nameModal = modal({ id: "name", title: "Your name", fields: { name: textInput({ label: "Name" }) }, run: () => {} });

export default command({
  name: "setup",
  description: "Interactive setup",
  run: async (ctx) => {
    // Wait for the user to type an answer in this channel:
    const reply = await ctx.awaitMessageFrom(ctx.user.id, { time: 30_000 });
    if (reply === null) return ctx.error("Timed out.");
    // Or show a modal and await its submission:
    const submission = await ctx.awaitModal(nameModal);
    if (submission !== null) await submission.reply(`Hi, ${submission.fields.getTextInputValue("name")}!`);
  },
});

The standalone awaitMessage, awaitComponent and showAndAwaitModal helpers are also exported; see the API reference.

See also

  • CommandsCommandContext, options and showModal.
  • Components — button, select and modal contexts.

On this page