Your first bot
Create a seedcord project with pnpm create seedcord, run it, and try its first slash command in Discord.
You need the token from the last page, though you can leave it for later. The command below asks a few questions, then writes your project.
pnpm create seedcordWhat it asks
- Where the project goes. The folder name becomes the package name, so it takes lowercase letters, digits, and
. _ -. - How Discord reaches your bot. Gateway or http, the choice from gateway or http.
- Where your http bot runs. Http only. A Node or Bun process on any host, or Cloudflare Workers.
- What your bot should react to. Gateway only. A checklist of events like messages, reactions, and member joins, with a hint on each and the privileged ones marked. Your picks become the intents and partials in your
bot.ts. Those are discord.js's options for which events and incomplete objects your bot gets. - Your bot token. Paste it now, or press Enter twice on the empty prompt to leave
DISCORD_BOT_TOKENempty in.env. Your bot won't start until you fill it in. - Your test servers. The ids of the servers where you try your commands, separated by commas. Turn on Developer Mode in Discord (opens in a new tab), right-click a server, and pick Copy Server ID. The ids go to
TESTING_GUILD_IDSin.env. If you press Enter to leave it empty, your commands go to every server your bot is in. - Your bot color. Every embed and container you build with
BuilderComponenttakes it, unless you set a color on that one yourself. - Whether to install dependencies and set up git. Two yes or no questions, both defaulting to yes.
If you say yes to installing, the command installs your dependencies, formats the project, and runs seedcord codegen. If you say yes to git, it makes the first commit, unless git isn't installed or the folder is already inside a repo.
Every question has a flag, like --transport http or --testing-guilds 123,456, and --help lists them all. npm passes flags on only after a --.
Run it
cd my-bot
pnpm run devYour bot starts and sends its commands to Discord. The terminal shows a dev console that reloads a handler when you save it.
Http only
seedcord dev also opens a cloudflared tunnel to let Discord reach your laptop. It writes the tunnel's address into the Interactions Endpoint URL of your Discord application. If cloudflared isn't installed, your bot still runs and the terminal prints an install hint. The dev tunnel has the rest.
Run the ping command
The new project ships a ping command. Type /ping in a server your bot is in.
If you gave test servers, /ping appears right away. seedcord deploys it to those servers only, and Discord updates a server's commands immediately (opens in a new tab).
If you left the test servers empty, ping is a global command, and global commands can take a while to appear.
What the command wrote
The ping command came from a file the setup command wrote, along with everything else in this tree.
my-bot/
├─ .vscode/
│ ├─ extensions.json
│ └─ settings.json
├─ src/
│ ├─ commands/
│ │ └─ Ping.ts
│ ├─ events/
│ │ └─ Ready.ts
│ ├─ handlers/
│ │ └─ Ping.ts
│ ├─ plugins/
│ │ └─ Uptime.ts
│ ├─ bot.ts
│ ├─ index.ts
│ └─ seedcord-gen.d.ts
├─ .env
├─ .gitattributes
├─ .gitignore
├─ .prettierignore
├─ AGENTS.md
├─ CLAUDE.md
├─ eslint.config.ts
├─ package.json
├─ pnpm-workspace.yaml
├─ prettier.config.ts
├─ README.md
├─ seedcord.config.ts
└─ tsconfig.jsonpnpm-workspace.yaml only exists for a project made with pnpm. A project made with Bun gets a bunfig.toml instead, unless it's a Workers bot.
src/bot.tsbuilds your bot and exports it.src/index.tsimports that and starts it. They are separate so the CLI can importbot.tsand read your commands without starting the bot.seedcord.config.tsconfigures the CLI. Your bot's own settings go inbot.ts, shown below.src/seedcord-gen.d.tsholds the types generated from your config and your commands.seedcord codegenwrites it, and you commit it so a fresh clone type-checks before anyone runs codegen.src/plugins/Uptime.tsis a small plugin that records when the bot started.tsconfig.jsonconfigures the type check, andseedcord buildreads it too. Itspathsdefine the#src/*,#components/*, and#plugins/*aliases.bot.tsimports#plugins/Uptimethrough the last one..envholds your secrets, and.gitignorekeeps it out of commits. envapt (opens in a new tab) reads it..envkeys:DISCORD_BOT_TOKEN,TESTING_GUILD_IDS, andUNKNOWN_EXCEPTION_WEBHOOK_URLandHANDLED_EXCEPTION_WEBHOOK_URL, which take Discord webhook URLs for error reports. A server bot also getsENV=development. The mode itself comes from your shell, as Environment and config explains.src/events/holdsReady.ts. If you pick a message event,Mentioned.tsreplaces it and answers when someone mentions your bot.AGENTS.mdandCLAUDE.mdpoint a coding agent at this guide.
Http only
Discord posts only interactions to an http bot, so the command doesn't create a src/events folder. A bot for Cloudflare Workers has no src/index.ts either, since Cloudflare starts the worker. It gets a cloudflare.config.ts that declares the keys the worker receives, and nothing else reaches it.
Scripts
package.json defines the scripts that run the CLI. If you run a global seedcord in a project that installs its own, it throws CliNotProjectInstall, because the CLI has to load the same seedcord packages your bot imports.
| script | what it does |
|---|---|
dev | runs the bot with hot reload |
build | builds the bot for production |
start | runs the build in dist. Every bot but a Workers bot has it |
deploy | builds, then uploads the worker with cf deploy. Only a Workers bot has it |
codegen | rewrites seedcord-gen.d.ts |
commands | opens a menu to deploy, list, clean up, or purge your commands on Discord |
commands:deploy | sends your commands to Discord |
tc, lint, lint:fix, fmt, fmt:check | the type check, ESLint, and Prettier |
check | runs tc, lint, and fmt:check one after another |
A Workers bot also has a types script that regenerates its Cloudflare binding types, and a postinstall script that runs it after each install.
Where your folders resolve
seedcord.config.ts sets where the CLI finds your bot. root is the folder that holds your bot's code, and it defaults to the folder that holds the config. seedcord build bundles your code from root.
import { defineConfig } from 'seedcord';
export default defineConfig({
root: './src',
instance: './bot.ts',
build: { start: './index.ts' },
// set false to hold the running arc and live dot still, which cuts idle redraws by ~80%
idleAnimation: true,
hmr: {
// set true to run tsc --watch alongside the bot, at the cost of a second node process
typecheck: false
}
});instance is the file that exports your bot, and build.start is the file that starts it. If you leave build.start out, the build exports the bot as a package instead. Both paths are relative to root, and so is every relative folder path in bot.ts. A Workers bot's config has no build.start.
Your bot's settings
Your answers about events, color, and test servers end up in one object, the argument to new Seedcord().
import { Seedcord } from '@seedcord/gateway';
import { GatewayIntentBits } from 'discord.js';
import { Converters, Envapter } from 'envapt';
import { Uptime } from '#plugins/Uptime';
export const seedcord = new Seedcord({
bot: {
clientOptions: {
intents: [GatewayIntentBits.Guilds]
},
interactions: {
path: './handlers'
},
commands: {
path: './commands',
guilds: Envapter.getAs(
'TESTING_GUILD_IDS',
Converters.array(),
[]
)
},
events: {
path: './events'
}
},
subscribers: {
path: null
},
botColor: '#4ade80',
notifications: {
developerUsername: 'you'
}
}).attach('uptime', Uptime);
export default seedcord;Each path key is a folder seedcord imports files from at startup, and a null path scans nothing. './handlers' is relative to root, so with root: './src' it means src/handlers. Only seedcord dev and a built bot set root. If a test or a tsx script runs the bot, a relative path throws CoreDirectoryRootUnknown. In those runs, use an absolute path built from import.meta.dirname.
The optional guilds key lists the servers where every command registered with RegisterCommand() deploys. It reads TESTING_GUILD_IDS from .env through envapt, which splits the comma-separated ids into an array. An empty list sends those commands to every server your bot is in, so don't set TESTING_GUILD_IDS where you deploy. A command that passes 'global' to the decorator ignores the list, and one that passes its own ids replaces it. Commands explains both. Environment files explains how envapt picks .env files and reads your own keys.
botColor is the color from your answer. developerUsername is who seedcord's generic error reply tells users to contact. The setup command fills it from your git user name, or "the developer" when git has none, so the sample's 'you' stands for your name. subscribers.path is null because the new project has no subscribers. .attach('uptime', Uptime) puts the plugin on this.core.uptime, which the core object shows next.
Build your bot with new Seedcord(...) and make it the default export, the way this file does, because instance in seedcord.config.ts points at that export.
Http only
An http bot imports Seedcord from @seedcord/http and has no clientOptions or events. On a server it takes a top-level port, which defaults to 3000. port: false turns the built-in server off for a bot inside your own server. A bot on Workers has neither port nor lifecycle.
The ping command
Two files make /ping work.
import {
BuilderComponent,
RegisterCommand
} from '@seedcord/gateway';
@RegisterCommand()
export class Ping extends BuilderComponent<'command'> {
constructor() {
super('command');
this.instance
.setName('ping')
.setDescription('Check that the bot is answering')
.addBooleanOption((option) =>
option
.setName('detailed')
.setDescription('Include process uptime')
);
}
}@RegisterCommand() makes seedcord send this class to Discord, and this.instance is the discord.js slash command builder.
import {
SlashHandler,
SlashRoute,
timestampFromSnowflake,
toEpochSeconds
} from '@seedcord/gateway';
@SlashRoute('ping')
export class Ping extends SlashHandler<'ping'> {
public async execute(): Promise<void> {
const roundtrip =
Date.now() - timestampFromSnowflake(this.event.id);
const lines = ['### Pong', `Roundtrip **${roundtrip}ms**`];
if (this.options.getBoolean('detailed')) {
lines.push(
`Up since **<t:${toEpochSeconds(this.core.uptime.startedAt)}:R>**`
);
}
await this.reply(lines.join('\n'));
}
}execute() runs when someone uses /ping. 'ping' appears twice, on the decorator and on the generic, and both have to match the name the command sets. The compiler catches a mismatch. this.options carries detailed because seedcord codegen recorded it off the command file.
roundtrip measures one way, from Discord creating the interaction to execute() running, so it is closer to a latency than a round trip. It comes from the interaction's id, so it includes any gap between your clock and Discord's. With detailed set, the reply adds a line like "Up since 3 minutes ago". Discord turns the <t:...:R> markup into a relative time.
Http only
An http project has the same two files and imports from @seedcord/http. this.event is a discord.js ChatInputCommandInteraction on gateway and Discord's raw APIChatInputApplicationCommandInteraction on http. The handler reads only id from it, which both have.
Change something
Open src/handlers/Ping.ts and change what it replies with. Save.
The dev console reloads the handler while the bot keeps running. Run /ping again to see the new reply.
Now open src/commands/Ping.ts and change the command itself, its description for example. Save, then look at the terminal. The console asks whether to refresh your commands.
Commands updated:
- src/commands/Ping.ts
Refresh commands? (y/n)The Refresh commands? (y/n) prompt appears only for the command edit, because the new definition has to go to Discord before anyone sees it. A handler edit reloads on its own.
Press y to send the new command to Discord and run seedcord codegen, which rewrites src/seedcord-gen.d.ts so your handler's types match what the command now declares.
Press n if you want Discord to keep the old command. Codegen doesn't run then, so your handler's types stay as they were until the dev console restarts or you run seedcord codegen.
When a file fails to import
seedcord imports every file in the folders you list in bot.ts at startup. If one of them throws while it loads, startup stops with an error shaped like this.
Could not import <file>. <what the file threw>Fix the file and press r to restart. If a save throws while the bot is running, seedcord goes back to the last working version of that handler. A save that throws on import explains the rollback.
The core object is next.