botflare
botflare
Discord bots on Cloudflare Workers.
Answer commands, buttons and modals over HTTP or over the gateway. The REST client takes its types from Discord's own API spec. Everything a bot imports is 13 kB gzipped.
npm install botflareconst roll = command( { name: 'roll', description: 'Roll a die', options: { sides: option('integer', { description: 'Sides' }) }, }, (c) => { const sides = c.options.sides ?? 6; const rolled = 1 + Math.floor(Math.random() * sides);
return c.reply(`You rolled ${rolled}`, { buttons: [{ id: `again:${sides}`, label: 'Roll again' }], }); },);/roll sides:20
my-bot You rolled 17 · Roll again
bot({ commands: [roll], buttons: { again: (c) => { const rolled = 1 + Math.floor(Math.random() * Number(c.value));
return c.update(`You rolled ${rolled}`); }, },});The click edits the same message: my-bot You rolled 4
export const Gateway = gateway({ intents: ['Guilds', 'GuildMembers'], bot: app, events: { GUILD_MEMBER_ADD: (member, c) => c.send(WELCOME_CHANNEL, `Welcome, ${mention.user(member.user)}`), },});#welcome · my-bot Welcome, @yu
GuildMembers is a privileged intent. Turn it on in the Developer Portal first.
const [error, member] = await rest.get('/guilds/{guild_id}/members/{user_id}', { path: { guild_id, user_id },});if (error) return c.reply('Not a member');
return c.reply(`Joined ${member.joined_at}`);my-bot Joined 2026-11-04T15:21:52.481000+00:00
How a bot runs
One Worker answers interactions. A Durable Object holds the gateway connection, so events reach the same handlers without a public URL.
Discord
interactions, events
Sends slash commands, clicks and form submits, and gateway events such as member joins.
Worker
bot()
Checks the Ed25519 signature, runs the matching handler and returns the answer.
Durable Object
gateway()
Keeps the WebSocket open, resumes after a drop, and passes interactions to bot().
POST / over HTTP, for bots with an Interactions Endpoint URL.
wss://gateway.discord.gg from the Durable Object, for bots that need events.
Gateway or HTTP
Pick one per bot. Both run the same handlers.
| Gateway | HTTP | |
|---|---|---|
| Commands, buttons, menus, modals | Yes | Yes |
| Events, such as member joins | Yes | No |
| Needs a public URL | No | Yes, the Interactions Endpoint URL |
| Durable Object time | About 11,000 GB-s a day | None |
Workers Free includes 13,000 GB-s a day, enough for one gateway bot. Source: Durable Objects pricing.
Typed from the spec
Every route, path value, query, body and result comes from discord/discord-api-spec.
await rest.post('/channels/{channel_id}/messages', { path: { channel_id }, body: { content: 1 }, // ~~~~~~~ Type 'number' is not assignable to type 'string'.});The types cover 157 paths, generated from commit 4e75626 of the spec. When Discord adds a route, bun run gen:routes brings it in. On a 429, rest waits for retry_after and tries again, up to three tries.