!ping in any channel it can see. Along the way it prints the permission grant each server hands it and logs the connection lifecycle, so you can watch the parts you cannot see from the app.
The SDK repo ships this as examples/welcome-bot.ts. The version below is the same bot pointed at Cloak’s hosted service, with the self-hosted transport plumbing left out.
The full bot
welcome-bot.ts
Run it
examples/welcome-bot.ts and reads your token from a local .env. Node must be ^20.19.0 || >=22.12.0. See Install and requirements.
You do not set a
url. The SDK connects to Cloak’s hosted service over a standard wss:// WebSocket by default. url, allowInsecure and serverCertificateHashes only come up when you point a bot at a self-hosted or local dev stack, which the shipped example shows in its header comment.How it works, piece by piece
Why keystorePath is not optional
Why keystorePath is not optional
The bot publishes its cryptographic identity to the server on its first login, and that publication is write-once. Without a keystore the identity is generated fresh in memory each run, so the first run works, the second run tries to publish a different identity, and the server refuses it permanently. Set
keystorePath (or pass a keystore adapter) before you ever run the bot. See Keys and the keystore.Declaring permissions, and being granted them
Declaring permissions, and being granted them
requiredPermissions: ['message_send'] tells servers what the bot wants. That is a request, not a grant. A human approves a subset per server on the add-a-bot screen, and the server pushes the resolved set back on permissionsUpdate, which carries the server id, an effectiveRank, and one boolean per permission. The handler above prints it so you can see exactly what you were given. See Permissions.Greeting new members
Greeting new members
A join arrives as a
systemMessage with type: 'member_join'. The event carries actorId, actorName, serverId, channelId, and often groupId, so the bot knows who joined and where to say hello.Pass { groupId: evt.groupId } as the options object. The channel select needs the channel’s group, and for a channel the bot has not seen traffic from yet the SDK has nothing cached to fall back on. groupId is optional on the event, so treat it as a hint rather than a guarantee.Ignoring its own messages
Ignoring its own messages
msg.authorId === client.user?.id filters out the bot’s own traffic, which it also receives on the firehose. Without the guard, a bot that echoes could answer itself forever. Both ids are normalized by the SDK, so the comparison is reliable across the two id shapes in the platform.DMs arrive on the same event
DMs arrive on the same event
messageCreate is unified. A direct message arrives on the same handler with isDM: true and a null serverId, because a DM has no server at all.That is why the permission check reads if (msg.serverId && !client.can(...)). can() takes a string, so passing msg.serverId unguarded does not typecheck, and in JavaScript it would return false for every DM and make the bot silently mute there. Any handler that does something server-scoped needs if (msg.isDM || !msg.serverId) return; instead. See Direct messages.channel.send() versus reply()
channel.send() versus reply()
msg.channel.send('pong') is a plain message back into the same place the command came from. msg.reply('pong') is a true reply: it renders a quote header and fires a “replied to you” notification that pierces the recipient’s mutes. A ping bot answering a command does not need to pierce anyone’s mutes, so this bot uses channel.send(). Reach for reply() when the quote header genuinely helps the reader. See Mentions and replies.Sends are fire-and-forget
Sends are fire-and-forget
A resolved
send() promise means the frame went out, not that the message landed. A server-side denial arrives later on the sendRejected event, never as a rejection of the send promise. The .catch() handlers above only catch local failures, like an invalid target or a dead transport. Add a sendRejected listener if you need to know about denials. See Sending messages.Watching the connection
Watching the connection
disconnect, reconnecting and reconnected report the transport. The SDK reconnects on its own with backoff, resumes the session, and re-establishes anything you had watched. Two login failures are terminal and stop the loop: -2 (the token was revoked or regenerated) and -12 (the SDK is below the backend’s minimum wire version). Both emit disconnect rather than throwing. See Connection lifecycle.Next
Moderation bot
The next step up: commands, actions, and typed denials.
Events reference
Every event this bot listens for, with payload shapes.
Client reference
Constructor options and methods used above.
Keys and the keystore
Why the keystore file decides whether a second run works.