Skip to content
seedcordGitHub

The whole Discord bot, typed end to end.

A TypeScript framework for Discord bots. A wrong route or option name is a compile error, before the bot ever connects. Every command, button, and event goes to a class you write, with reusable checks, any Vite plugin, hot reload, and a lot more built in.

  • gateway & http transports
  • typed slash commands
  • typed slash options
  • subcommand routing
  • autocomplete handlers
  • context menu commands
  • typed command mentions
  • typed emojis
  • typed customIds
  • ComponentsV2 first
  • button, select menu & modal handlers
  • confirmation prompts
  • restart-proof pagination
  • permission & role checks
  • guild / DM / NSFW checks
  • cooldowns
  • sliding-window rate limiter
  • reusable custom checks
  • errors that reply and report
  • error reports to a Discord webhook
  • ordered startup & shutdown
  • HTTP health check
  • logger with named channels
  • typed pub/sub bus
  • interaction & event middleware
  • typed event handlers & waitFor
  • Vite HMR hot reload, gateway stays alive
  • any Vite plugin
  • .env modes
  • full-screen dev terminal
  • cloudflared dev tunnel
  • project scaffolding
  • seedcord codegen
  • deploy commands from the CLI
  • Node, Bun & single binary builds
  • Cloudflare Workers
  • your bot as an npm package
  • 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

commands/search.ts
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' }
                )
            );
    }
}
handlers/search.ts
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}`);
    }
}
seedcord-gen.d.ts
// Generated by `seedcord codegen`. Do not edit by hand.
declare module '@seedcord/gateway' {
    interface SlashRegistry {
        search: {
            options: {
                category: {
                    kind: 'string';
                    required: true;
                    choices: ['books', 'films'];
                };
            };
            cache: 'cached';
        };
    }
}

the resolved type

Hover category and the editor says 'books' | 'films'. You never typed that union.

proof.ts
const category = getString('category');
//    ^?  'books' | 'films'

const valid: 'books' | 'films' = category; // OK
const wrong: 'audio' = category; // Error 2322

Same command.
Far less to write.

by handseedcord · one handler
by hand
// 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');
    // cast
    const query = raw as 'fiction' | 'nonfiction';

    // ...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 edit
seedcord
import {
    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 connection stays up.

By hand you wire the routing, registration and checks yourself. seedcord does all that from your decorators.

Your bot runs on Vite.
Bring any plugin.

seedcord loads your bot through Vite, from the first seedcord dev to the production build. With a plugin in seedcord.config.ts, your handlers import YAML, SQL, or anything else that plugin loads.

The same plugins, .env modes, and data files work in dev and in the build. ENV=staging pnpm build loads your .env.staging files, and envapt reads them in your bot.

any Vite plugin.env modeshot reloaddata files
seedcord.config.ts
import yaml from '@rollup/plugin-yaml';
import { defineConfig } from 'seedcord';

export default defineConfig({
    root: './src',
    instance: './bot.ts',
    build: { start: './index.ts' },
    vitePlugins: () => [yaml()]
});
handlers/greet.ts
import {
    SlashRoute, SlashHandler
} from '@seedcord/gateway';

import strings from '../strings.yaml';

@SlashRoute('greet')
export class GreetHandler extends SlashHandler<'greet'> {
    public async execute(): Promise<void> {
        await this.reply(strings.greeting);
    }
}

Two transports.
One way to write handlers.

Handlers, checks, and the rest of the seedcord API work the same way on both. The objects they read differ: raw payloads on http, discord.js objects on the gateway.

@seedcord/http

Answers Discord's interactions endpoint.

Discord POSTs each interaction to your URL, and seedcord verifies the Ed25519 signature before anything dispatches. A commands-only bot never opens a connection, so it runs wherever a request can reach it, like your own server or Cloudflare Workers.

@seedcord/gateway

Holds a websocket connection through the discord.js client.

Discord streams every event down it. Messages, joins, voice state, typing, and all other events. Each one gets a typed handler class, and handlers read discord.js objects. Pick it when your bot reacts to anything past interactions.

One build.
Five places to run it.

seedcord build turns the same bot code into whichever one you need.

a Node or Bun server

Point build.start at the file that starts your bot, then run the output.

node dist/index.mjs

one Bun binary

The build prints the command that compiles your bot into a single file.

bun build --compile

Cloudflare Workers

An http bot with a cloudflare.config.ts builds into a worker.

cf deploy --prebuilt

inside your server

With port: false, a Hono or Next.js route passes each request to the bot.

bot.fetch(request)

an npm package

Without build.start, the build exports your bot with its types. You can publish it to npm, and anyone can install it and start it.

pnpm add your-bot

their-app.ts
import bot from 'your-bot';

await bot.start();

Discord hands back a flat string.
You get your typed fields.

A click arrives carrying the custom id you set, as one string. Split it yourself and every field comes back as string.

CustomId declares those fields once. this.params then returns each one at its real type.

Discord caps that string at 100 characters. The codec packs your fields into fewer of them than joining the values would.

encode(values) to stringdecode(id) to typed values
components/role-picker.ts
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);
    }
}
handlers/role-picker.ts
import {
    RoleMenuHandler, RoleMenuRoute
} from '@seedcord/gateway';
import { Roles } from '#components/role-picker';

@RoleMenuRoute(Roles)
export class RolePickerHandler extends
    RoleMenuHandler<[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. Attach one to the wrong handler kind and it fails to compile.

A refusal answers the user on its own, with a message you write once beside the check. Everyday refusals stay out of your logs, and the ones you mark as faults get logged and reported.

and() every arm passesor() any one arm passes
handlers/ban.ts
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 in a guild, gets through
    }
}

Write your own Plugin.
Reach it from every handler.

A plugin extends seedcord with whatever your bot needs, a database, a cache, a metrics client. Extend Plugin and give it a key when you attach it.

That key becomes a property on the same object every handler already carries. seedcord codegen writes its type, so this.core.uptime.startedAt compiles wherever you need it.

A plugin runs on both transports, and on Cloudflare Workers unless it needs a server. You can publish it to npm, and any other bot can attach it the same way.

plugins/uptime.ts
import { Plugin } from '@seedcord/core/plugin';
import type { CoreBase } from '@seedcord/core';

export class Uptime extends Plugin {
    public startedAt = 0;

    constructor(
        host: CoreBase,
        private readonly label: string
    ) {
        super(host);
    }

    public async init(): Promise<void> {
        this.startedAt = Date.now();
        this.logger.info(`${this.label} up`);
    }
}
bot.ts
import { Uptime } from '#plugins/uptime';

export default new Seedcord({
    // ...your bot config
}).attach('uptime', Uptime, 'my-bot');
seedcord-gen.d.ts
// Generated by `seedcord codegen`. Do not edit by hand.
import type Bot from './bot';

declare module '@seedcord/gateway' {
    interface Core {
        uptime: (typeof Bot)['uptime'];
    }
}

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.

The seedcord dev server running an http bot. A sidebar on the left shows the version, uptime, bound port and tunnel status. The log stream beside it shows a handler failing, reloading in 4ms, then serving the same command. A card at the bottom asks whether to refresh the changed commands.

It all comes built in.

Commands · 06

decorator routing

@SlashRoute, @ButtonRoute and five 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, and a missing one fails to compile

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

typed customIds

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 refuses, Fault reports, Silence stays quiet

Runtime · 05

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

typed env

envapt reads .env, your shell and Worker bindings

Tooling · 09

create seedcord

answers a few questions, writes the project

ready for agents

AGENTS.md, a skill and llms.txt

seedcord dev

a full-screen dev terminal

one dev session per app

a second seedcord dev on the same app stops and points at the first

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

deploy, list and clean up your commands on Discord

eslint rules

flags payloads Discord rejects, before you send them

Build · 08

Vite plugins

any Vite plugin, in dev and in the build

.env modes

ENV=staging loads your .env.staging files

data files

listed folders ship inside the build

Node or Bun

one bundle you start with node or bun

Bun binary

the build prints the compile command

Cloudflare Workers

an http bot builds into a worker

npm package

publish your bot for anyone to install and start

inside your server

a Hono or Next.js route calls bot.fetch()

Plugins · 03

typed plugins

attach once, codegen types it on core

Mongoose

MongoDB, services typed by key

Kysely

Postgres, queries typed off your schema

From zero
to hot reload.

Scaffold a typed bot, open it, and run it. seedcord sends each interaction to its handler, registers your commands, and types every option. Hot reload keeps the gateway connected while you edit.

terminal
# scaffold a typed bot
$ pnpm create seedcord my-bot
$ cd my-bot

# tui with hot reload, the gateway stays up
$ seedcord dev

# bot online, every slash option fully typed