Skip to content

Start here

The guide for using seedcord, a TypeScript framework for Discord bots built on discord.js. Go from an empty folder to a running bot with commands, replies, components, gates, and much more.

Commands, events, components, gates, replies, and the lifecycle all come with the framework. You write your bot's features. A wrong route or a wrong option name is a compile error, before the bot ever connects.

Note

seedcord is pre-1.0, so a minor version can break. Read the changelog before you bump. The http transport's edge build for Cloudflare Workers is still being written and cannot be used yet.

What seedcord covers

  • Commands. Slash commands, subcommands and groups, user and message context menus, autocomplete. seedcord sends every command it finds to Discord when the bot starts.
  • Components. Buttons, select menus, modals, and pagination. Discord hands a click back as the custom_id string you built, and parsing it is on you. In seedcord you declare the fields once, and the handler gets them back typed. The fields are packed, so you get more characters per character.
  • Replying. Discord allows one first response per interaction, then edits and follow-ups. seedcord tracks which one you already sent and throws a readable error when you call the wrong one.
  • Gates. A check that runs before your handler and refuses by throwing. @Gated puts one on a handler. You write your own with defineGate. Combine them with and when a command needs two things at once, or or when either will do. seedcord ships a catalog of gates you can import and use directly, like cooldowns and permission checks.
  • Throwing. One throw stops the command, shows the user your message, logs it, and publishes it for telemetry. It picks the right reply method for whatever state the interaction is already in. Notice is the one you subclass with your own message. Fault shows a generic failure and posts the real error to a Discord channel. Silence does all of it except the reply.
  • Events. Every event discord.js emits, on the gateway. You can publish and subscribe to your own alongside them.
  • The dev loop. Edit a handler and the running bot picks it up without a restart. Change a command and it asks whether to re-register. On http it opens a tunnel and points your app at it.

Philosophy

If you have built a Discord bot, you have written the routing yourself. A switch on the command name, a tree of if/else on the custom id, one controller per interaction kind, all of it extended by hand each time you add a command. I wrote that too, and I copied it into every bot I started.

TypeScript could not help with any of it. You registered the option yourself, and getString still hands back string | null. A custom id arrives as a plain string you parse and cast into the thing you encoded. Every read opens with a check whose answer you already know.

Every time I needed to test my bot and tweak it while doing so, I had to restart it. That meant waiting for it to log in, do any registration steps, and take its time on any other startup work before I could see my change in Discord.

Most frameworks put the routing, the checks, and the work in one handler. Each of those changes for a different reason, and a file holding all three gets edited every time any of them moves.

I wanted to fix all that, and more, following two principles in making the framework:

  1. seedcord automates what it reliably can, and still lets you stay in control.
  2. Types cover everything seedcord can know before your bot runs.

All this, while keeping the framework simple and straightforward to use for a bot of any size.

What a seedcord project looks like

my-bot
my-bot/
├─ src/
│  ├─ commands/
│  │  └─ Ping.ts
│  ├─ handlers/
│  │  └─ Ping.ts
│  ├─ events/
│  │  └─ Ready.ts
│  ├─ bot.ts
│  ├─ index.ts
│  └─ seedcord-gen.d.ts
├─ seedcord.config.ts
├─ .env
└─ package.json

src/commands/ holds what a command looks like on Discord. src/handlers/ holds the code that runs when someone uses one. src/events/ holds everything else happening in a server. bot.ts is your config, and seedcord-gen.d.ts is written by the CLI.

One command sets this up, along with the tsconfig, lint, and formatter files.

pnpm create seedcord

Your commands type your handlers

You declare an option once, on the command. The handler asks for it by name and gets back the type you declared.

src/handlers/Ban.ts
import { SlashHandler, SlashRoute } from '@seedcord/gateway';

@SlashRoute('ban')
export class Ban extends SlashHandler<'ban'> {
    public async execute(): Promise<void> {
        const target = this.options.getUser('target');
const target: User
await this.reply(`Banned ${target.username}`); this.options.getString('resaon');
Argument of type '"resaon"' is not assignable to parameter of type '"reason"'.
} }

target is required on the command, so it arrives as a User with no null check. reason is optional, so that one arrives as string | null. The typo on the last line stops the build.

Gateway and http differ

The sample is gateway, where getUser returns a discord.js User. On http the same call returns an APIUser, which is Discord's raw payload. Both carry username and id, and only the discord.js one has methods.

seedcord codegen reads your command files and writes seedcord-gen.d.ts. Run it again after you change a command, or say yes when seedcord dev offers to re-register.

Errors that tell you what to do

Every seedcord error tells you what happened and what you need to do about it. For example, you deferred and then called reply():

reply() was called when this interaction was already deferred.
Use edit() to fill the deferred reply or followUp() for a new
message. (route slash:ban)

Or you packed a component id past what Discord accepts:

Encoded customId is 118 characters, Discord allows at most 100.

Here you read a value before startup had filled it:

Emojis.wave has no value yet. Emojis fills during startup, and a read
at the top of a file runs before that.

discord.js validates a component through shapeshift, which throws a nested aggregate naming no component and no field. seedcord translates it:

ProfileCard at components[0] failed to serialize: need a
ButtonBuilder or ThumbnailBuilder, got nothing.
(route slash:profile)

There are over a hundred more.

Two transports

You pick one when you scaffold.

@seedcord/gateway holds a websocket connection through discord.js, so every event Discord sends about a server reaches your bot.

@seedcord/http answers Discord's interactions endpoint, where Discord posts only interactions and signs every request. seedcord checks each signature, turns away a stale or repeated one, and answers the check Discord runs before it accepts your URL.

Most of the code you write works the same on either transport, and the import line is likely all you'll need to change. Pick gateway or http based on your needs.

Prerequisites

  • Node 24.11 or newer. Every seedcord package declares it under engines.
  • TypeScript. seedcord uses it heavily. You read and write generics from the first handler onward.
  • discord.js. seedcord is built on top of it and covers most of the boilerplate. A lot of the code that touches Discord is still discord.js.