Commands
Each file in src/commands/ exports one command. Popii reads the file, registers the slash command with Discord, and routes incoming interactions to it automatically. There’s no index file to maintain and no registration call to write.
Basic command
Section titled “Basic command”import { command } from "popii-framework";
export default command({ name: "hello", description: "Say hello back", async do(pop) { await pop.reply(`Hello, ${pop.user.username}!`); },});Subcommands via folder nesting
Section titled “Subcommands via folder nesting”Folders automatically become subcommand groups. The file path maps directly to the slash command path:
src/commands/ config/ set.ts → /config set reset.ts → /config reset admin/ ban.ts → /admin ban ping.ts → /pingOptions
Section titled “Options”Declare parameters with the options array. Access them via pop.options using the same getter API as discord.js:
import { command } from "popii-framework";import { ApplicationCommandOptionType } from "discord.js";
export default command({ name: "greet", description: "Greet a user", options: [ { name: "user", description: "Who to greet", type: ApplicationCommandOptionType.User, required: true, }, { name: "message", description: "Custom greeting", type: ApplicationCommandOptionType.String, }, ], async do(pop) { const target = pop.options.getUser("user", true); // true = required const msg = pop.options.getString("message") ?? "Hello"; await pop.reply(`${msg}, ${target}!`); },});Typed options with schemas
Section titled “Typed options with schemas”Pass a schema and Popii validates the raw options and exposes a typed pop.input object instead. Use this with Zod or any Standard Schema-compatible library:
import { command } from "popii-framework";import { z } from "zod";import { ApplicationCommandOptionType } from "discord.js";
export default command({ name: "echo", description: "Repeats your text", options: [ { name: "text", description: "Text to echo", type: ApplicationCommandOptionType.String, required: true }, ], schema: z.object({ text: z.string().min(1).max(200), }), async do(pop) { await pop.reply(pop.input.text); // string — fully typed and validated },});If validation fails, Popii automatically replies with an ephemeral error before your handler runs.
Cooldowns
Section titled “Cooldowns”Attach a cooldown to rate-limit individual users, the whole guild, or globally:
export default command({ name: "daily", description: "Claim your daily reward", cooldown: { ms: 86_400_000, scope: "user" }, async do(pop) { await pop.reply("Here's your daily reward!"); },});scope | Who shares the cooldown |
|---|---|
"user" (default) | Each user gets their own timer |
"guild" | One shared timer across the whole server |
"global" | One shared timer across all servers |
When a user hits a cooldown, Popii replies with an ephemeral message showing how long they need to wait. You don’t need to handle that yourself.
Permissions
Section titled “Permissions”import { PermissionFlagsBits } from "discord.js";
export default command({ name: "kick", description: "Kick a member", guildOnly: true, permissions: ["KickMembers"], // Discord permission required allowedRoles: ["1234567890"], // specific role IDs allowed deniedRoles: ["0987654321"], // specific role IDs denied allowedChannels: ["1122334455"], // restrict to specific channels async do(pop) { /* ... */ },});Permission checks run in this order: channel → permissions → allowed roles → denied roles. Failed checks reply with an ephemeral rejection — no code needed in your handler.
Ephemeral replies
Section titled “Ephemeral replies”export default command({ ephemeral: true, // every reply from this command is ephemeral async do(pop) { await pop.reply("Only you can see this."); },});You can also set ephemeral: true per-reply for finer control:
await pop.reply({ content: "Only you see this", ephemeral: true });Deferred responses
Section titled “Deferred responses”Discord requires a response within 3 seconds or the interaction expires. For anything slower, call pop.defer() first:
async do(pop) { await pop.defer(); // acknowledges immediately, shows a loading indicator const data = await fetchSomethingSlow(); await pop.reply(data); // follow-up reply, no time limit after defer},Context menu commands
Section titled “Context menu commands”Right-click commands on messages or users:
import { ApplicationCommandType } from "discord.js";
export default command({ name: "Translate Message", // shown in the right-click menu type: ApplicationCommandType.Message, async do(pop) { const msg = pop.targetMessage!; const translation = await translate(msg.content); await pop.reply({ content: translation, ephemeral: true }); },});For user context menus, use ApplicationCommandType.User and access pop.targetUser.
Text commands (prefix-based)
Section titled “Text commands (prefix-based)”export default command({ name: "ping", description: "Ping (text version)", text: true, // enables !ping slash: false, // skip slash registration async do(pop) { await pop.reply("Pong!"); },});Set prefix in popiiClient() to enable text commands globally. Both text and slash default to true, so leaving them out registers both.
Disabling a command at runtime
Section titled “Disabling a command at runtime”// disableclient._disabledCommands.add("ping");
// re-enableclient._disabledCommands.delete("ping");Disabled commands remain in Discord’s command list but return a “currently unavailable” message. The web dashboard also exposes a toggle for this per-server.