Skip to content

Your first bot

Let’s build a moderation bot from scratch. We’ll start simple and add features as we go. First, scaffold a new project:

Terminal window
pnpm create @warden my-mod-bot

The scaffolder copies a fully-wired starter into my-mod-bot/ — Drizzle for persistence, Redis-friendly caching, audit logging, permissions, jobs, i18n, and a heartbeat plugin all pre-installed. You can ignore (or delete) the pieces you don’t need. Nothing is opt-in via prompts; everything is one rip-out away if it gets in your way.

Take a moment to poke at what got generated:

  • Directorysrc/
    • bootstrap.ts shared Bot — plugins + config discovery
    • app.ts Discord runtime entry — handlers, intents, middleware
    • cli.ts console entry — for db:migrate, db:seed, etc.
    • Directorycommands/
      • PingCommand.ts simple command
      • tasks/, polls/, mod/ mini-apps with common patterns
    • Directorycontext/
      • UserInfoContext.ts user context menu (right-click user)
    • Directorybuttons/
      • tasks/ConfirmClearTasksButton.ts dynamic custom ID handler
      • polls/PollVoteButton.ts
      • mod/ConfirmClearWarningsButton.ts
    • Directorymodals/
      • FeedbackModal.ts modal handler with typed fields
    • Directorymenus/
      • polls/PollVoteMenu.ts string-select menu
    • Directoryevents/
      • ReadyEvent.ts logs bot online
      • MemberJoinEvent.ts decomposed event example
      • MemberBoostEvent.ts another decomposed event
    • Directoryerrors/
      • FallbackErrorHandler.ts catch-all error handler
    • Directorylifecycle/
      • LogOnErrorLifecycle.ts bot lifecycle hook
    • Directoryjobs/ background jobs (empty, ready for you)
    • Directorymiddleware/
      • AdminOnly.ts reusable typed middleware
      • LogRequests.ts global middleware
    • Directoryrepositories/
      • TasksRepository.ts query layer for the tasks mini-app
      • PollsRepository.ts
      • ModRepository.ts
    • Directorydb/
      • Directoryschema/ drizzle schema definitions
      • types.ts pinned Db alias for full row inference
      • index.ts
    • Directoryconfig/ typed config per plugin
      • discord.ts, drizzle.ts, cache.ts, audit.ts, …
    • Directorylang/ i18n translation files
  • docker-compose.yml database + cache services
  • drizzle.config.ts drizzle-kit configuration
  • .env.example
  • .env
  • tsconfig.json
  • tsup.config.ts
  • vitest.config.ts
  • package.json
  • .gitignore

The starter is on the maximalist side on purpose — every primitive Warden ships gets a working example, so when you start building you can copy from a real pattern instead of staring at a blank file. Anything you don’t need (i18n, audit, the polls or tasks mini-apps), delete.

Open src/bootstrap.ts:

import 'reflect-metadata';
import '@warden/console';
import {Bot} from '@warden/core';
import {DrizzleServiceProvider} from '@warden/drizzle';
import {CacheServiceProvider} from '@warden/cache';
import {AuditServiceProvider} from '@warden/audit';
import {I18nServiceProvider} from '@warden/i18n';
import {PermissionsServiceProvider} from '@warden/permissions';
import {JobsServiceProvider} from '@warden/jobs';
import {HeartbeatServiceProvider} from '@warden/heartbeat';
const bot = new Bot()
.discover(d => d.config('./src/config/**/*.ts'))
.plugins([
DrizzleServiceProvider, // first — others resolve it during boot()
CacheServiceProvider,
AuditServiceProvider,
I18nServiceProvider,
PermissionsServiceProvider,
JobsServiceProvider,
HeartbeatServiceProvider,
]);
export default bot;

This is the shared Bot. It knows about your plugins and your config, but not whether it’s about to connect to Discord or run a console command — both entry points import it. Plugin order matters once: DrizzleServiceProvider goes first because everything else resolves it during boot(). After that, the order doesn’t matter.

Open src/app.ts next — this is the Discord runtime:

import 'dotenv/config';
import {env} from '@warden/core';
import bot from './bootstrap';
import LogRequests from './middleware/LogRequests';
await bot
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts')
.jobs('./src/jobs/**/*.ts')
.hooks('./src/{errors,lifecycle}/**/*.ts'))
.intents(i => i.defaults())
.partials(p => p.defaults())
.middleware([LogRequests])
.registerTo(env('DEV_GUILD_ID'))
.start();

Every line earns its keep:

  • .discover(...) is kind-bucketed. .interactions() accepts commands, context menus, buttons, modals, and select menus. .events() accepts events and decomposers. .jobs() and .hooks() (errors + lifecycle) round it out. A stray @event under .interactions(...) throws DiscoveryKindError at boot — wiring you can’t accidentally violate.
  • .intents(i => i.defaults()) declares which Discord gateway events your bot needs to receive.
  • .partials(p => p.defaults()) tells discord.js how to handle partial data structures.
  • .middleware([LogRequests]) registers a global middleware that runs on every interaction.
  • .registerTo(env('DEV_GUILD_ID')) scopes slash command registration to your dev server — guild-scoped commands update instantly, global ones take up to an hour to propagate.
  • .start() connects to Discord, syncs your slash commands, and begins listening.

Finally src/cli.ts — the console entry, used by pnpm db:migrate, pnpm db:seed, and any other console command you write:

import 'dotenv/config';
import bot from './bootstrap';
await bot
.discover(d => d.load('./src/db/seeders/**/*.ts'))
.console({scan: ['./src/console/**/*.{ts,js}']});

.console(...) is installed on the Bot class as a side effect of importing @warden/console in bootstrap.ts. It boots your plugins without connecting to Discord and dispatches process.argv into whichever decorated console command matches. .load(...) is the unchecked escape hatch for plugin decorators (like @seeder()) that don’t fit one of the strict buckets.

Open src/commands/PingCommand.ts:

import {command, Command, CommandInteraction, Params, reply} from '@warden/core';
@command({name: 'ping', description: 'Ping!'})
export default class PingCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const latency = Math.abs(Date.now() - interaction.createdTimestamp);
await reply(interaction, `Pong! (${latency}ms)`);
}
}

If you’ve used a web framework before, this pattern will feel familiar — the @command() decorator is your route definition, and execute() is the handler. The framework takes care of discovering, registering, and routing to this class automatically.

No registration files. No import lists. Just a decorated class.

Before you can run the bot, you’ll need a Discord application. If you already have one, skip ahead to Running the Bot.

  1. Head to the Discord Developer Portal

  2. Click New Application and give it a name

  3. Navigate to Bot in the sidebar and click Reset Token — copy the token

  4. Navigate to OAuth2 in the sidebar

  5. Under Scopes, select bot and applications.commands

  6. Under Bot Permissions, select the permissions your bot will need. For a moderation bot, you’ll typically want: Ban Members, Kick Members, Moderate Members, Manage Messages, and Send Messages

  7. Copy the generated URL and open it in your browser to invite the bot to your test server

Paste your token and application ID into the .env file:

DISCORD_TOKEN=your-bot-token
DISCORD_APPLICATION_ID=your-application-id

Then install dependencies and start the development server:

Terminal window
pnpm install
pnpm dev

You should see something like:

[info] Starting bot...
[info] Successfully started in 1234ms

Head to your Discord server and type /ping. Your bot should respond with its latency. Congratulations — you have a working Discord bot!

The development server watches for file changes, so from here on out, just edit your files, save, and the bot restarts automatically. You can also generate new files using the CLI:

Terminal window
pnpm warden make:command WarnCommand
pnpm warden make:event MemberJoinLogEvent

Now that your bot is up and running, it’s time to start building real features: