Bots

A bot is a member of your server. It has a name and an avatar, it shows up in the member list with a BOT tag, it holds roles, and its permissions are checked exactly like anybody else's. Giving it a role is how you decide what it may do, and it cannot read a channel you have not let it into.

It needs no public URL and no inbound port. The bot dials out: one WebSocket to each community it has been added to, and ordinary HTTPS requests back. You can run one on a laptop.

Adding one to your server

What a bot can do

Writing one

Create the bot in Settings → Bots. Give it a name and a picture. You get a key and a secret; the secret is shown once, and a lost one is rotated rather than recovered.

npm install @tusile/bot
import { Bot } from '@tusile/bot'; const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET bot.command({ name: 'ping', description: 'Check the bot is awake' }, (i) => i.reply('pong')); await bot.start();
CORE_SERVER_URL=https://api.tusile.com BOT_KEY=... BOT_SECRET=... node bot.js

That bot is live on every community that has added it, and joins new ones by itself. The package handles what nobody should have to write twice: a token minted for each community every hour, reconnects with a backoff, heartbeats at whatever interval the server asks for, and registering the command list on every connect.

The package is on npm as @tusile/bot. Everything below is the protocol underneath it, for writing a bot in another language or reaching something the package has no method for.

Commands

bot.command( { name: 'roll', description: 'Roll a die', options: [ { name: 'sides', type: 'integer', description: 'Sides', min: 2, max: 100 }, { name: 'secret', type: 'boolean', description: 'Only you see it' }, ], }, async (i) => { const sides = i.options.sides || 6; await i.reply(`d${sides}: ${1 + Math.floor(Math.random() * sides)}`, { ephemeral: Boolean(i.options.secret), }); }, );

Option types are string, integer, boolean, user, channel and role.

FieldWhat it does
namelowercase, no spaces
typeone of the six above
descriptionshown beside the input
requiredrequired options must come before optional ones
min, maxintegers only
choices[{"name": "Shown", "value": "sent"}], string and integer only, at most 25

choices turns the option into a picker, so nobody can type a value the bot will refuse. user, channel and role are pickers of the people, channels and roles on that server, and the bot is handed an id.

Values arrive already checked against this schema: an integer is a number and within its bounds, a choice is one of the ones offered, an id is the right shape. A bad value is refused before the bot hears about it, with a message naming the option, shown to whoever typed it.

At most 25 options per command. Registering replaces that bot's whole set, and only that bot's: two bots may register the same command name, and the app says whose is whose.

Answering

A bot has ten seconds to say something, or the person who asked is shown "the bot did not respond". Deferring buys fifteen minutes, and their client waits that long for it.

await i.reply('everyone in the channel sees this'); await i.replyPrivately('only the person who asked sees this'); await i.defer(); // buys fifteen minutes await i.followUp('still working'); // another message after the first await i.editReply('done'); // rewrite what it already said await i.deleteReply(); await i.showModal({ ... }); // ask them to fill something in // Rewrite the message a button is on. await i.update({ content: 'Confirmed', components: [] }); await i.acknowledge(); // a press that needs no visible change
Response typeWhat happens
messageposted to the channel; everyone sees it
ephemeralreaches only the person who asked, and is not channel history
deferacknowledges now; answer with a followup within 15 minutes
modalasks them to fill something in
update_messagerewrites the message the control is on, in place
defer_updateacknowledges a press with no visible change

The last two answer a press, not a command, and they are what a control is mostly for. A confirm that removes its own buttons, a page of a list that turns, a toggle that redraws: all of them rewrite the message in place. Posting a new message instead moves the exchange to the bottom of the channel and leaves the old buttons above it still working.

An absent embeds or components leaves that part alone; an empty list removes it, which is how a bot turns its own buttons off.

Whether an answer is private is the bot's decision and nobody else's. The app will not offer to publish a private one: a bot that wants an answer shared answers publicly, or puts a button on the private one that posts.

An interaction can be answered once. A second answer is a conflict, not a silent overwrite, and the package refuses it before it leaves the process.

The package defers for you if a command or form handler is still working after eight seconds, so a bot that is busy answering is never reported as one that did not respond. A press is left alone: rewriting the message a button sits on is only available before the interaction has been answered at all, so a slow press should defer and follow up rather than update.

Buttons and menus

await i.reply({ content: 'Delete this channel?', components: [{ type: 'row', components: [ { type: 'button', style: 'danger', label: 'Delete', custom_id: 'del:yes' }, { type: 'button', style: 'secondary', label: 'Keep', custom_id: 'del:no' }, { type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com' }, ], }], }); // Matched on the prefix, so both buttons land here and the // id carries the answer. bot.component('del', (i) => i.update({ content: i.customId === 'del:yes' ? 'Gone.' : 'Kept.', components: [], }), );

Rows only at the top level, at most 5 rows of 5. A menu takes a row to itself. Button styles are primary, secondary, danger and link; anything else draws as a plain button rather than not at all. A link button carries a url and never reaches the bot, every other style carries a custom_id and does.

A custom_id is yours. The server echoes it back verbatim, never parses it, and never truncates it below 100 bytes, so encode whatever state you need in it. An exactly registered id wins over a prefix, and the longest matching prefix wins, so a general handler cannot swallow presses meant for a specific one.

{ type: 'select', custom_id: 'size', placeholder: 'Pick one', min_values: 1, max_values: 2, options: [ { label: 'Small', value: 's' }, { label: 'Large', value: 'l', description: 'the big one' }, ] }

A menu's min_values and max_values are enforced on the way back as well as declared: a submission with too few or too many choices, or the same choice twice, is refused before the bot hears about it. So is a form submission carrying a field the form never declared, leaving a required one blank, or longer than 4000 bytes. A bot need not check any of it.

What the server does check is that the control is really yours: a press names the message that carries it, and the message must be one this bot authored and must actually contain a control with that id. Without that, anybody who can post could name any id and have a bot act as though it had authored the control.

Buttons on a private answer, and a form opened in answer to a command, are not messages. Those carry source_interaction_id instead, and only the person the answer was addressed to can use them.

Forms

bot.command({ name: 'feedback', description: 'Send a note' }, (i) => i.showModal({ custom_id: 'feedback', title: 'Tell us more', fields: [ { custom_id: 'subject', label: 'Subject', required: true }, { custom_id: 'body', label: 'Anything else', style: 'paragraph' }, ], }), ); bot.modal('feedback', (i) => i.replyPrivately(`Noted: ${i.fields.subject}`));

The submission arrives keyed by each field's custom_id. style: "paragraph" draws a multi-line box; anything else is one line.

Embeds

embeds: [{ title: 'Now playing', url: 'https://example.com/track', description: 'a song', color: 5793266, author: { name: 'Jukebox', icon_url: 'https://...' }, fields: [{ name: 'Length', value: '3:21', inline: true }], image: { url: 'https://...' }, thumbnail: { url: 'https://...' }, footer: { text: 'queued by Ada' }, }]

color is 24-bit RGB. Limits: 10 embeds per message, 25 fields per embed, 256 bytes of title, 4096 of description, 1024 per field value, and 6000 across every embed on one message. Bytes, not characters, so Arabic or Polish text reaches the limit at about half the character count and CJK at about a third. Only http and https urls.

A payload past a limit is refused with a message naming the field, when it is sent rather than when it is drawn: a payload that reaches the database breaks the channel for everybody who opens it until a human deletes the row.

Everything a bot can do

Each handler is given i.server, the community the interaction came from. i.server.request(method, path, body) reaches anything not on this list.

Reading
history(channelId, { limit, before })channel history; limit up to 100, before to page
message(id)one message
channels()the channels it can see
members()members with their roles
roles()the server's roles
serverInfo()the server's name, icon, member and channel count
commands()the commands this bot has registered here
Talking
send(channelId, message)content, embeds, components, reply_to_id, attachment_url
editMessage(id, message)edit its own
deleteMessage(id)its own, or anybody's with Manage Messages
deleteMessages(channelId, ids)up to 100 at once, with Manage Messages
typing(channelId)show it is working on something
react(id, emoji), unreact(id, emoji)add and remove its reaction
pin(id), unpin(id)with Manage Messages
Moderating and managing
addRole(userId, roleId), removeRole(...)with Manage Roles
timeout(userId, seconds, reason), clearTimeout(userId)with Moderate Members
kick(userId)with Manage Members
ban(userId, reason), unban(userId), bans()with Manage Members
createChannel, updateChannel, deleteChannelwith Manage Channels
categories(), createCategory, updateCategory, deleteCategorywith Manage Channels
channelPermissions(id), setChannelPermission, removeChannelPermissionwith Manage Channels or Manage Roles
createRole, updateRole, deleteRolewith Manage Roles
setNickname(userId, name)'@me' renames the bot itself, which needs nothing
joinVoice(channelId), leaveVoice(channelId)voice presence

A bot cannot create a role above its own, or put a permission into a role that it does not hold itself. Without those two rules Manage Roles is Administrator in two requests.

Running a command needs View and Send in the channel; pressing a control the bot already posted needs only View, so a bot's buttons still work in a read-only announcements channel. The bot needs View there as well, and an interaction in a channel it has been denied is refused outright rather than handing it that channel's id.

Permissions are the member's, not a bypass. It cannot act on another bot. Carrying audio in voice needs a LiveKit client as well (@livekit/rtc-node, joining the room named channel_<channel id>); the calls above are the presence half, which is what most bots want.

Adding a reaction is idempotent: sending it twice leaves one reaction rather than toggling it off, because a retry after a timeout must not undo the thing it was retrying.

A bot that sets a server up

Everything a server is made of is reachable, so the first bot most people want to write is the one that builds the whole thing from a template.

import { Bot, Permissions } from '@tusile/bot'; bot.command({ name: 'setup', description: 'Build the standard channels' }, async (i) => { await i.defer(); const s = i.server; const staff = await s.createRole({ name: 'Staff', permissions: Permissions.view_channel }); const team = await s.createCategory({ name: 'Team' }); const general = await s.createChannel({ name: 'general', type: 'text', category_id: team.id }); const inner = await s.createChannel({ name: 'staff-only', type: 'text', category_id: team.id }); // The part that makes it a server rather than a pile of channels. const everyone = (await s.roles()).find((r) => r.is_default); await s.setChannelPermission(inner.id, { role: everyone.id, deny: 'view_channel' }); await s.setChannelPermission(inner.id, { role: staff.id, allow: 'view_channel' }); await s.addRole(i.userId, staff.id); await s.send(general.id, 'Set up. #staff-only is for the Staff role.'); await i.editReply('Done.'); });

allow and deny take a permission name, a list of names, or a number: view_channel, send_messages, manage_messages, manage_channels, manage_roles, manage_members, administrator, connect, speak, mute_members, deafen_members, manage_server, create_invite, attach_files, stream, use_camera, manage_emojis, manage_emoji_policy, timeout_members. A name the package does not know throws, rather than quietly denying nothing.

Two rules the server keeps whatever the bot asks for: it cannot create a role above its own, and it cannot allow, in a channel, a permission it does not hold itself. Denying is not capped, so a bot locking a channel down does not need every permission it is switching off.

Events

bot.on('ready', (me, server) => {}); // connected to a community bot.on('message', (message, server) => {}); // said in a channel the bot can see bot.on('joined', (server) => {}); // added to a community bot.on('left', (serverId) => {}); // removed from one bot.on('error', (err, context) => {}); // anything that went wrong

A bot that replies to plain messages must check message.author_id !== bot.me.id first, or it answers itself, forever.

Rate limits

Per bot per community: 60 writes a minute with a burst of 20, and 300 reads a minute with a burst of 60. A bot on many communities gets that allowance on each of them, not one between them.

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the budget is full), so a bot can slow down before it is refused rather than after. Over the limit is a 429 with Retry-After, which the package waits out and retries.

The protocol underneath

For writing a bot in another language. Two hosts: Core holds the bot's identity, each community holds its own data.

Authorization: Bot base64(key:secret) # to Core, only ever to Core GET /bots/me # id, name, avatar GET /bots/me/servers # [{server_id, api_url}] # ?wait=25 holds it open POST /bots/token {"server_id": "..."} # a token for one community, # good for an hour Authorization: Bearer <that token> # to that community, and nowhere else WS /bot/gateway # send the auth frame first PUT /bot/commands # the command list, every connect POST /bot/interactions/{id}/callback # answer, within ten seconds POST /bot/interactions/{id}/followup # again, within fifteen minutes PATCH,DELETE /bot/interactions/{id}/response POST /bot/channels/{id}/messages # and the rest of the table above

The gateway sends ready (with heartbeat_interval_seconds, which a bot must honour or be dropped), interaction_create when somebody runs a command or presses a control, and message_create for messages in channels the bot can see. A close reason of bot_uninstalled means the bot was removed: stop reconnecting on that one, and retry everything else with a backoff.

{ "type": "interaction_create", "data": { "id": "...", "type": "command", "command": "roll", "options": {"sides": 20, "secret": true}, "channel_id": "...", "user_id": "...", "custom_id": "", "values": [], "fields": {}, "message_id": "", "source_interaction_id": "" } }

A per-community token means a community never sees a credential that works anywhere else, and it expires in an hour, so mint a fresh one on every connect rather than working out whether the last one is still good.

Two things worth knowing

A bot is a member. It occupies a seat in the member list, it counts against the server's member limit, and removing it takes its commands with it. It is not a special case with its own permission rules.

Nothing is retried for you. If a bot is offline when somebody runs its command, the interaction times out and the person is told so. There is no queue and no replay: a bot that must not miss anything should stay connected, which is what the package does. A reconnect starts clean, so anything that happened while the socket was down is gone.

What this does not have

Said plainly, because finding out by getting a 404 is worse.

Not thereWhy
Direct messagesA bot cannot DM anybody. Tusile's one-to-one messages are end-to-end encrypted, and a bot has no key for them.
Autocomplete on optionsAn option's choices are fixed when the command is registered; there is no per-keystroke callback.
Context-menu commandsNo right-click-on-a-message or right-click-on-a-person commands, only slash commands and controls.
A status lineA bot cannot set a "playing" line. It does show as online while its gateway is connected, which is what the dot in the member list means.
Gateway resumeA reconnect does not replay what was missed.
WebhooksDeliberately gone. It is what this replaced, and the reason a bot now needs no public address.

Last updated: 2026-09-24. The package is @tusile/bot.