The whole Discord bot, wired and typed.
Everything a Discord bot needs, commands, events, components, gates, lifecycle and plugins, wired and typed on top of discord.js. A wrong route or option is a compile error, before the bot ever connects.
- gateway & http transports
- typed slash commands
- typed slash options
- subcommand routing
- autocomplete handlers
- context menu commands
- typed command mentions
- typed emojis
- typed customId codec
- ComponentsV2 first
- button & selectmenu handlers
- modal handlers
- confirmation prompts
- restart-proof pagination
- permission & role gates
- guild / DM / NSFW gates
- cooldown gate
- sliding-window rate limiter
- composable custom & effect gates
- Notice / Fault / Silence error flow
- webhook fault reporters
- coordinated startup & shutdown
- HTTP health check
- logger with channels & sinks
- typed pub/sub bus
- interaction & event middleware
- typed event handlers & waitFor
- Vite HMR hot reload, gateway stays alive
- Ink dev UI
- cloudflared dev tunnel
- project scaffolding
- seedcord codegen
- seedcord commands wizard
- typed plugins
- Postgres & Mongo plugins
- eslint rules for discord.js
The builder is the source of the types.
You define options on the standard discord.js builder for slash commands or context menu commands. seedcord codegen reads it and writes the accessor types, so the builder stays the only schema you maintain.
Choices become a literal union
'books' | 'films'
Required options are never null
use it directly
Getter signatures regenerate on change
// Generated by seedcord codegen. Do not edit by hand.
declare module '@seedcord/gateway' {
interface SlashOptionRegistry {
search: {
category: {
kind: 'string';
required: true;
choices: ['books', 'films'];
};
};
}
}import {
RegisterCommand, BuilderComponent
} from '@seedcord/gateway';
@RegisterCommand('global')
export class SearchCommand extends
BuilderComponent<'command'> {
constructor() {
super('command');
this.instance
.setName('search')
.setDescription('Search the catalog')
.addStringOption((o) =>
o
.setName('category')
.setDescription('What to look through')
.setRequired(true)
.addChoices(
{ name: 'Books', value: 'books' },
{ name: 'Films', value: 'films' }
)
);
}
}import {
SlashRoute, SlashHandler
} from '@seedcord/gateway';
@SlashRoute('search')
export class SearchHandler extends
SlashHandler<'search'> {
public async execute(): Promise<void> {
// generated accessor, no cast,
// no null check
const category =
this.options.getString('category');
// ^? 'books' | 'films'
await this.reply(`Searching ${category}`);
}
}the resolved type
Hover category and the editor says 'books' | 'films'. You never typed that union.
const category = getString('category');
// ^? 'books' | 'films'
const valid: 'books' | 'films' = category; // OK
const wrong: 'audio' = category; // Error 2322Same command.
Far less to write.
// build it, route to the subcommand,
// validate, all by hand
const data = new SlashCommandBuilder()
.setName('library')
.addSubcommand((s) =>
s.setName('search').addStringOption(...)
);
client.on(Events.InteractionCreate, async (i) => {
if (!i.isChatInputCommand()) return;
if (i.commandName !== 'library') return;
if (i.options.getSubcommand() !== 'search') return;
const raw = i.options.getString('query');
if (raw === null) throw new Error('required');
const query = raw as 'fiction' | 'nonfiction'; // cast
// ...manual guard and cooldown checks...
await i.reply(`Searching for ${query}`);
});
// plus a REST register script, plus a
// switch per subcommand,
// plus a full process restart on every editimport {
SlashRoute, SlashHandler,
Gated, GuildOnly
} from '@seedcord/gateway';
@Gated(GuildOnly())
@SlashRoute('library/search')
export class SearchHandler extends
SlashHandler<'library/search'> {
public async execute() {
const query = this.options.getString('query');
// ^? 'fiction' | 'nonfiction'
await this.reply(`Searching for ${query}`);
}
}
// the route, registration and guards are done.
// edit, save, hot reload, the gateway stays up.The left wires the routing, registration and checks by hand. seedcord does that wiring from your decorators.
100 characters.
Packed by a
typed schema.
Discord limits a customId to 100 characters. The codec packs your fields so more state fits than a joined string would, then decodes them back to exact, typed values.
import {
BuilderComponent, CustomId
} from '@seedcord/gateway';
export const Roles = new CustomId('roles')
.snowflake('memberId')
.oneOf('mode', ['add', 'remove']);
export class RolePicker extends
BuilderComponent<'menu_role'> {
constructor(memberId: string) {
super('menu_role');
const id = Roles.encode({
memberId,
mode: 'add'
});
this.instance
.setPlaceholder('Roles to add')
.setCustomId(id);
}
}import {
SelectMenuHandler,
SelectMenuKind,
SelectMenuRoute
} from '@seedcord/gateway';
import { Roles } from '#components/role-picker';
@SelectMenuRoute(SelectMenuKind.Role, Roles)
export class RolePickerHandler extends SelectMenuHandler<
SelectMenuKind.Role,
[typeof Roles]
> {
public async execute(): Promise<void> {
const { memberId, mode } = this.params;
// memberId: string, mode: 'add' | 'remove'
const picked = this.event.values;
await this.reply(
`${mode} ${picked.length} <@${memberId}>`
);
}
}Compose the guards.
The compiler checks.
Stack @Gated guards on a handler and combine them with and() and or(). Guild, owner, role, permission and cooldown each run at runtime, before your handler does. Attaching one to the wrong handler kind is a compile error.
import {
Gated, and, or,
GuildOnly, OwnerOnly, RequireRole,
SlashRoute, SlashHandler
} from '@seedcord/gateway';
@Gated(or(
OwnerOnly(),
and(GuildOnly(), RequireRole(modRoleId))
))
@SlashRoute('ban')
export class BanHandler extends SlashHandler<'ban'> {
public async execute() {
// an owner, or a mod inside a guild, gets through
}
}The dev server has a UI.
seedcord dev starts your bot inside a terminal UI. Filter the log stream by channel and level, and watch the uptime beside it. On http you get the bound port and the tunnel status too. Save a handler and Vite swaps it in a few milliseconds with the bot still up, so your change reaches Discord without a restart.

It all comes built in.
Commands · 06
decorator routing
@SlashRoute, @ButtonRoute and four more bind handlers at startup
subcommand routing
subcommands and groups, routed by name
typed slash options
accessors generated from your command definitions
typed autocomplete
a handler per option, missing one is a compile error
command mentions
renders </route:id> once Discord assigns the id
emojis
resolved at startup, reached by name
Components & replies · 06
component handlers
buttons, selects and modals, routed by customId
customId codec
pack fields into 100 characters, decode them typed
context menus
user and message commands
multi-route handlers
one class serves several routes, narrowed by this.match
getConfirmation
an ephemeral confirm, resolves to a boolean
pagination
each nav button carries its page, so a restart keeps working
Events · 05
event handlers
body typed to the exact event
event emitter
event names and payloads typed together
typed waitFor
await a single typed event inline
pub/sub bus
framework events publish on default keys
middleware
runs before your handler, typed the same way
Guards & failures · 05
permission & role gates
checked before the handler runs
composable gates
stack them with and, or
cooldowns
scoped per user, guild or channel
rate limiter
a sliding window per key
errors
Notice to refuse, Fault to report, Silence to drop
Runtime · 04
lifecycle
phased startup and shutdown
logger
named channels, levels and sinks
health check
an HTTP endpoint that reports readiness
webhook reporters
faults posted to a Discord webhook
Tooling · 07
create seedcord
answers a few questions, writes the project
seedcord dev
a full-screen dev terminal
hot reload
Vite HMR swaps changed modules, the gateway stays connected
dev tunnel
opens cloudflared and points Discord at it
codegen
writes the types for your commands and config
seedcord commands
inspect and clean commands already deployed
eslint rules
flags payloads Discord rejects, before you send them
Plugins · 03
typed plugins
attach once, codegen types it on core
Mongoose
MongoDB, services typed by key
Kysely
Postgres, queries typed off your schema
Two transports.
One set of handlers.
Your handlers compile on both, and the import line will usually be the only difference.
@seedcord/gateway
Holds a websocket connection, built on discord.js.
Discord streams every event down it. Messages, joins, voice state, typing, and all other events. Pick it when your bot reacts to anything past interactions.
@seedcord/http
Answers Discord's interactions endpoint.
Discord POSTs each interaction to your URL, and seedcord verifies the Ed25519 signature before anything dispatches. Nothing else arrives here, so a commands-only bot never opens a connection.
From zero
to hot reload.
Scaffold a typed bot, open it, and run it. Routing, registration and the option types are wired for you, and hot reload keeps the gateway alive.
$ pnpm create seedcord my-bot # scaffold a typed bot
$ cd my-bot
$ seedcord dev # tui | hot reload, gateway alive
# bot online, every slash option fully typed