Skip to content

Handler API (ctx)

A code handler is one file in handlers/ with a single default export:

export default async function (ctx) {
  await ctx.reply("Hello " + ctx.user.mention);
}

Handlers run in a sandbox: no imports, no require, no process, no timers, no filesystem. Everything goes through ctx. Helper functions may be declared in the same file. Each file is independent.

Who and where

  • ctx.user — { id, username, displayName, name, mention, bot, avatarUrl, createdAt, accountAgeDays }
  • ctx.member — { id, displayName, roles: string[], permissions: string[], joinedAt } or null
  • ctx.guild — { id, name, memberCount } or null
  • ctx.channel — { id, name, mention } or null
  • ctx.bot — { id, name, username }
  • ctx.options — slash-command options by name. Text/number/boolean options are plain values. User options are { id, username, displayName, name, mention, bot }, channel options { id, name, mention }, role options { id, name, mention }.
  • ctx.message — the triggering message for message/reaction/button events: { id, content, channelId, authorId, authorName }
  • ctx.customId — the pressed button's ID (button events). ctx.emoji — the reaction emoji (reaction events).
  • ctx.config — values of the bot's configuration settings, by key (channel/role settings hold IDs; "roles" settings hold arrays of IDs).
  • ctx.env — secrets configured under Integrations (API keys).
  • ctx.hasPermission("ManageMessages"), ctx.hasRole(roleId) — checks on the acting member.

Messages

A message is a string or an object: { content, embeds, buttons, files, ephemeral }.

  • embed: { title, description, url, color: "#RRGGBB", author, footer, thumbnail, image, timestamp: true, fields: [{ name, value, inline }] }
  • button: { label, style: "primary" | "secondary" | "success" | "danger", customId, emoji } or a link button { label, url }
  • file: { name: "transcript.txt", content: "text" }

Pressing a button fires the automation whose Button trigger matches its customId (exact, or prefix for IDs like "poll:42:yes").

  • await ctx.reply(message, { ephemeral }) — answer the command/button (or post in the current channel for other events)
  • await ctx.followUp(message) — extra message after the first reply
  • await ctx.update(message) — button events: edit the message the button is on
  • await ctx.defer({ ephemeral }) — acknowledge now, reply later (done automatically after 2 seconds)
  • await ctx.send(channelId, message) → { id, channelId }
  • await ctx.dm(userId, message) → { id } or null when the member's DMs are closed. At most 5 per run and 120 per hour for the whole bot: bulk DMs are refused
  • await ctx.editMessage(channelId, messageId, message)
  • await ctx.deleteMessage(channelId?, messageId?) — defaults to the triggering message
  • await ctx.react(emoji, channelId?, messageId?)
  • await ctx.fetchMessages(channelId, limit ≤ 100) → [{ id, content, authorId, authorName, createdAt }] oldest first

Members, roles, moderation

  • await ctx.getMember(userId) → member or null
  • await ctx.roles.add(userId, roleId, reason?), await ctx.roles.remove(userId, roleId, reason?), await ctx.roles.get(roleId) → { id, name, mention } or null
  • await ctx.moderation.timeout(userId, secondsOrText, reason?) — accepts 600 or "10m"
  • await ctx.moderation.kick(userId, reason?), .ban(userId, { reason, deleteMessageSeconds }), .unban(userId)
  • await ctx.moderation.purge(channelId, count ≤ 100) → number deleted

Channels

  • await ctx.channels.create({ name, parentId, topic, private: true, allowUserIds, allowRoleIds }) → { id, name, mention }
  • await ctx.channels.delete(channelId), await ctx.channels.get(channelId)

Storage (persists across restarts, separate per server)

  • await ctx.store.get(key, fallback?), .set(key, value, { ttlSeconds }), .delete(key)
  • await ctx.store.incr(key, by = 1) → new number
  • await ctx.store.list(prefix, { limit, sort: "key" | "valueDesc" | "valueAsc" }) → [{ key, value }]
  • await ctx.store.count(prefix)
  • ctx.globalStore — same API, shared across servers.

Values must be JSON-serialisable. Use "valueDesc" on numeric values for leaderboards.

Utilities

  • ctx.random(min, max) inclusive integer, ctx.pick(array), await ctx.sleep(ms ≤ 10000), ctx.now()
  • ctx.parseDuration("1h30m") → seconds or null, ctx.format.duration(seconds) → "1h 30m"
  • ctx.format.time(date, "R") → Discord timestamp, ctx.format.number(n), ctx.format.user(id), .role(id), .channel(id)
  • ctx.log.info(text), .warn(text), .error(text) — appear in the bot's live logs. console.log works too.
  • ctx.track("ticket_opened", { data }) — custom analytics event
  • await ctx.fetch(url, { method, headers, body, timeoutMs }) → { ok, status, headers, text(), json() }. HTTPS only, and the host must be allow-listed under Integrations.
  • await ctx.ai.ask({ prompt, system, maxTokens }) → string. Needs an ANTHROPIC_API_KEY or GEMINI_API_KEY secret under Integrations.

Rules

  • Throwing an Error stops the handler; the member sees a generic error and the details go to the logs.
  • A command that never replies gets an automatic "Done." so Discord doesn't show a failure.
  • Never hard-code IDs or secrets: declare a configuration setting and read ctx.config.key, or use ctx.env.KEY.

Example: a daily reward

export default async function (ctx) {
  const key = "daily:" + ctx.user.id;
  const nextAt = await ctx.store.get(key);
  if (nextAt && nextAt > Date.now()) {
    return ctx.reply({ content: "Come back " + ctx.format.time(nextAt, "R") + ".", ephemeral: true });
  }
  const balance = await ctx.store.incr("balance:" + ctx.user.id, 100);
  await ctx.store.set(key, Date.now() + 86400000, { ttlSeconds: 86400 });
  await ctx.reply("You collected 100 coins. Balance: " + balance);
}