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.

Get the SDK

Jaw API 0.0.5

1.1 MB · SHA-256 522afa87c83cc2f3785fe7350f81e0dbf3221782322b981f2b07fefe001083ca

Download the Jaw API

It 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:

PathWhat 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.configPoints projects in the kit's folder at packages/.
README.mdThe 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

  1. Account. On the first run the bot creates its account in DataDirectory and calls OnRecoveryPhrase once 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.
  2. 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"),
  3. 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.
  4. 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 = false to turn that off).
  5. 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:

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.

OptionDefaultWhat 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.
DescriptionemptyWhat it does, shown in the bot directory and the approval screen. Up to 1,000 characters.
DeveloperemptyWho makes it, e.g. @frank or a project name. Up to 100 characters.
HomepageemptyAn https:// page about the bot. Up to 200 characters.
TagsnoneTags for the bot directory, e.g. ["games", "polls"].
RequestedPermissionsSendMessagesWhat it asks for when added to a room (see Permissions). Operators may grant less.
HandlenoneAn @handle to claim, e.g. "dicebot". Skipped (with a log line) if someone else holds it.
ListedtrueWhether 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).
NodepickedThe home node's client endpoint, e.g. wss://chronocloned.net/ws/client.
PassphrasegeneratedEncrypts 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.
OnRecoveryPhrasenoneCalled once, when the account is created, with its 24 recovery words.
AcceptInvitestrueJoin rooms the bot is invited to.
MaxEventAge10 minutesCommands and clicks older than this (sent while the bot was offline) are skipped.
ReconnectDelay2 secondsWait 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:

MemberWhat it is
NameThe command, without the slash.
ArgumentsEverything 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, UserNameWho used it, and their display name (self-chosen, not unique: use User to tell people apart).
Room, RoomName, ChannelWhere (Channel is main in single-channel rooms).
MessageThe signed command event itself.
BotYour 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"),
    ]));

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:

MemberWhat it is
ComponentIdThe id you gave the button or menu.
ValueThe chosen menu option's value (empty for buttons).
MessageYour 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,
ValueWhat people seeApplies
RoomPermissions.SendMessagesSend messagesPer channel
RoomPermissions.InviteInvite peopleRoom-wide
RoomPermissions.ManageMessagesDelete other people's messagesPer channel
RoomPermissions.KickRemove peopleRoom-wide
RoomPermissions.BanBan peopleRoom-wide
RoomPermissions.ManageChannelsCreate and delete channelsRoom-wide
RoomPermissions.ManageRolesManage rolesRoom-wide
RoomPermissions.ManageRoomChange the room's name and descriptionRoom-wide
RoomPermissions.EditOwnMessagesEdit its own messagesPer channel
RoomPermissions.DeleteOwnMessagesDelete its own messagesPer channel
RoomPermissions.ManageChannelPermissionsSet channel permissionsPer channel
RoomPermissions.ManageChannelRename channelsPer channel
RoomPermissions.ViewChannelRead channelsPer channel
RoomPermissions.AdministratorDo 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

Limits

WhatLimit
Message text4,000 characters
Commands per bot / options per command50 / 10
Command and option names1-32 of a-z 0-9 _ -
Command and option descriptions100 characters
Components per message10
Component ids / labels32 / 80 characters
Menu options / option values25 / 100 characters
Bot name / description / developer / homepage64 / 1,000 / 100 / 200 characters
Events waiting for handlers1,000 (oldest dropped)

Nodes may also rate-limit busy senders; bots get higher limits than people.

Keeping your bot safe

Logging

Pass any ILogger<JawBot> (from Microsoft.Extensions.Logging) as the second argument: new JawBot(options, logger). Event ids:

IdLevelMeaning
5600InformationCreated the bot's account (first run).
5601InformationConnected to its home node.
5602InformationDisconnected; reconnecting.
5603WarningCouldn't connect to a node (the reason is included).
5604WarningCouldn't register with the node.
5605WarningNo home node set and none reachable from the starting list.
5606InformationPublished the bot profile.
5607WarningThe requested handle is taken.
5608ErrorA handler threw (the exception is attached).
5609WarningCouldn'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

MemberWhat 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.
IdThe bot's UserId (once running).
IsConnectedWhether it's connected and registered right now.
RoomsRooms it is a member of.
ProfileThe bot profile it published.
DisposeAsync()Closes the connection and releases the keys; use await using.

Contexts

TypePassed toMembers
CommandContextCommandName, Arguments, Options, Option(name), Message, ReplyAsync, plus the common members
InteractionContextOnInteractionComponentId, Value, Message, Interaction, ReplyAsync, EditMessageAsync, plus the common members
MessageContextOnMessageText, Message, DeleteAsync, plus the common members
DirectMessageContextOnDirectMessageBot, 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 seeWhat 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 nodeSet 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 takenPick another Handle. The bot still works; people find it in the bot directory.
Commands don't show upCommands 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 sendingThe room refused it, usually a missing permission in that channel. The message says which.