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 nullctx.guild— { id, name, memberCount } or nullctx.channel— { id, name, mention } or nullctx.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 replyawait ctx.update(message)— button events: edit the message the button is onawait 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 refusedawait ctx.editMessage(channelId, messageId, message)await ctx.deleteMessage(channelId?, messageId?)— defaults to the triggering messageawait 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 nullawait ctx.roles.add(userId, roleId, reason?),await ctx.roles.remove(userId, roleId, reason?),await ctx.roles.get(roleId)→ { id, name, mention } or nullawait 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 numberawait 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.logworks too.ctx.track("ticket_opened", { data })— custom analytics eventawait 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 usectx.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);
}