Skip to content

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.

src/commands/hello.ts
import { command } from "popii-framework";
export default command({
name: "hello",
description: "Say hello back",
async do(pop) {
await pop.reply(`Hello, ${pop.user.username}!`);
},
});

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 → /ping

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}!`);
},
});

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.

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!");
},
});
scopeWho 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.

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.

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 });

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
},

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.

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.

// disable
client._disabledCommands.add("ping");
// re-enable
client._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.