Skip to content

Building a bot

Kenny Lasyone edited this page Oct 15, 2026 · 10 revisions

Building a bot

A bot is an account that a program runs. It joins a server as a member, reads what it is allowed to see, posts, and can offer slash commands. This page takes you from nothing to a working bot, then goes through what the API offers. Every request, call, event and field is listed in the API reference.

There are two kinds, fixed when the bot is made:

  • An ordinary bot talks to the Maplecord server. This page is about these.
  • A P2P bot connects straight to people's apps in P2P channels and can do nothing else. See P2P bots at the end.

If all you need is to post messages from another program, a webhook is simpler than a bot, and a bot can post with one request without staying connected.

1. Make the bot

  1. In the app, open Settings, then My bots.
  2. Type a name and press Create bot.
  3. Copy the Token and the Signing secret now. They are not shown again. Also copy the Bot ID.

When you make a bot, its ID, token and signing secret come up by themselves first, each with a Copy button.

The token is the bot's password. Keep it out of your code and out of anywhere public. If it leaks, press New token: the old token and signing secret stop working at once.

You can have up to 25 bots.

2. Add it to a server

In Server settings, open Bots, paste the Bot ID and press Add bot. This takes the Manage server permission. Anyone can add a bot whose ID they have been given.

The bot joins as a member. What it may see and do is set with roles, like anyone else: it starts with what @everyone has, and you can give it more or less.

3. A starter bot

This bot answers !ping with pong, and has one slash command, /hello. It needs Node 22 or newer.

npm init -y
npm install @microsoft/signalr

Save this as bot.mjs:

import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr'

const server = process.env.MAPLECORD_SERVER // for example https://api.maplecord.app
const token = process.env.BOT_TOKEN         // the token shown once under Settings, My bots
const botId = process.env.BOT_ID            // the Bot ID shown under Settings, My bots

// Every request to the server carries the token like this.
async function api(path, init = {}) {
  const res = await fetch(server + path, { ...init, headers: { Authorization: 'Bot ' + token, 'Content-Type': 'application/json' } })
  if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${path}: ${res.status} ${await res.text()}`)
  return res.status === 204 ? null : res.json()
}

const me = await api('/api/me')

// Slash commands. This list replaces whatever the bot had before, in every server it is in.
await api(`/api/applications/${botId}/commands`, {
  method: 'PUT',
  body: JSON.stringify([
    { name: 'hello', description: 'Says hello', options: [{ name: 'name', description: 'Who to greet', type: 0, required: false }] },
  ]),
})

// The live connection: this is where messages arrive, and how the bot posts.
const meta = await (await fetch(server + '/api/meta')).json()
const hub = new HubConnectionBuilder()
  .withUrl(server + '/hubs/chat', {
    accessTokenFactory: () => 'bot:' + token,
    ...(meta.webSocketsOnly ? { skipNegotiation: true, transport: HttpTransportType.WebSockets } : {}),
  })
  .withAutomaticReconnect()
  .configureLogging(LogLevel.Error)
  .build()

// Every message in a channel the bot can see.
hub.on('MessageReceived', async message => {
  if (message.authorId === me.id || message.kind !== 0) return // not the bot's own, and only ordinary messages
  if (message.content.trim() === '!ping')
    await hub.invoke('Post', { channelId: message.channelId, content: 'pong', replyToId: message.id })
})

// Someone used one of the bot's slash commands. It has 8 seconds to answer.
hub.on('InteractionCreated', async interaction => {
  if (interaction.commandName === 'hello') {
    const who = interaction.args.name ?? interaction.username
    await hub.invoke('RespondToInteraction', interaction.id, interaction.token, { content: `Hello, ${who}!`, ephemeral: false })
  }
})

hub.onreconnected(() => hub.invoke('Ready'))
await hub.start()
await hub.invoke('Ready')
console.log(`Signed in as ${me.username}`)

Run it with the three values in the environment:

MAPLECORD_SERVER=https://api.maplecord.app BOT_TOKEN=... BOT_ID=... node bot.mjs

Then type !ping or /hello in a channel the bot can see.

How a bot talks to the server

There are two halves, and most bots use both.

  • Requests for looking things up and for setting the bot up. They go to https://api.maplecord.app/api/... with the header Authorization: Bot <token>. Bodies and answers are JSON. An error is { "error": "..." } with a matching status code.
  • A live connection for everything that happens as it happens: messages arriving, and the bot posting. It is a SignalR connection to /hubs/chat, with the access token bot:<token>. Microsoft publishes SignalR client libraries for JavaScript, .NET and Java, and there are others for more languages. After connecting, and after every reconnect, call Ready before anything else. A Maplecord that is full keeps new arrivals in line, bots included. The three libraries below wait for you and tell you how many are ahead. On a connection of your own, ask QueuePlace before Ready: see the API reference.

A message can be posted either way: with one request, or over the live connection. Editing, deleting and reacting are done over the live connection.

IDs are GUIDs. Field names are camelCase. Kinds and types are sent as numbers.

Posting with one request

POST /api/channels/{channelId}/messages posts a message. Nothing has to stay connected, so it works from any language, a scheduled job, or a terminal:

curl -X POST "https://api.maplecord.app/api/channels/<channel id>/messages"   -H "Authorization: Bot <token>"   -H "Content-Type: application/json"   -d '{"content": "Backup finished"}'

The answer is the message that was posted, with status 201. The body can hold:

Field
content The text, up to 4000 characters
replyToId The ID of a message this one answers
threadId The ID of the message whose thread this goes in
attachmentIds Pictures uploaded beforehand (see below)
title For a post in a forum or a gallery, which needs one
poll { "options": ["Yes", "No"], "multi": false, "hours": 24 }, with content as the question
buttons Up to five buttons under the message

It follows the same rules as posting from the app: the bot needs Send messages in that channel, and it does not work in a P2P channel. To find a channel's ID, read GET /api/guilds: each server there lists its channels.

A bot may post 10 messages in any 10 seconds, counting what it does on the live connection too. Past that the answer is 429 with a Retry-After header.

Reading

Request Gives you
GET /api/me The bot's own account
GET /api/guilds Every server the bot is in, each with its channels, members and roles. Channels are already limited to the ones the bot may see.
GET /api/guilds/{guildId} One server, the same way
GET /api/channels/{channelId}/messages Messages, newest first. limit (50 unless given, 200 at most), and before, after or around a message ID. thread with a message ID reads that thread.
GET /api/channels/{channelId}/messages/{messageId} One message
GET /api/channels/{channelId}/pins Pinned messages
GET /api/channels/{channelId}/search?q=... Up to 25 matching messages. One search at a time per account.
GET /api/reactions The emoji that can be used as reactions

A channel's type is 0 category, 1 text, 2 voice, 3 direct message, 4 forum, 5 gallery.

Posting and acting

Call these on the live connection, for example hub.invoke('Post', {...}).

Method What it does
SendMessage(channelId, content, attachmentIds) Posts a message. attachmentIds may be null.
Post({ channelId, content, attachmentIds, replyToId, threadId, title, poll }) Posts with more choices: a reply, a message in a thread, a titled post in a forum or gallery, or a poll. Only channelId and content are needed.
EditMessage(messageId, content, title) Edits one of the bot's own messages
DeleteMessage(messageId) Deletes one of its own, or someone else's with Manage messages
React(messageId, emoji, on) Adds or removes a reaction: one of GET /api/reactions, or one of the server's custom emoji written <:name:id>
Pin(messageId, on) Pins or unpins. Needs Manage messages.
SetTyping(channelId) Shows the bot as typing
RespondToInteraction(interactionId, token, { content, embeds, ephemeral, buttons, update }) Answers a slash command or a button press

A message can be up to 4000 characters. Posting needs Send messages in that channel.

Where custom emoji are switched on, text can hold one of the server's own, written <:name:id> (the ids are in the messages people send). One the bot may not use there is refused with the reason. A sticker is posted on its own: Post({ channelId, stickerId }), with no text.

To post a picture, upload it first with POST /api/channels/{channelId}/attachments as multipart/form-data. The answer has an id; pass it in attachmentIds. This needs Attach files. A bot can post pictures and no other kind of file: anything else is refused with 400.

What arrives

Register a handler for each event you care about with hub.on(name, handler). A bot only receives events for channels it can see.

Event When
MessageReceived(message) A message was posted in a channel
ThreadMessage(message) A message was posted in a thread. These do not arrive as MessageReceived.
MessageEdited(channelId, messageId, content, title, editedAt) A message was edited
MessageDeleted(channelId, messageId) A message was deleted
ReactionChanged(channelId, messageId, emoji, userId, on, count) A reaction was added or removed
InteractionCreated(interaction) Someone used one of the bot's slash commands, or pressed one of its buttons
MemberJoined(guildId, member), MemberLeft(guildId, userId) Someone joined or left a server the bot is in
ChannelCreated, ChannelUpdated, ChannelDeleted A channel changed
PermissionsChanged(guildId) What the bot may see or do there changed. Read the server again.

The fields of a message you will use most: id, channelId, authorId, authorName, content, createdAt, kind, attachments, replyTo, threadId, webhookId.

kind says what sort of message it is: 0 an ordinary message, 1 a system notice, 2 a roll, 3 a coin flip, 4 from a webhook, 5 a bot's answer to a slash command, 6 rock paper scissors, 7 dice, 8 a file offer, 9 a call.

A bot that replies to messages should ignore its own (authorId is the bot's ID) and usually anything whose kind is not 0, or it can end up answering itself or another bot for ever.

Slash commands

Registering

Send the whole list in one request. It replaces what was there.

  • PUT /api/applications/{botId}/commands for every server the bot is in.
  • PUT /api/applications/{botId}/guilds/{guildId}/commands for one server.
[
  {
    "name": "weather",
    "description": "Today's weather somewhere",
    "options": [
      { "name": "city", "description": "Which city", "type": 0, "required": true },
      { "name": "units", "description": "Which units", "type": 0, "required": false,
        "choices": [{ "name": "Celsius", "value": "c" }, { "name": "Fahrenheit", "value": "f" }] }
    ]
  }
]

Option type: 0 text, 1 whole number, 2 true or false, 3 a person, 4 a channel, 5 a number.

Limits: 50 commands for each of those two lists, 25 options a command. Names are lower-case letters, digits, _ and -, up to 32 characters. Descriptions are 1 to 200 characters.

How people use them

Someone types /weather city:Paris in a channel. Values with spaces go in quotes: city:"New York". With no name: in front, what they typed goes to the first required option, or to the first option if none is required.

Maplecord's own commands (/roll, /need, /dice, /flip, /rps and their relatives) come first, so do not give a command one of those names. Slash commands work in server channels, not in direct messages or P2P channels.

Whoever manages a server can limit a bot's commands there: to certain roles and people, to certain channels, for the bot as a whole or one command at a time (Server settings, Bots, Manage). The bot does nothing for this. A use the server does not allow is refused before it reaches the bot, and people are only offered the commands they can use. Someone who can manage the server can always use all of them.

A bot can also watch the messages it receives for words of its own, such as !ping. That needs nothing registered, and the server's limits on slash commands do not apply to it.

Answering

The bot receives InteractionCreated with:

id, token, commandName, guildId, channelId, userId, username, and args, the options as text keyed by name. The server has already checked that required options are there and that each value fits its type.

Answer once with RespondToInteraction(id, token, { content, embeds, ephemeral }).

  • ephemeral: true shows the answer only to whoever used the command, and it is not kept.
  • embeds takes the same cards as a webhook; see below.
  • Answer within 8 seconds. After that the person is told the command did not get a response. A late answer is still posted for up to 15 minutes.

Without a live connection

A bot does not have to stay connected. Under Settings, My bots, give the bot a web address to send its slash commands to. When the bot is not connected, each use of a command is sent there as a POST with the same fields as above, as JSON. The address has to be https and reachable from the internet.

Check that the request is really from Maplecord before trusting it. It carries two headers:

  • X-Maplecord-Timestamp: the time, in seconds.
  • X-Maplecord-Signature: sha256= followed by the HMAC-SHA256, in lower-case hex, of the timestamp, a full stop and the exact body, keyed with the bot's signing secret.
import { createHmac, timingSafeEqual } from 'node:crypto'

function fromMaplecord(rawBody, headers, signingSecret) {
  const expected = 'sha256=' + createHmac('sha256', signingSecret).update(headers['x-maplecord-timestamp'] + '.' + rawBody).digest('hex')
  const given = headers['x-maplecord-signature'] ?? ''
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}

Answer by sending { "content": "...", "ephemeral": false } to POST /api/interactions/{id}/{token}. That request needs no Authorization header; the token in the address is what allows it, once.

Buttons

A bot's message can have up to five buttons under it. When someone presses one, the bot is told who pressed which, and answers the way it answers a slash command.

Putting buttons on a message. Add buttons when posting, with one request or over the live connection, or when answering a slash command:

{
  "content": "Ready check for tonight",
  "buttons": [
    { "id": "yes", "label": "I am ready", "style": 1 },
    { "id": "no", "label": "Not yet", "style": 2 },
    { "id": "later", "label": "Ask me later" }
  ]
}
Field
id The bot's own name for the button, handed back when it is pressed. 1 to 64 letters, digits, or any of _ . : -. No two on one message the same.
label What the button says, 1 to 40 characters
style 0 plain, 1 in Maplecord's purple, 2 red. Plain if left out.

When one is pressed the bot receives InteractionCreated, as for a slash command, with:

  • buttonId: the id of the button;
  • messageId: the message it is under;
  • userId and username: who pressed it;
  • commandName empty and args empty, which is how to tell a press from a command.
hub.on('InteractionCreated', async interaction => {
  if (interaction.buttonId === 'yes')
    await hub.invoke('RespondToInteraction', interaction.id, interaction.token, { content: `${interaction.username} is ready.`, ephemeral: false })
})

The bot answers once, within 8 seconds, with RespondToInteraction or POST /api/interactions/{id}/{token}. Its answer is a new message, which can have buttons of its own. An answer with ephemeral: true is shown only to whoever pressed, is not kept, and cannot have buttons.

Changing the message instead. With update: true in the answer, nothing new is posted: the message the button is on becomes the answer, with its content, embeds and buttons. Buttons left out are taken off. Everyone looking at the channel sees it change.

await hub.invoke('RespondToInteraction', interaction.id, interaction.token, { content: 'Added song 3.', buttons: [{ id: 'undo', label: 'Undo' }], update: true })

If the message has been deleted in the meantime, the answer is posted as a new message.

Things to know:

  • Buttons stay pressable for as long as the message is there. Pressing does not change the message unless the bot answers with update: true; if a button should stop counting after one use, the bot has to take it off that way or remember it itself.
  • Whoever may write in the channel may press. Only a bot's message can have buttons: not a person's, not a webhook's.
  • A bot that is not connected is sent presses at its web address, like slash commands. With neither, the person is told the bot is offline.
  • Presses count towards the person's 10 actions in 10 seconds, like messages.

Webhooks

A webhook is an address that posts a message into one channel when something sends JSON to it. Nothing has to stay running, and no bot is needed.

Make one: Server settings, Webhooks, type the name it will post as, choose a text channel and press Create webhook. Copy the address now; it is not shown again. This takes the Manage webhooks permission. New address replaces it if it leaks.

Post to it:

curl -X POST "https://api.maplecord.app/api/webhooks/<id>/<token>" \
  -H "Content-Type: application/json" \
  -d '{"content": "Build finished"}'

What the body can hold:

Field
content The text, up to 4000 characters
username A name to post as this once, in place of the webhook's own
avatarUrl A picture to post with this once
embeds Up to 10 cards

Either content or embeds has to be there. An embed is a card with any of:

{
  "title": "Version 1.2.3",
  "description": "All tests passed.",
  "url": "https://example.com/builds/123",
  "color": 3107839,
  "fields": [{ "name": "Took", "value": "4 minutes", "inline": true }],
  "image": { "url": "https://example.com/chart.png" },
  "footer": { "text": "build server" }
}

color is a number: the colour #2f6bff is 0x2f6bff, which is 3107839. A title can be 256 characters, a description 2048, and a card can have 25 fields. Up to 4 pictures a message are shown; the Maplecord server fetches each one and serves its own copy, so nobody's app loads anything from another site.

Limits: 15 webhooks a channel, and 30 posts a minute for each webhook. Past that the answer is 429.

Limits

What Limit When you pass it
Requests 600 a minute for each bot 429, with a Retry-After header saying how many seconds to wait
Posting with a request, and posting, editing, reacting, pinning and voting over the live connection, all together 10 in any 10 seconds for the bot, however many connections it has open A request gets 429, with a Retry-After header; a call fails with "You are doing that too fast"
Other calls on the live connection 800 in any 10 seconds on each connection The same
Uploads An hourly allowance for each account, and a largest file size the server sets 429 or 413
One message over the live connection 128 KB

Bots have the same limits as people. Build in a pause and try again when you are told to slow down.

Bots in many servers

A bot in more than 100 servers is no longer sent:

  • messages, edits, reactions and typing. It cannot read messages back either: those requests get 403;
  • member lists, and people joining, leaving or changing. GET /api/guilds then lists only the bot itself as a member;
  • who is online, idle or not to be disturbed.

It still gets its slash commands and button presses, can answer them, and can post.

Whoever runs the Maplecord server sets that number, and can lift any of the three for one bot. Ask them if your bot needs one.

Voice

A bot joins voice channels only if its owner allows it: tick Let it join voice channels when you make it, or May join voice channels under My bots later. It also needs the Join voice permission, like anyone.

The two kinds of bot are in voice in different places, and very differently. Which kind you make decides all of this, and it cannot be changed afterwards.

Ordinary bot P2P bot
Servers it can be in Standard and hybrid servers Full P2P and hybrid servers, and one server only
Voice channels it can join Ordinary voice channels, and no P2P one The P2P voice channels it was let into, one at a time, and no ordinary one
Being heard It listens. It is heard only in turns it asks for, and only where whoever runs the Maplecord server has switched that on. It is heard without asking, unless whoever runs the Maplecord server has switched P2P bots being heard off. Then it only listens.
Limits on what it sends Seconds at a time, seconds of quiet after, and kilobits a second, all set by whoever runs the server. They can lift them for one bot. None
Where its sound travels Through the Maplecord server's relay or stream server, as a person's voice does there Straight between the bot and each person's app. Never through the server or a relay.
What people are asked Nothing. They see that it is a bot, and whether it is being heard. Each person's app asks before it connects. Until they agree they do not hear it and it does not hear them.
IP addresses It never learns anyone's Whoever runs it can find the address of everyone who connects to it
Someone on the relay Hears it like anyone else Never connects to it, so does not hear it
Hearing people It hears the channel It hears whoever has connected to it
Sharing video Only where whoever runs the server has switched video from bots on. It is off to start. Only where they have switched video from P2P bots on, which is a separate switch. It is off to start.

The same for both:

  • A bot never watches video, and cannot start or join a call.
  • People in the channel are told it is a bot.
  • Whoever runs the Maplecord server can switch bots in voice off altogether.

A bot for music or anything else that plays for a long time is a P2P bot, where nothing limits what it sends because none of it crosses the server, or an ordinary bot that whoever runs the Maplecord server has lifted the limits for. A bot that speaks now and then in ordinary channels (a translator, a voice assistant) is an ordinary bot, within the limits the server sets.

Being heard

An ordinary bot comes into a voice channel listening. It hears the channel and is not heard, and people see it marked that way. To be heard it asks, on the voice connection:

  • Speak() answers { seconds, kbps, until, restSeconds }: how long the bot is heard for, the most kilobits a second it may send, when it will be made quiet again, and how long it then has to stay quiet. The server makes it quiet by itself when the time is up.
  • The apps of the people listening measure what arrives. A bot sending clearly more than kbps is no longer played, and its turn ends at once, with the whole quiet to follow.
  • StopSpeaking() ends the turn early.

After a turn the bot has to stay quiet for restSeconds before it can ask again; asking sooner is refused and says how many seconds are left. A turn ended early costs the same share of the quiet as was used of the turn: with 60 seconds of each, a turn ended after 5 seconds is followed by 5 seconds of quiet.

Whoever runs the Maplecord server decides whether bots can be heard at all, and sets the three numbers. It is off unless they turn it on, and Speak() is then refused. They can also lift the limits for one bot: that bot is heard from the moment it joins, Speak() answers { seconds: 0, kbps: 0, until: null }, and there is no turn to keep to. The bot also needs the Speak permission in the channel.

A P2P bot in a P2P voice channel is heard without asking, and nothing it sends there goes through the server. Whoever runs the server can switch that off, for every P2P bot or all but the ones they choose; a P2P bot is then shown as listening, and people's apps play nothing from it. It is told in ParticipantUpdated, where its own silenced is true.

Sharing video

Bots sharing video is off unless whoever runs the Maplecord server turns it on: one switch for ordinary bots in ordinary voice channels, another for P2P bots in P2P voice channels. They can also allow it for one bot. Where it is off, starting to share is refused. A bot never watches what others share. Nothing here sends video for a bot yet: no library does, and MapleLink sends sound only.

What sends the sound

A bot is heard through MapleLink, which the libraries can drive: a P2P bot in a P2P voice channel, and an ordinary bot in an ordinary one. For an ordinary bot MapleLink plays only where the limits have been lifted for that bot: it does not take turns. The calls a bot makes itself to join a voice channel and ask to be heard are not in the API reference.

What the server's owner can switch off

Whoever runs a Maplecord server can switch off, for every bot: bots as a whole, reading and posting messages, voice, P2P bots, and slash commands and buttons. For one bot, they can suspend it, ban it or revoke its token.

  • Bots switched off, a P2P bot while P2P bots are off, or a suspended or banned bot: every request gets 401 and its connections are closed. My bots says when a bot is suspended or banned.
  • A banned bot is also taken out of every server and cannot be added to one.
  • A revoked token gets 401 until you press New token.
  • Messages switched off: reading and posting get 403. Slash commands and buttons still reach the bot, and it can answer them.

What a bot cannot do

  • Be in a P2P channel, or read or post anything there.
  • Watch video, or start or join a call.
  • Press buttons.
  • Use slash commands itself.
  • Be someone's friend, be called, or be sent a direct message.
  • Join a public server by itself. Someone with Manage server adds it.
  • Make or manage other bots, link accounts, or wear a server tag.

If the person who made a bot is suspended, the bot stops working. If they delete their account, their bots go with it.

P2P bots

A P2P bot is made by ticking Make it a P2P bot when you create it. That cannot be changed afterwards.

  • It can only be added to a server that has P2P channels (a full P2P server or a hybrid), and to one server only. For another server, make another bot. (An ordinary bot, the other way round, cannot be added to a full P2P server.)
  • It is then let into P2P channels one at a time: right-click a P2P channel, open P2P bots and tick it.
  • It connects straight to people's apps there. Each person is asked before their app connects to it, because whoever runs the bot can then find their IP address. It is never reached through the relay.
  • It has no slash commands and cannot read or post in ordinary channels.
  • People can type commands to it in the P2P channels it was let into, such as !play. Those are ordinary messages that reach it straight from people's apps. See Typed commands below.
  • If its owner allows it, it can join the P2P voice channels it was let into. See Voice, which sets it beside an ordinary bot in an ordinary voice channel.

Typed commands for a P2P bot

The server never sees what is typed to a P2P bot, so it cannot check who may use which command. Whoever manages a server can still set that in the app (Server settings, Bots, Manage), and the bot applies it:

  1. The bot asks which bot it is: GET /api/applications/me. The answer's id is its Bot ID.
  2. It lists its commands by name: PUT /api/applications/{botId}/commands with [{ "name": "play", "description": "…" }, …]. No options: these are not slash commands, and are never offered after "/".
  3. It reads what the server's managers set: GET /api/applications/{botId}/guilds/{guildId}/command-rules. The answer has bot (the rule for all of its commands), commands (each with a rule of its own, or null), and managers (the IDs of people who manage the server).
  4. It reads them again when CommandsChanged arrives.

A rule is { everyone, roleIds, userIds, allChannels, channelIds }. Someone may use a command when they are in managers; or when the rule says everyone, names them, or names a role they hold (their roles are on the member list from GET /api/guilds), and the channel is allowed. A command's own rule is used instead of the bot's.

Nothing makes a P2P bot keep to this but the bot. Only add one whose host you trust.

Writing one from scratch means speaking the same app-to-app protocol the Maplecord app does. The one P2P bot that exists today is the archive bot: see Keeping your own history.