What a bot is
- Not an account with a badge. Its messages are written by its own bot account, and that account is marked as a bot wherever an author is shown: in a message, in a member list, in a conversation list, on a profile.
- Not the developer. It posts with the rights the community gave it, and the developer cannot speak as it with their own session.
- Not a person. It has no password, so there is nothing to sign in with and nothing to guess.
Getting started
1. Create the application
Inside the community it will live in, a community administrator creates an application:
POST /api/v1/communities/{communityId}/applications
Authorization: Bearer {a person with communities:write and bots:write}
{ "name": "helper", "scopes": ["communities:read", "members:read", "channels:read", "roles:read", "messages:read", "messages:write"] }
The answer carries the application, its token and its credential. The token is shown once. It is stored hashed, and it is not in any answer afterwards - not the application, not the credential, not the health report. If it is lost, rotate it:
POST /api/v1/communities/{communityId}/applications/{applicationId}/rotate-token
Rotating ends the old token at once.
2. Keep the token safe
Put it in the environment, never in the source and never in a URL:
TRYCORD_BOT_TOKEN=trcd_bot_...
A token is a credential. A URL ends up in access logs, proxy logs and referrer headers, so the token never rides in one - not even for the gateway, which is why the gateway has a ticket.
3. Make the first request
curl -H "Authorization: Bearer $TRYCORD_BOT_TOKEN" \
https://your-instance.example/api/v1/communities/$COMMUNITY
The answer is the community. Every route is under the instance's versioned API root, and the error envelope is the same everywhere:
{ "error": { "code": "INSUFFICIENT_SCOPE", "message": "this credential lacks the scope: messages:write" } }
4. Send the first message
curl -X POST -H "Authorization: Bearer $TRYCORD_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"Hello from the bot."}' \
https://your-instance.example/api/v1/communities/$COMMUNITY/channels/$CHANNEL/messages
The message is written by the bot, and it says so everywhere it is read.
Authentication and scopes
One header, one kind of credential. A credential carries scopes, and a route checks them:
| Scope | What it allows |
|---|---|
communities:read | read a community |
members:read | list the members of a community |
channels:read | read channel names, topics and types |
roles:read | read role names, colors and permissions |
communities:write | change the community's developer configuration |
bots:read | list the applications, read one, read its health |
bots:write | create an application, give it scopes, rotate and revoke it |
commands:read | list the commands an application answers |
commands:write | declare, change and remove those commands |
messages:read | read the recent messages of a channel |
messages:write | post a message in a channel |
webhooks:manage | manage the community's webhooks |
A credential may only be created with scopes that are a subset of its own, so a key can never widen itself. The scopes are not the whole of the permission: a bot that may post still needs the community to let it.
The API reference
Every route this instance serves is described by an OpenAPI document, generated from the same definitions the server routes on:
GET /api/v1/openapi.json
The routes a bot uses:
| Route | What it does |
|---|---|
GET /api/v1/applications/self | which application this token belongs to, and its community |
GET /api/v1/communities/{id} | the community |
GET /api/v1/communities/{id}/members | a page of members with limit and offset |
GET /api/v1/communities/{id}/channels/{id} | channel details |
GET /api/v1/communities/{id}/roles | role names, colors and permissions |
GET /api/v1/communities/{id}/applications | the applications of the community |
GET /api/v1/communities/{id}/applications/{app}/health | what the instance thinks of the application |
GET /api/v1/communities/{id}/channels/{id}/messages | recent messages, with a cursor for older pages |
POST /api/v1/communities/{id}/channels/{id}/messages | post a message as the application |
GET /api/v1/communities/{id}/applications/{app}/commands | the commands the application answers |
PUT /api/v1/communities/{id}/applications/{app}/commands | declare a command |
POST /api/v1/applications/{app}/gateway-ticket | a ticket for the gateway |
Events and the gateway
An application that can post and cannot hear anything is a mail-merge. The gateway is the socket it holds open. Three steps:
POST /api/v1/applications/{applicationId}/gateway-ticket
wss://your-instance.example/api/v1/gateway?application={applicationId}&ticket={ticket}
{ "op": "subscribe", "events": ["message.created", "member.joined"] }
The events are the same names the webhooks are handed, from the same dispatch, so a bot and a webhook cannot see different events:
message.created— a message was written.member.joinedandmember.left— somebody joined or left the community.channel.createdandchannel.deleted.role.created,role.updatedandrole.deleted.command.created— a command was declared.
A connection that has not said which events it wants hears nothing, and an application only hears the events of the community it belongs to.
What the gateway does not promise: an event is delivered while the socket is open. Nothing is replayed, and nothing is queued for a socket that was closed. A bot that was not connected missed what happened, and the messages it can read are still there when it comes back.
Commands
A command is declared in the community, so a member sees it there. The declaration needs a person's credential with communities:write and commands:write — the server refuses an application on that route, because a bot that could declare its own commands would be able to answer anything it liked.
PUT /api/v1/communities/{communityId}/applications/{applicationId}/commands
{ "name": "ping", "description": "Answers with a word.", "response": "Pong." }
The response is a template, not a script. {name} is filled in with the argument of that name, and {name:something} with the argument or with something when the member left it out. Nothing else is substituted, and there is no expression evaluation anywhere on this path.
The SDK: trycord-botjs
The official client for everything above. It calls the routes this page describes and returns the shapes they return.
Install it from npm, or from a Trycord source checkout:
npm install trycord-botjs
# or, from a checkout of this repository
npm install ./sdk/trycord-botjs
import { Client, Events } from 'trycord-botjs';
const client = new Client({
token: process.env.TRYCORD_BOT_TOKEN!,
// A self-hosted instance is addressed by its own name.
baseUrl: process.env.TRYCORD_BASE_URL || 'https://trycord.dev',
});
client.on(Events.MessageCreated, async (event) => {
if (event.message?.content === '!ping') {
await client.send(event.serverId!, event.channelId!, 'Pong.');
}
});
await client.login();
It is built in TypeScript, ships its own types, and the errors say which kind of thing went wrong: a refusal from the server, a rate limit with a number attached, a network that did not answer, or a mistake in the calling code. A request that writes something is never retried, so a message is not posted twice.
Examples
A ping bot, a command bot, and a self-hosted instance:
// ping-bot.ts
import { Client, Events } from 'trycord-botjs';
const client = new Client({ token: process.env.TRYCORD_BOT_TOKEN! });
client.on(Events.MessageCreated, async (event) => {
const message = event.message;
if (!message || message.author_is_bot) return;
if (message.content.trim().toLowerCase() !== '!ping') return;
await client.send(event.serverId!, event.channelId!, 'Pong.');
});
await client.login();
The full set, with the tests, is in the repository under sdk/trycord-botjs.
Rate limits and troubleshooting
Every credential has a rate limit, enforced on every request it makes, with a Retry-After header when it is over. One bot cannot use up the instance's capacity for everybody else.
| What you see | What it means |
|---|---|
401 API_KEY_INVALID | the token was revoked, or the application was deleted |
403 INSUFFICIENT_SCOPE | the credential does not hold the scope the route asks for; the message names it |
403 BOT_NOT_ALLOWED | the route is not for applications, or the application is disabled |
403 PERMISSION_DENIED | the community does not let this bot post in this channel |
404 NOT_FOUND | the application is gone, or the token is not one of its credentials |
| nothing arrives on the socket | the application has not subscribed, or it is not connected |
The health report says what the instance thinks of the application, and every number in it comes from a row this instance wrote:
GET /api/v1/communities/{communityId}/applications/{applicationId}/health
Where the rest lives
- The self-hosting guide — running your own instance.
- What Trycord does — the product, for a reader rather than a builder.
- The source — the API reference, the SDK and the tests.