Developers

Build a bot for Trycord

A Trycord bot is an application: an identity of its own, with a token that is shown once, scopes that say what it may do, and a gateway it holds open to hear what happens. This page is the whole thing: how to make one, how to talk to it, and what to do when it does not answer.

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:

ScopeWhat it allows
communities:readread a community
members:readlist the members of a community
channels:readread channel names, topics and types
roles:readread role names, colors and permissions
communities:writechange the community's developer configuration
bots:readlist the applications, read one, read its health
bots:writecreate an application, give it scopes, rotate and revoke it
commands:readlist the commands an application answers
commands:writedeclare, change and remove those commands
messages:readread the recent messages of a channel
messages:writepost a message in a channel
webhooks:managemanage 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:

RouteWhat it does
GET /api/v1/applications/selfwhich application this token belongs to, and its community
GET /api/v1/communities/{id}the community
GET /api/v1/communities/{id}/membersa page of members with limit and offset
GET /api/v1/communities/{id}/channels/{id}channel details
GET /api/v1/communities/{id}/rolesrole names, colors and permissions
GET /api/v1/communities/{id}/applicationsthe applications of the community
GET /api/v1/communities/{id}/applications/{app}/healthwhat the instance thinks of the application
GET /api/v1/communities/{id}/channels/{id}/messagesrecent messages, with a cursor for older pages
POST /api/v1/communities/{id}/channels/{id}/messagespost a message as the application
GET /api/v1/communities/{id}/applications/{app}/commandsthe commands the application answers
PUT /api/v1/communities/{id}/applications/{app}/commandsdeclare a command
POST /api/v1/applications/{app}/gateway-ticketa 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.joined and member.left — somebody joined or left the community.
  • channel.created and channel.deleted.
  • role.created, role.updated and role.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 seeWhat it means
401 API_KEY_INVALIDthe token was revoked, or the application was deleted
403 INSUFFICIENT_SCOPEthe credential does not hold the scope the route asks for; the message names it
403 BOT_NOT_ALLOWEDthe route is not for applications, or the application is disabled
403 PERMISSION_DENIEDthe community does not let this bot post in this channel
404 NOT_FOUNDthe application is gone, or the token is not one of its credentials
nothing arrives on the socketthe 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