Skip to content

Client Configuration

popiiClient() is the single entry point for creating a Popii bot. Pass a config object; only token is required. Everything else has a sensible default.

import { popiiClient } from "popii-framework";
const client = popiiClient({
token: process.env.DISCORD_TOKEN!,
});
client.start();

Your Discord bot token. Keep it in .env — never commit it.

token: process.env.DISCORD_TOKEN!

mode"websocket" | "http" · default "websocket"

Section titled “mode — "websocket" | "http" · default "websocket"”
  • "websocket" — Standard Gateway connection. Works everywhere, recommended for most bots.
  • "http" — HTTP interactions endpoint for serverless or edge deployments. Requires publicKey and port.
mode: "http",
publicKey: process.env.DISCORD_PUBLIC_KEY!,
port: 3000,

A guild ID to sync slash commands to during development. Guild commands sync instantly; without this, global sync can take up to an hour.

devGuildId: process.env.DEV_GUILD_ID,

Remove or leave undefined in production.


prefixstring | (message) => string | string[] | null

Section titled “prefix — string | (message) => string | string[] | null”

Enables legacy prefix-based text commands. Without this, only slash commands work.

prefix: "!", // all commands accept !commandName
prefix: msg => msg.guild?.id === "123" ? "?" : "!", // dynamic prefix per guild

Discord user IDs with bot-owner privileges. Commands marked ownerOnly: true are restricted to these users.

owners: ["176341399966244864"],

Enables Redis-backed storage: persistent cooldowns, the Cooler KV store, pub/sub for cross-shard messaging.

redisUrl: "redis://localhost:6379",
redisUrl: process.env.REDIS_URL,

Alternative to redisUrl. Accepts a connection string or an ioredis options object.

Provide a fully custom storage adapter — any backend that implements the get/set/zadd interface. The built-in default is an in-process Map (non-persistent).


Popii infers both from your event filenames automatically. Override only when you need intents that have no corresponding event file.

import { GatewayIntentBits } from "discord.js";
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
import { Partials } from "discord.js";
partials: [Partials.Message, Partials.Reaction],

Fine-grained control over command loading and syncing.

commands: {
dir: "./src/commands", // default
folderRouting: true, // src/commands/config/set.ts → /config set
autoSync: true, // sync to Discord on every startup
},
OptionTypeDefaultDescription
dirstring"./src/commands"Directory Popii scans for command files
folderRoutingbooleantrueMap nested folders to subcommand groups
autoSyncbooleantrueSync slash commands to Discord at startup. Set to false in production and use popii sync instead

Controls the built-in /help command that lists all registered commands.

help: false, // disable entirely
help: {
enabled: true,
command: "commands", // /commands instead of /help
},

Suppress or patch commands that plugins bundle, without touching the plugin source.

pluginCommands: {
disable: ["daily"], // don't register this command at all
overrides: [
{ name: "balance", ownerOnly: true }, // restrict to owners
{ name: "leaderboard", cooldown: 10_000 }, // add a cooldown
],
},

User commands in src/commands/ always take priority over plugin commands regardless of this config.


The plugin array. Plugins initialize in list order; if plugin B depends on A, put A first.

import { sqlitePlugin, errorHandlerPlugin, webPlugin } from "popii-framework";
plugins: [
sqlitePlugin(),
errorHandlerPlugin(),
webPlugin({ port: 3000 }),
],

When true, Popii scans node_modules for any installed popii-plugin-* package (identified by a "popii" field in package.json) and loads it automatically.

Pass config to auto-discovered plugins by their package name:

autoDiscover: true,
pluginConfig: {
"popii-plugin-economy": { currencySymbol: "🪙" },
},

Activity text shown in the member list. An array rotates on a timer.

status: "Serving {{guilds}} servers", // single static status
status: ["Serving {{guilds}} servers", "Use /help"], // rotates

Template tokens: {{guilds}}, {{users}}, {{shardId}}

statusIntervalnumber · default 30000

Section titled “statusInterval — number · default 30000”

Milliseconds between status rotations when status is an array.

presenceStrategy"local" | "coordinated" · default "local"

Section titled “presenceStrategy — "local" | "coordinated" · default "local"”

Controls how rotation is coordinated across shards.

  • "local" — each shard picks its own status independently.
  • "coordinated" — shard 0 broadcasts the active status to all other shards via Redis pub/sub so they all show the same string. Requires Redis.

Every auto-loaded directory can be changed:

events: { dir: "./src/events" },
snaps: { dir: "./src/snaps" },
middlewares: { dir: "./src/middlewares" },
tasks: { dir: "./src/tasks" },

locales: {
dir: "./src/locales", // directory with <locale>.json files
default: "en-US", // fallback when a key is missing in the user's locale
url: "https://cdn.example.com/locales", // fetch from a remote URL instead
},

See the Localization guide for full details.


Called when a command or snap throws an error that is not caught by any middleware.

onError(error, pop) {
console.error(`Command failed for ${pop.user.tag}:`, error);
},

Called for unhandled promise rejections and uncaught exceptions not tied to a command.

onGlobalError(error) {
Sentry.captureException(error);
},

Replace the built-in logger with your own. Must implement info, warn, error, and debug.

import pino from "pino";
const pinoLogger = pino();
logger: {
info: (msg, ...args) => pinoLogger.info(args, msg),
warn: (msg, ...args) => pinoLogger.warn(args, msg),
error: (msg, ...args) => pinoLogger.error(args, msg),
debug: (msg, ...args) => pinoLogger.debug(args, msg),
},

Write rotating log files to ./logs/. Useful for debugging on a VPS where you can’t tail the process output live.

structuredLogsboolean · default false

Section titled “structuredLogs — boolean · default false”

Emit newline-delimited JSON instead of human-readable text. Pair with log aggregation tools like Datadog or Loki.


Initial value for pop.state and client.state. Shared across every handler, persisted to Redis/storage between restarts.

state: {
totalCommandsRun: 0,
startedAt: Date.now(),
},

TypeScript infers the state type from this value — pop.state.totalCommandsRun is typed as number everywhere.

stateTtlMsnumber · default 7_776_000_000 (90 days)

Section titled “stateTtlMs — number · default 7_776_000_000 (90 days)”

How long persisted state lives in Redis before expiring.


popiiClient() returns a PopiiClient with these methods:

MethodDescription
client.start()Connect to Discord and begin listening
client.stop()Graceful shutdown — drains active executions before disconnecting
client.reload()Hot-reload commands, events, snaps, and middleware without restarting
client.schedule(name, payload?, delayMs?)Trigger a named task from outside a handler
client.broadcast(event, data)Emit a cross-shard event (requires Redis)
client.isPremium(userId, guildId?)Check premium status via payPlugin
client.extend(name, factory)Add a custom property to every pop context
client.loadPlugin(plugin)Load a plugin at runtime
client.unloadPlugin(name)Unload a plugin at runtime
client.reloadPlugin(name)Reload a single plugin at runtime
client.setPresence(status)Update the bot’s activity string
client.global.emit(event, data?)Emit a custom in-process event
client.global.on(event, listener)Listen to a custom in-process event