Extensions can add slash commands — including multi-step flows with pop-up forms and buttons. Commands re-register with Discord automatically when you save (and when an admin toggles the extension).
Defining a command
defineExtension({
id: "poll",
name: "Poll",
permissions: ["discord.reply"],
commands: [{
name: "ping",
description: "Check that Olisar is alive.",
handler: async (i) => { await i.reply("pong") },
}],
})
| Command field | Type | Notes |
|---|
name / description | string | As they appear in Discord's slash-command list. |
options | OptionDef[] | Inputs: { name, description, type, required }. Types: string, integer, number, boolean, user, channel, attachment. |
defaultMemberPermissions | string or null | "manage_guild" to limit it to server managers, or null for everyone. |
guildOnly | boolean | Disallow the command in DMs. |
handler(i) | function | Runs the command; i is the live interaction. |
Read option values from i.options:
commands: [{
name: "echo",
description: "Repeat a message.",
options: [{ name: "text", description: "what to say", type: "string", required: true }],
handler: async (i) => { await i.reply(i.options.text) },
}]
File uploads (attachment options)
Use type: "attachment" so Discord shows a file picker. i.options.<name> is metadata only ({ id, filename, size, contentType }) — no filesystem path. Load the file two ways:
| Method | What you get | Size cap | When to use |
|---|
host.files.ingest(name) | { blobId, filename, size } (bytes stay on the host) | ~25 MB | Large files, external APIs, reply with the result |
host.files.read(name) | { contentB64, … } (base64 into the sandbox) | ~20 MB | Small files you process in JS |
Preferred pipeline (upload → external API → reply with file) — nothing large enters QuickJS:
permissions: ["discord.reply", "fetch"],
commands: [{
name: "compress",
description: "Compress a file via an external API.",
options: [{ name: "file", description: "File to compress", type: "attachment", required: true }],
handler: async (i) => {
await i.reply({ content: "Compressing…", ephemeral: true }) // ack within 3s
const input = await host.files.ingest("file") // host blob
const res = await host.fetch("https://api.example.com/compress", {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
bodyBlobId: input.blobId, // raw bytes as the request body
responseBlob: true, // store response as another host blob
})
if (!res.ok || !res.blobId) {
await i.followUp({ content: "Compression failed.", ephemeral: true })
return
}
await i.followUp({
content: "Done — compressed **" + input.filename + "**.",
files: [{ name: input.filename + ".gz", blobId: res.blobId }],
// stays private: followUps inherit ephemeral from the first reply
})
},
}]
Small-file path with base64 still works:
const file = await host.files.read("doc") // { filename, size, contentType, contentB64 }
await i.reply({ content: "Got " + file.filename + " (" + file.size + " bytes)", ephemeral: true })
File output (attach on reply)
reply / followUp / host.discord.send accept files. Each entry needs one of:
text — UTF-8 (≤ ~20 MB)contentB64 — binary as base64 (≤ ~20 MB)blobId — host-held blob from ingest / from / fetch({ responseBlob: true }) (≤ ~25 MB)
Max 10 files; total size capped at ~25 MB (Discord’s bot limit):
await i.reply({
content: "Here's your export.",
files: [
{ name: "report.csv", text: "a,b\n1,2\n" },
{ name: "out.bin", blobId: someBlob.blobId },
],
})
The interaction object
The handler's i exposes the conversation context (guildId, channelId, userId, displayName) and:
i.reply(payload) — the first response. A string, or { content, embed, ephemeral, components, files }.i.followUp(payload) — additional messages after the first (same shape; supports ephemeral and files).i.modal(spec) — pop a form and await the submitted values (permission discord.modal).i.awaitComponent({ timeoutMs }) — wait for a button click / menu choice (permission discord.components).
reply/followUp need discord.reply. Use ephemeral: true to make a message visible only to the caller. If the first reply is ephemeral, later followUps stay private by default (you can still set ephemeral: false to post publicly). Use host.files.read / ingest for attachment option bytes (command handlers only).
i.modal opens a Discord form and resolves with the submitted fields, keyed by id:
permissions: ["discord.reply", "discord.modal"],
commands: [{
name: "suggest",
description: "Submit a suggestion.",
handler: async (i) => {
const f = await i.modal({
title: "New suggestion",
fields: [
{ id: "title", label: "Title", style: "short", required: true },
{ id: "body", label: "Details", style: "paragraph" },
],
})
await i.reply({ content: "Thanks! Logged: " + f.title, ephemeral: true })
},
}]
Send components with reply, then wait for the interaction:
permissions: ["discord.reply", "discord.components"],
handler: async (i) => {
await i.reply({
content: "Ready to launch?",
components: [
{ kind: "button", customId: "go", label: "Launch", style: "primary" },
{ kind: "button", customId: "cancel", label: "Cancel", style: "secondary" },
],
})
const c = await i.awaitComponent({ timeoutMs: 30000 })
await i.followUp(c.customId === "go" ? "Launching!" : "Cancelled.")
}
A select component returns the chosen values in c.values. If nobody responds within timeoutMs, the await rejects — catch it and tidy up.
awaitComponent is for a single, short-lived prompt — it stops listening after timeoutMs (and after a bot restart). For buttons many people click over hours or days — polls, RSVPs — declare a `components` map instead. Each handler has a short key; a button/select that references it by handlerId keeps working for everyone and survives restarts (no per-message rebuild).
permissions: ["kv", "discord.reply", "discord.components"],
components: {
// keyed by handlerId; runs on every click, by anyone, forever
vote: async (i) => {
const tally = (await host.kv.get("tally")) || {}
tally[i.userId] = i.arg // i.arg is the small payload you set on the button
await host.kv.set("tally", tally)
await i.update({ embed: host.embed({ title: "Votes: " + Object.keys(tally).length }) })
},
},
commands: [{
name: "poll", description: "Start a poll.",
handler: async (i) => {
await i.reply({
content: "Pick one:",
components: [
{ kind: "button", handlerId: "vote", arg: "a", label: "A" },
{ kind: "button", handlerId: "vote", arg: "b", label: "B" },
],
})
},
}]
A persistent handler receives a ComponentInteraction i with customId, arg, values (for selects), and the usual context. Its methods differ from a command's:
i.reply(payload) — answer the clicker privately (ephemeral).i.update(payload) — edit the source message in place (live tally, attendee list). Pass components: [] to clear the buttons; omit components to leave them.i.deferUpdate() — acknowledge the click with no visible change.
Keep handlerId + arg short (under ~40 chars); store anything bigger in host.kv and pass its key as arg. The host stamps the routing id, so a click can only ever reach the extension that owns it.
Embeds
Build rich cards with host.embed and pass them to reply:
const card = host.embed({
title: "Status", description: "All systems nominal.", color: 0x2e9fff,
fields: [{ name: "Uptime", value: "5d 2h", inline: true }],
footer: "live",
})
await i.reply({ embed: card })