Bot API
Write bots for Jaw in C#. A bot is an ordinary Jaw account run by your program: it joins rooms, answers slash commands, posts buttons and menus, and reads and sends direct messages, all end-to-end encrypted like any app.
How bots work
There is no separate bot server or HTTP API to call. The Jaw.Bots library does in your process
everything a Jaw app does: it holds the bot's keys, connects to a home node over a WebSocket, encrypts and decrypts
messages, and keeps the bot's rooms in sync. Nodes only ever carry the bot's ciphertext, exactly as they do for people.
- A bot is a declared account. It publishes a signed bot profile: its name, description, the permissions it asks for and its slash commands. Apps show a BOT tag beside it and list it in the bot directory (Browse → Find a bot).
- Rooms choose to add it. A room operator adds the bot from an approval screen that lists what it asks for, and may grant less. The bot then gets a role with those permissions and joins by itself.
- Commands are addressed to it. When someone types
/roll 2d6and picks your bot's command, the app sends a signed command event to the room that only your bot answers. - Buttons are just labels and ids. Nothing runs in people's apps. A click is posted to the room as a signed event, and your handler decides what happens.
Get the SDK
Jaw API 0.0.5
1.1 MB · SHA-256 522afa87c83cc2f3785fe7350f81e0dbf3221782322b981f2b07fefe001083ca
Download the Jaw APIIt comes from this node, like everything on the downloads page: make sure you trust the node, or compare the SHA-256 with another node's.
You need the .NET 10 SDK (dot.net). The bot kit contains:
| Path | What it is |
|---|---|
packages/ | The SDK as NuGet packages: Jaw.Bots, and Jaw.Client.UI and Jaw.Core, which it uses. |
DiceBot/ | An example bot with commands, buttons, edits and DMs, ready to dotnet run. |
nuget.config | Points projects in the kit's folder at packages/. |
README.md | The short version of this page. |
To use the SDK in your own project, put your project inside the unzipped kit folder (so it finds
nuget.config), or add packages/ as a package source, then:
dotnet new console -n MyBot
cd MyBot
dotnet add package Jaw.Bots --version 0.0.5
Quick start
The smallest useful bot: one command, run until Ctrl+C.
using Jaw.Bots;
using var stop = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); };
await using var bot = new JawBot(new JawBotOptions
{
DataDirectory = "mybot-data", // its keys live here: keep it private, back it up
Name = "MyBot",
Description = "Says hello.",
OnRecoveryPhrase = words => Console.WriteLine("Recovery phrase: " + string.Join(' ', words)),
});
bot.Command("hello", "Say hello", ctx => ctx.ReplyAsync($"Hello, {ctx.UserName}!"));
await bot.RunAsync(stop.Token);
Register every command and handler before calling RunAsync; it throws if you add one after.
RunAsync returns when the token is cancelled.
First run and adding your bot to a room
- Account. On the first run the bot creates its account in
DataDirectoryand callsOnRecoveryPhraseonce with its 24-word recovery phrase. The SDK never stores or logs the phrase: write it down if you want to be able to recover the bot. - Home node. If you set
Node, it connects there. Otherwise it uses the node it used last time, or picks one from the network's starting list that ships inside the package. A node picked from that list must prove it is the node the list names. To use this node:Node = new Uri("wss://chronocloned.net/ws/client"), - Declare. It publishes its bot profile (only when something in it changed) and, if you set
Handle, claims that @handle so people can find it. - Add it to a room. In a Jaw app, open Browse → Find a bot, or invite the bot's @handle from a room.
The room operator sees what the bot asks for, chooses what to grant, and the bot joins by itself (set
AcceptInvites = falseto turn that off). - Use it. Type
/in the room to see its commands.
Bots for one community
Not every bot is for everyone. If you've built a bot for your own server, set Listed = false and give it
a handle:
await using var bot = new JawBot(new JawBotOptions
{
DataDirectory = "denbot-data",
Name = "DenBot",
Description = "Rota and reminders for the Den.",
Handle = "denbot",
Listed = false, // don't advertise it
});
An unlisted bot:
- doesn't appear in bot search (Browse → Find a bot, or a node's
/api/bots); - can't be featured by node owners, and leaves the featured list if it was featured before;
- still works everywhere else. Invite it from your room by its handle (
@denbot): the room operator gets the usual approval screen, and its commands and buttons work as normal.
Unlisted isn't secret. Its profile still travels between nodes so every member's app can show its commands, and anyone
who knows its handle or id can look it up. It just isn't advertised. You can change your mind at any time: set
Listed back to true and restart the bot.
Your bot's home node must support unlisted bots. If it doesn't, RunAsync stops with a
BotException saying so, rather than publishing the bot as listed. Choose a newer node with
Node. Nodes that haven't updated yet never receive unlisted profiles, so people whose home node is one of
those see the bot as an ordinary account until their node updates.
Options
JawBotOptions: only DataDirectory and Name are required.
| Option | Default | What it does |
|---|---|---|
DataDirectory | (required) | Where the bot keeps its keys and data. Created readable only by the bot's user. This folder is the bot's account. |
Name | (required) | The bot's name in its profile and in chats. Up to 64 characters. |
Description | empty | What it does, shown in the bot directory and the approval screen. Up to 1,000 characters. |
Developer | empty | Who makes it, e.g. @frank or a project name. Up to 100 characters. |
Homepage | empty | An https:// page about the bot. Up to 200 characters. |
Tags | none | Tags for the bot directory, e.g. ["games", "polls"]. |
RequestedPermissions | SendMessages | What it asks for when added to a room (see Permissions). Operators may grant less. |
Handle | none | An @handle to claim, e.g. "dicebot". Skipped (with a log line) if someone else holds it. |
Listed | true | Whether to advertise the bot: in bot search, and available for node owners to feature. Set false for a bot made for one community; people add it by its Handle (see Bots for one community). |
Node | picked | The home node's client endpoint, e.g. wss://chronocloned.net/ws/client. |
Passphrase | generated | Encrypts the bot's keys on disk. If not set, a random one is kept beside them in an owner-only file, so the keys are protected by file permissions alone. Set it from a secret store to keep it off that disk. |
OnRecoveryPhrase | none | Called once, when the account is created, with its 24 recovery words. |
AcceptInvites | true | Join rooms the bot is invited to. |
MaxEventAge | 10 minutes | Commands and clicks older than this (sent while the bot was offline) are skipped. |
ReconnectDelay | 2 seconds | Wait before reconnecting; doubles after each failure, up to 5 minutes. |
Slash commands
bot.Command(name, description, handler, params BotCommandOption[] options)
Names are 1-32 characters of a-z 0-9 _ -. Descriptions are shown in the command list
(up to 100 characters). A bot can have up to 50 commands, each with up to
10 options.
Options are matched to what the person typed, in order, split on spaces; the last option gets the rest of
the line. Declare one with new BotCommandOption(name, description, required).
using Jaw.Core.Bots;
bot.Command("remind", "Remind the channel later, e.g. /remind 10 stand up", async ctx =>
{
if (!int.TryParse(ctx.Option("minutes"), out var minutes) || minutes is < 1 or > 1440)
{
await ctx.ReplyAsync("Say how many minutes, e.g. /remind 10 stand up");
return;
}
var what = ctx.Option("what") ?? "something";
await ctx.ReplyAsync($"OK, I'll remind you in {minutes} min.");
_ = RemindLaterAsync(ctx.Bot, ctx.Room, ctx.Channel, minutes, what); // don't hold up other events
},
new BotCommandOption("minutes", "How long to wait", true),
new BotCommandOption("what", "What to say", false));
static async Task RemindLaterAsync(JawBot bot, Jaw.Core.Rooms.RoomId room, string channel, int minutes, string what)
{
await Task.Delay(TimeSpan.FromMinutes(minutes));
await bot.SendAsync(room, channel, "⏰ " + what);
}
In the handler, CommandContext gives you:
| Member | What it is |
|---|---|
Name | The command, without the slash. |
Arguments | Everything typed after the command name. |
Options, Option(name) | The arguments matched to your options (null when not given). |
ReplyAsync(text, components...) | Answers in the same channel, shown as a reply to the command. |
SendAsync(text), SendAsync(text, components...) | Posts to the same channel, not as a reply. |
User, UserName | Who used it, and their display name (self-chosen, not unique: use User to tell people apart). |
Room, RoomName, Channel | Where (Channel is main in single-channel rooms). |
Message | The signed command event itself. |
Bot | Your JawBot, to do anything else. |
Buttons and menus
Pass components to ReplyAsync or SendAsync. Apps draw them only on messages from declared bots.
using Jaw.Core.Rooms;
await ctx.ReplyAsync("Deploy to production?",
MessageComponent.Button("deploy:yes", "Deploy", ButtonStyle.Danger),
MessageComponent.Button("deploy:no", "Cancel"));
await ctx.ReplyAsync("Pick a colour",
MessageComponent.Select("colour", "Choose…",
[
new SelectOption("red", "Red"),
new SelectOption("green", "Green"),
new SelectOption("blue", "Blue"),
]));
MessageComponent.Button(id, label, style): styles arePrimary,Secondary(the default) andDanger.MessageComponent.Select(id, placeholder, options): a menu of up to 25SelectOption(value, label)choices, each value unique and up to 100 characters.- Ids are 1-32 characters of
a-z 0-9 _ - : .. Use a prefix such aspoll:so one handler can take a family of buttons. - Up to 10 components per message; labels up to 80 characters.
Handling clicks
bot.OnInteraction("deploy:", async click =>
{
if (click.ComponentId == "deploy:yes")
{
await click.EditMessageAsync($"Deploying (asked by {click.UserName})…");
// … start the deploy …
}
else
{
await click.EditMessageAsync("Cancelled.");
}
});
bot.OnInteraction("colour", click => click.ReplyAsync($"{click.UserName} picked {click.Value}."));
OnInteraction(prefix, handler) runs for clicks on components whose id starts with the prefix;
OnInteraction(handler) runs for every click. Only clicks on your bot's own messages reach it.
InteractionContext has the same User, Room, Channel and
SendAsync as commands, plus:
| Member | What it is |
|---|---|
ComponentId | The id you gave the button or menu. |
Value | The chosen menu option's value (empty for buttons). |
Message | Your message the component was on (Message.Id, Message.Text). |
EditMessageAsync(text) | Changes that message's text, e.g. to update a tally. |
ReplyAsync(text, components...) | Answers as a reply to that message. |
Anyone in the room can click, as many times as they like: decide in your handler who may do what (for example, only
the person who ran the command), and count one vote per User.
Reading room messages
bot.OnMessage(async msg =>
{
if (msg.Text.Contains("http://", StringComparison.OrdinalIgnoreCase))
{
await msg.Bot.SendAsync(msg.Room, msg.Channel, $"{msg.UserName}, please share https links.");
}
});
OnMessage gets ordinary messages in rooms the bot is in, from other people, sent after the bot started.
MessageContext has Text, Message, the usual User, Room
and Channel, and DeleteAsync() (which needs the Delete other people's messages
permission). A bot that reads chat can read everything in the channels it is in, so say so in its
Description.
Direct messages
bot.OnDirectMessage(dm => dm.ReplyAsync($"Hi {dm.FromName}! Add me to a room and type /help there."));
// Or start one yourself:
await bot.SendDirectAsync(someUserId, "Your report is ready.");
DirectMessageContext has From (a UserId), FromName, Text
and ReplyAsync(text).
Doing things on your own
Outside a handler, use the JawBot methods with a room id you got from a context or from
bot.Rooms. Room ids can be saved as text (room.ToString()) and read back with
RoomId.Parse(text).
// Post to every room the bot is in, e.g. from a timer.
foreach (var room in bot.Rooms)
{
await bot.SendAsync(room, "main", "Good morning! ☀️");
}
await bot.EditAsync(room, messageId, "New text"); // the bot's own messages
await bot.DeleteAsync(room, messageId); // its own, or others' with the permission
await bot.JoinPublicRoomAsync(RoomId.Parse(text)); // a public room listed on the bot's home node
Anything the room or node refuses throws a BotException whose message says why (for example, the bot
may not send in that channel). Calling these before RunAsync has started throws
InvalidOperationException.
Permissions
Set RequestedPermissions to what your bot needs, combined with |, and ask for as little as
you can: operators are shown the list, and may grant less. Most bots need only SendMessages.
RequestedPermissions = RoomPermissions.SendMessages | RoomPermissions.ManageMessages,
| Value | What people see | Applies |
|---|---|---|
RoomPermissions.SendMessages | Send messages | Per channel |
RoomPermissions.Invite | Invite people | Room-wide |
RoomPermissions.ManageMessages | Delete other people's messages | Per channel |
RoomPermissions.Kick | Remove people | Room-wide |
RoomPermissions.Ban | Ban people | Room-wide |
RoomPermissions.ManageChannels | Create and delete channels | Room-wide |
RoomPermissions.ManageRoles | Manage roles | Room-wide |
RoomPermissions.ManageRoom | Change the room's name and description | Room-wide |
RoomPermissions.EditOwnMessages | Edit its own messages | Per channel |
RoomPermissions.DeleteOwnMessages | Delete its own messages | Per channel |
RoomPermissions.ManageChannelPermissions | Set channel permissions | Per channel |
RoomPermissions.ManageChannel | Rename channels | Per channel |
RoomPermissions.ViewChannel | Read channels | Per channel |
RoomPermissions.Administrator | Do everything (administrator) | Everywhere |
"Per channel" permissions can be changed for one channel by the room's operators, so a bot may be able to speak in one channel and not another.
How events are delivered
- One at a time, in order. Handlers never run at the same time, so you don't need locks for state only
handlers touch. Keep them quick: start long work without awaiting it (as in the
/remindexample) so other events aren't held up. Calling the bot's methods from your own tasks and timers is fine too. - Bounded. Up to 1,000 events wait in line; if your handlers fall further behind, the oldest are dropped.
- Once each. Each event reaches your handlers once, even if it arrives again after a reconnect.
- Not stale. Commands and clicks older than
MaxEventAgeare skipped, so a bot that was offline doesn't answer an hour-old/roll. Room messages are delivered only from when the bot started. - Failures are contained. An exception in a handler is logged and the bot carries on.
- Reconnects by itself. If the connection drops, it reconnects with a growing delay, and catches up on rooms and direct messages when it's back.
Limits
| What | Limit |
|---|---|
| Message text | 4,000 characters |
| Commands per bot / options per command | 50 / 10 |
| Command and option names | 1-32 of a-z 0-9 _ - |
| Command and option descriptions | 100 characters |
| Components per message | 10 |
| Component ids / labels | 32 / 80 characters |
| Menu options / option values | 25 / 100 characters |
| Bot name / description / developer / homepage | 64 / 1,000 / 100 / 200 characters |
| Events waiting for handlers | 1,000 (oldest dropped) |
Nodes may also rate-limit busy senders; bots get higher limits than people.
Keeping your bot safe
- The data folder is the account. Anyone with a copy of
DataDirectorycan act as your bot. Keep it readable only by the bot's user, back it up, and never commit it to source control. - Prefer a passphrase from a secret store (
Passphrase), so a copy of the disk alone isn't enough. - Keep the recovery phrase offline. It can rebuild the account if the folder is lost.
- Don't log what people write. The SDK logs ids only, never message contents or keys; keep your own logging the same way.
- Treat input as untrusted. Command options, menu values and messages come from anyone in the room. Check them before using them, especially before running anything or calling another service.
- Ask for little. Request only the permissions you use.
Logging
Pass any ILogger<JawBot> (from Microsoft.Extensions.Logging) as the second argument:
new JawBot(options, logger). Event ids:
| Id | Level | Meaning |
|---|---|---|
| 5600 | Information | Created the bot's account (first run). |
| 5601 | Information | Connected to its home node. |
| 5602 | Information | Disconnected; reconnecting. |
| 5603 | Warning | Couldn't connect to a node (the reason is included). |
| 5604 | Warning | Couldn't register with the node. |
| 5605 | Warning | No home node set and none reachable from the starting list. |
| 5606 | Information | Published the bot profile. |
| 5607 | Warning | The requested handle is taken. |
| 5608 | Error | A handler threw (the exception is attached). |
| 5609 | Warning | Couldn't accept an invite to a room. |
With the generic host you can inject it: services.AddLogging() then
provider.GetRequiredService<ILogger<JawBot>>().
Running it for real
Publish it and run it as a service. On Linux with systemd:
dotnet publish -c Release -o /opt/mybot
# /etc/systemd/system/mybot.service
[Unit]
Description=MyBot (Jaw bot)
After=network-online.target
[Service]
User=mybot
WorkingDirectory=/var/lib/mybot
ExecStart=/usr/bin/dotnet /opt/mybot/MyBot.dll
Environment=JAW_BOT_DATA=/var/lib/mybot/data
Restart=on-failure
[Install]
WantedBy=multi-user.target
Run one copy per data folder. The bot's account can be used from a limited number of devices, and each data folder is one device.
API reference
JawBot
| Member | What it does |
|---|---|
JawBot(JawBotOptions options, ILogger<JawBot>? logger = null) | Makes a bot. Nothing happens until RunAsync. |
Command(name, description, handler, params BotCommandOption[] options) | Adds a slash command. Returns the bot, so calls can be chained. |
OnInteraction(prefix, handler), OnInteraction(handler) | Handles clicks on the bot's buttons and menus. |
OnMessage(handler) | Handles ordinary messages in the bot's rooms. |
OnDirectMessage(handler) | Handles direct messages to the bot. |
RunAsync(CancellationToken) | Opens or creates the account, connects, and runs until cancelled. Throws BotException if the keys can't be opened (wrong passphrase) or the account has no room for another device. |
SendAsync(room, channel, text, components = null, replyTo = null) | Posts a message, optionally with components, optionally as a reply to a message id. |
EditAsync(room, messageId, text) | Changes one of the bot's messages. |
DeleteAsync(room, messageId) | Deletes a message. |
SendDirectAsync(user, text) | Sends a direct message. |
JoinPublicRoomAsync(room) | Joins a public room listed on the home node. |
NameOf(user), RoomNameOf(room) | Display names, for messages. |
Id | The bot's UserId (once running). |
IsConnected | Whether it's connected and registered right now. |
Rooms | Rooms it is a member of. |
Profile | The bot profile it published. |
DisposeAsync() | Closes the connection and releases the keys; use await using. |
Contexts
| Type | Passed to | Members |
|---|---|---|
CommandContext | Command | Name, Arguments, Options, Option(name), Message, ReplyAsync, plus the common members |
InteractionContext | OnInteraction | ComponentId, Value, Message, Interaction, ReplyAsync, EditMessageAsync, plus the common members |
MessageContext | OnMessage | Text, Message, DeleteAsync, plus the common members |
DirectMessageContext | OnDirectMessage | Bot, From, FromName, Text, ReplyAsync |
Common members (room contexts): Bot, Room, RoomName, Channel,
User, UserName, SendAsync(text), SendAsync(text, components...).
Namespaces
Jaw.Bots (the bot, options, contexts, BotException), Jaw.Core.Bots
(BotCommandOption), Jaw.Core.Rooms (RoomId, RoomPermissions,
MessageComponent, ButtonStyle, SelectOption), Jaw.Core.Identity
(UserId).
Full example: DiceBot
The example in the bot kit (DiceBot/Program.cs): dice, a coin and yes/no polls with live tallies.
The main part:
var options = new JawBotOptions
{
DataDirectory = Environment.GetEnvironmentVariable("JAW_BOT_DATA") ?? "dicebot-data",
Name = "DiceBot",
Description = "Rolls dice, flips coins and runs quick yes/no polls.",
Developer = "Jaw example",
Tags = ["games", "polls"],
RequestedPermissions = RoomPermissions.SendMessages,
Handle = Environment.GetEnvironmentVariable("JAW_BOT_HANDLE"),
Node = Environment.GetEnvironmentVariable("JAW_NODE") is { Length: > 0 } node ? new Uri(node) : null,
OnRecoveryPhrase = words => Console.WriteLine("Recovery phrase: " + string.Join(' ', words)),
};
await using var bot = new JawBot(options, new ConsoleLogger());
var polls = new ConcurrentDictionary<string, Poll>();
bot.Command("roll", "Roll dice, e.g. /roll 2d6", async ctx =>
{
if (!Dice.TryParse(ctx.Option("dice") ?? "1d6", out var count, out var sides))
{
await ctx.ReplyAsync("Try something like /roll 2d6 (up to 20 dice with up to 1000 sides).");
return;
}
var rolls = Enumerable.Range(0, count).Select(_ => RandomNumberGenerator.GetInt32(1, sides + 1)).ToList();
var detail = rolls.Count == 1 ? "" : $"{string.Join(" + ", rolls)} = ";
await ctx.ReplyAsync($"🎲 {ctx.UserName} rolled {count}d{sides}: {detail}{rolls.Sum()}");
}, new BotCommandOption("dice", "How many and which, e.g. 2d6", false));
bot.Command("coin", "Flip a coin", ctx => ctx.ReplyAsync(RandomNumberGenerator.GetInt32(2) == 0 ? "🪙 Heads" : "🪙 Tails"));
bot.Command("poll", "Ask a yes/no question", async ctx =>
{
await ctx.ReplyAsync(Poll.Render(ctx.Option("question") ?? "", 0, 0),
MessageComponent.Button("poll:yes", "Yes", ButtonStyle.Primary),
MessageComponent.Button("poll:no", "No"));
}, new BotCommandOption("question", "What to ask", true));
// One vote per person; voting again moves it.
bot.OnInteraction("poll:", async click =>
{
var poll = polls.GetOrAdd(Convert.ToHexString(click.Message.Id), _ => new Poll(click.Message.Text));
poll.Vote(click.User.ToString(), click.ComponentId == "poll:yes");
await click.EditMessageAsync(Poll.Render(poll.Question, poll.Yes, poll.No));
});
bot.OnDirectMessage(dm => dm.ReplyAsync("Hi! Add me to a room and type /help there."));
await bot.RunAsync(stop.Token);
Run it from the kit with cd DiceBot then dotnet run. Settings come from
JAW_BOT_DATA, JAW_NODE and JAW_BOT_HANDLE.
Troubleshooting
| You see | What to do |
|---|---|
The bot's keys couldn't be opened: wrong passphrase. | The Passphrase doesn't match the one the data folder was made with. Use the original, or start a new bot with an empty folder. |
…already has the maximum number of devices… | Each data folder is a device of the bot's account. Remove an old one, or don't copy the folder to run two bots. |
| Log 5605: no home node | Set Node (e.g. wss://chronocloned.net/ws/client), or check the machine can reach the internet. |
This node doesn't support unlisted bots yet… | The bot has Listed = false but its home node is older. Set Node to a newer node, or wait for this one to update. |
| Log 5607: handle taken | Pick another Handle. The bot still works; people find it in the bot directory. |
| Commands don't show up | Commands are published when the bot connects: restart it after adding one, and make sure it's in the room (the operator approved it). |
BotException when sending | The room refused it, usually a missing permission in that channel. The message says which. |