Bot SDK
@missived/bot is a small TypeScript package for writing Missived bots: typed REST calls, a gateway client that resumes, signed slash commands, buttons, forms and private replies.
Before you start
- Node 22.18 or later. The package is ESM only, runs on Node only, and has no runtime dependencies. It is MIT licensed.
- It isn't on npm yet. Until it is, it ships as the
integrations/missived-bot-sdkfolder of the self-hosted edition; on the hosted service, ask us at [email protected] for the package. Then build it as below. - An app and its token. In Org Settings → Integrations → Apps & bots → New app, make an app, grant the scopes your bot needs, Make token (
msv_bot_…, shown once) and add the app to a text channel. You need the Manage integrations permission (Owners, Admins, or a custom role that grants it). See Apps and bots. Apps, tokens and the API are on every plan, with smaller caps on Free.
Build the package, then import from its dist/index.js, or pack it and install the .tgz in your own project:
cd integrations/missived-bot-sdk
npm ci --ignore-scripts
npm run build # writes dist/
npm pack # makes missived-bot-0.1.0.tgz
Keep the token in your secret manager or environment, never in code. The SDK can't widen anything: scopes, channel membership and the rule that apps never read direct messages are all enforced by Missived.
REST
import { RestClient, type Schemas } from '@missived/bot';
const rest = new RestClient({ token: process.env.MISSIVED_TOKEN! });
const me = await rest.call('getMe', {});
const channels = rest.paginate(cursor => rest.call('listChannels', {
path: { orgId: 'org-id' }, query: { cursor, limit: 100 },
}));
for await (const channel of channels) console.log(channel.name);
const body: Schemas['NewMessage'] = { content: 'Build passed' };
await rest.createMessage('channel-id', body, { idempotencyKey: 'build-123' });
rest.call(operationId, { path, query, body, idempotencyKey, signal })reaches every operation in the API reference, with exact request and response types. Path values are URL-encoded for you.baseUrlis the API root without/v1(defaulthttps://api.missived.com). On your own server, point it at your API; a reverse-proxy prefix is fine. Plainhttpis accepted only for127.0.0.1/localhost.paginatefollowsnext_cursoruntil it is null or a page is empty, and throws if a cursor repeats. When you poll oldest-first, keep the last cursor yourself: an empty page's cursor is a checkpoint.- Errors are
ApiErrorwithstatus,codeand, for a 429,retryAfterMs. - 429s are retried using
Retry-After, up tomaxRetries(default 3), each wait at mostmaxRetryAfterMs(default 60 seconds); a longer wait is returned as an error. Network errors and 5xx answers are not retried, so you decide. Redirects are refused, so your token is never forwarded. createMessage(and interaction follow-ups) make an idempotency key when you don't give one. Pass a stable key when you replay a job or an event, or a retry can post twice. See Idempotency keys.
Files
Upload with files:write, then attach. Only the token that uploaded a file can attach it, and the API takes files up to 25 MB.
const file = await rest.uploadFile('org-id', { name: 'build.log', type: 'text/plain', data: logBytes });
await rest.createMessage('channel-id', { content: 'Build log', attachment_ids: [file.id] });
const { data, fileName } = await rest.downloadAttachment(message.attachments[0].id); // needs messages:read
More in Files.
Gateway
GatewayClient holds one gateway connection: it discovers the socket from /v1/gateway, identifies, heartbeats, and reconnects with backoff.
import { GatewayClient, RestClient } from '@missived/bot';
const token = process.env.MISSIVED_TOKEN!;
const gateway = new GatewayClient({
token, rest: new RestClient({ token }), intents: ['messages'],
onReady: ready => console.log(ready.bot.handle),
onInvalidSession: () => console.log('Catch up over REST before continuing'),
onError: error => console.error(error.message),
});
gateway.on('message.created', async event => {
if (event.mentioned) console.log(event.data.message.content);
});
await gateway.start();
// gateway.stop() closes the socket and every timer.
Intents are messages, message_content, reactions, members, channels, meetings and shifts; each needs its scope (Intents). Message text is optional in every event: it arrives only as channel membership, the message_content intent and the org's content policy allow.
Sequence numbers and resume
- Handlers run one at a time, in order. The client commits an event's sequence number only after its handlers finish.
- If a handler throws, the client closes the connection and the uncommitted event is replayed on resume. Several handlers may have done part of their work before one failed, so make side effects idempotent with the event's
id(the echo example below uses it as the message's idempotency key). - Reconnects back off exponentially up to 60 seconds; after close code 4008 (rate limited) it waits at least 60 seconds.
- Resume works within one process. A restarted process identifies afresh, so keep your own checkpoint and catch up over REST if you need to.
gateway.checkpointshows the session id and last committed sequence. - An invalid session clears the checkpoint, calls
onInvalidSessionand identifies again. - It stops reconnecting on a failed authentication (4004), a replaced session (4010), too many connections (4011) and invalid or disallowed intents (4013, 4014).
onClose(code, reconnecting)tells you; fix the cause, then callstart()again. - Events replayed on resume carry partial payloads: message text and files may be missing.
Slash commands over HTTP
nodeInteractionHandler is a Node http handler for your app's interactions URL (Slash commands).
import { createServer } from 'node:http';
import { nodeInteractionHandler, reply, defer } from '@missived/bot';
createServer(nodeInteractionHandler({
publicKey: process.env.MISSIVED_PUBLIC_KEY!,
applicationId: 'app-id',
commands: { roll: () => reply('6'), later: () => defer() },
})).listen(4151, '127.0.0.1');
For each request it:
- accepts only POST and caps the raw body at 64 KB;
- verifies the Ed25519 signature over the timestamp plus the exact body bytes, and rejects timestamps more than 5 minutes off;
- only then parses the JSON, and claims the interaction's
idso a replay is refused (409); - answers a ping with
{ "type": 1 }, and routes commands by name.
Mount it before any JSON body parser, on a path of its own. Your interactions URL must be public HTTPS, so put a proxy in front of the local port; Missived refuses private and loopback addresses. Saving the URL sends a signed ping.
Answer within 3 seconds. The SDK doesn't defer a slow handler for you: return defer() and do the work in afterResponse, which runs after the answer has been sent and gets the context's editOriginal, deleteOriginal, followUp and option(name). Those calls use only the 15-minute interaction token, never your bot token; keep that token out of logs. Missived allows 20 follow-ups per interaction. In production, put real work on a durable queue: work held in memory is lost if the receiver restarts.
Other frameworks: createInteractionHandler(options) takes { method, headers, rawBody } and returns { status, body, afterResponse }. Send the answer, then call afterResponse().
The replay check is an in-memory cache by default. With several receivers, or to survive restarts, pass claim(id, expiresAt) backed by shared storage; it must refuse an id it has seen until it expires. A failed handler keeps its claim, so its side effects never run twice.
Buttons, menus, forms and private replies
The builders produce Discord-shaped components and throw a ValidationError, in the server's own words, before anything is sent: at most 5 rows of 5 buttons or one select, custom_id 1 to 100 characters and unique, labels up to 80, https link buttons, selects of 0 to 25 values, forms of 1 to 5 text inputs with a title up to 45.
import {
actionRow, button, linkButton, stringSelect, selectOption, modal, textInput,
nodeInteractionHandler, prefix, reply, privateReply, update, openModal,
} from '@missived/bot';
const row = actionRow(
button('vote:yes', 'Yes', { style: 'success' }),
button('vote:no', 'No', { style: 'danger' }),
linkButton('https://example.com/rules', 'Rules'),
);
const pick = actionRow(stringSelect('env', [selectOption('Production', 'prod'), selectOption('Staging', 'stage')]));
const form = modal('feedback', 'Feedback', [textInput('why', 'Why?', { style: 'paragraph', maxLength: 500 })]);
nodeInteractionHandler({
publicKey, rest,
commands: { poll: () => reply({ content: 'Ship it?', components: [row, pick] }) },
components: [
[prefix('vote:'), c => update(`${c.user?.name} voted ${c.params[0]}`)],
['env', c => privateReply(`You chose ${c.values[0]}`)],
[/^ticket:(\d+)$/, () => openModal(form)],
],
modals: { feedback: c => privateReply(`Thanks: ${c.field('why')}`) },
});
Routing by custom_id
components and modals take either an object of exact ids or a list of [pattern, handler] pairs tried in order. A pattern is an exact string, prefix('vote:') (the rest is c.params[0]) or a regular expression (its groups are c.params). Anything unmatched goes to onInteraction. Put state you need later in the id, such as roll:again:20.
c.values holds a select's chosen values (or user, role or channel ids), c.message the message the component is on, c.isPrivate whether that is a private reply. In a form, c.field(id) is a trimmed answer.
Answers
| Helper | Type | Does |
|---|---|---|
reply(data) |
4 | Posts a reply as the bot |
privateReply(data) or reply(data, { private: true }) |
4 + flag 64 | A reply only the person sees, kept 15 minutes |
defer() / defer({ private: true }) |
5 | A "thinking" placeholder to finish later |
deferUpdate() |
6 | Acknowledge a click now, then editOriginal |
update(data) |
7 | Edit the message the component is on (omitted fields stay) |
openModal(form) |
9 | Open a form |
Every answer is checked before it leaves: a command can't update, a form can't open another form. A refused answer becomes a 500 and goes to onError. In afterResponse, followUp(body, { private: true }) or privateFollowUp(body) sends a private follow-up. validateComponents and validateModal check shapes you wrote by hand, for example before rest.createMessage(..., { components }).
The examples
The SDK ships four examples. Set the token and the other values in your environment, then run one. Each slash example replaces the app's whole command list with its own commands.
| Example | Needs | Shows |
|---|---|---|
examples/run-echo.ts |
messages:write |
Gateway bot that answers @mentions |
examples/run-slash.ts |
commands:write, messages:write |
/roll [sides] and a deferred /later |
examples/roll-buttons/run.ts |
the same | /roll with "Roll again" and a "Custom…" form |
examples/private-reply/run.ts |
the same | /peek private select, /report deferred private reply |
export MISSIVED_URL=https://api.missived.com
node examples/run-echo.ts # MISSIVED_TOKEN set
PORT=4151 node examples/roll-buttons/run.ts # also MISSIVED_PUBLIC_KEY (the app's key) and MISSIVED_ORG_ID
The echo bot asks only for the messages intent, ignores its own posts, replies in the thread, and uses the event id as the idempotency key:
gateway.on('message.created', async event => {
const message = event.data.message;
if (!event.mentioned || message.author?.id === botId) return;
await rest.createMessage(message.channel_id,
{ content: `Echo: ${message.content ?? 'You called?'}`, reply_to: message.parent_message_id ?? message.id },
{ idempotencyKey: `echo-${event.id}` });
});
When the org sends thin payloads, the text is missing and it answers "You called?".
The roll-buttons bot registers its command, then carries the die size in the button's id:
export function registerRollButtons(rest: RestClient, orgId: string) {
return rest.registerCommands(orgId, [command('roll', 'Roll a die', [
{ name: 'sides', description: 'Number of sides', type: 'integer', required: false, min_value: 2, max_value: 100 },
])]);
}
function rolled(sides: number, who = 'Someone') {
return {
content: `${who} rolled **${randomInt(1, sides + 1)}** on a d${sides}`,
components: [actionRow(
button(`roll:again:${sides}`, 'Roll again'),
button('roll:custom', 'Custom…', { style: 'secondary' }),
)],
};
}
const customForm = modal('roll:custom', 'Custom roll', [
textInput('sides', 'Number of sides', { placeholder: '2 to 100', minLength: 1, maxLength: 3 }),
]);
nodeInteractionHandler({
...options,
commands: { roll: c => reply(rolled(Number(c.option('sides') ?? 6), c.user?.name)) },
components: [
[prefix('roll:again:'), c => update(rolled(Number(c.params[0]), c.user?.name))],
['roll:custom', () => openModal(customForm)],
],
modals: {
'roll:custom': c => {
const sides = Number(c.field('sides'));
if (!Number.isInteger(sides) || sides < 2 || sides > 100) return privateReply('Pick a whole number from 2 to 100.');
return update(rolled(sides, c.user?.name));
},
},
});
A deferred reply's placeholder can land a moment after your HTTP answer. The /later and /report examples retry editOriginal on a 404 for up to five seconds, which is a good pattern to copy.
Troubleshooting
- 401 "Invalid signature" from your receiver: a body parser ran first, the public key is wrong (it changes when you Rotate it), or your clock is more than 5 minutes off.
- 409 "Interaction already received": the same interaction id arrived twice; this is the replay check working.
- The gateway stopped and won't reconnect: check
onClose's code. 4004 means the token was revoked or the app paused or uninstalled; 4014 means an intent needs a scope the install doesn't have. ApiError404 oneditOriginalstraight afterdefer(): retry briefly, as the examples do.