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.

Want an app people use through screens, hosted on a node and written in JavaScript instead? See the NodeApp API.

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.23

33.8 MB · SHA-256 d63e721265da9a9bf6442a407c125d982111a80492d30fb9cfd42e8fb29e2fbe

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.23

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://jaw.network/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: your @handle 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://jaw.network/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, clicks, messages and room events older than this (sent while the bot was offline) are skipped.
LinkPreviewsfalseMake link cards for links in the bot's messages, as the apps do. The bot's machine then fetches those pages (never local or private addresses).
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 while it runs (or while it was offline, up to MaxEventAge ago), text and files alike (see Files). 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, MessageId, ReplyAsync(text) and ThisChat. bot.Direct(user) (or ThisChat) is the whole conversation: Messages, SendAsync, EditAsync and DeleteAsync for the bot's own messages, and SetLinkPreviewsAsync.

bot.OnDirectChange(change =>
{
    // change.Kind is Edited, Deleted or LinkCards; change.Before is the message as it was, change.Text the text now.
    return change.Kind == DirectChangeKind.Edited ? change.ReplyAsync($"Noted: \"{change.Text}\"") : Task.CompletedTask;
});

OnDirectChange gets edits and deletes of direct messages people sent the bot, and changes to their link cards. DirectChangeContext has From, FromName, Kind, MessageId, Before, Text, Links, ThisChat and ReplyAsync.

Everything else in a room

A bot can do whatever a member can (#89): ctx.ThisRoom in a handler, or bot.Room(roomId) anywhere, gives the room's live state and every action. The room's rules decide, as for people: a refusal throws BotException with the reason, and the bot only has the permissions its role in that room gives it.

// A bot that sets up its own community room.
var room = await bot.CreateRoomAsync("Book club", "one chapter a week", invite: [memberId]);
var news = await room.CreateChannelAsync("Announcements");
await room.SetChannelPermissionsAsync(news, RoomRoleDefinition.Everyone, RoomPermissions.None, RoomPermissions.SendMessages);
await room.UpsertRoleAsync("host", "Host", 0x22AA66, RoomPermissions.SendMessages | RoomPermissions.ManageMessages);
await room.AssignRolesAsync(memberId, ["host"]);

// Reading it: members and roles, channels, messages, owners, bans.
var state = room.State!;
foreach (var (member, roles) in state.Members) { /* ... */ }
var recent = room.Messages(RoomChannel.MainId).TakeLast(20);

Members: InviteAsync, KickAsync, BanAsync, UnbanAsync, LeaveAsync, AcceptInviteAsync, ApproveBotAsync (add another bot). The room: UpdateAsync (name, topic, description, tags), SetPictureAsync (any image; members see it, and search too for public rooms), CreateChannelAsync, UpdateChannelAsync, DeleteChannelAsync, PinAsync (node owners). Roles and permissions: UpsertRoleAsync, DeleteRoleAsync, MoveRoleAsync, AssignRolesAsync, SetChannelPermissionsAsync, SetUserPermissionsAsync, PermissionsIn(channel). Owners: AppointOwnerAsync, RemoveOwnerAsync, CancelOwnerRemovalAsync, StepDownAsync, PurgeJunkAsync. History: Messages(channel), LoadOlderAsync(channel). Link cards: SetLinkPreviewsAsync.

Files, images and voice clips

bot.OnMessage(async msg =>
{
    foreach (var file in msg.Files)
    {
        var bytes = await msg.DownloadAsync(file); // decrypted; null if it can't be fetched right now
        await msg.ReplyAsync($"{file.Name}: {bytes?.Length ?? 0} bytes");
    }
});

await bot.Room(roomId).SendFileAsync(RoomChannel.MainId, "report.pdf", "application/pdf", pdfBytes, caption: "This week's report");

A voice clip is an Ogg Opus file (audio/ogg); the apps show it with a player. Images may carry a small thumbnail and their width and height. Files are encrypted before they leave the bot, like messages.

Voice calls

Bots can answer calls, ring people and join voice channels, with the same encryption as people: every audio frame is encrypted on the bot with keys only the people in the call hold, so the node hosting the call hears nothing. A bot can be in voice channels in several rooms at once and in one direct call, up to MaxCalls (default 10) in all.

// Answer calls (without a handler, the bot declines them).
bot.OnCall(async incoming =>
{
    var call = await incoming.AnswerAsync();          // or: await incoming.DeclineAsync();
    _ = EchoAsync(call);                              // don't hold up other events
});

static async Task EchoAsync(BotCall call)
{
    await foreach (var frame in call.Frames.ReadAllAsync())
    {
        call.Send(frame.Opus);                        // says back what it hears
    }
}

// Ring someone (only people who talked to the bot first; see below).
var call = await bot.CallAsync(userId);

// Join a room's voice channel.
var lounge = await bot.Room(roomId).JoinVoiceAsync(channelId);
await lounge.HangUpAsync();

Audio is Opus. Send takes one Opus frame of 20 ms at 48 kHz, as any Opus encoder makes it (for example the Concentus package); call it every 20 ms while speaking. Frames gives you what others say as Opus frames, with Speaker saying who. To play a file, decode it to 48 kHz PCM and encode 20 ms at a time.

Who a bot may ring. People's apps only ring for a bot if that person messaged it directly, used one of its commands or clicked one of its buttons; otherwise the call is declined and CallAsync throws BotException("Declined."). Anyone may call a bot, so check incoming.Caller if it matters.

Voice channels follow the room's rules: the bot needs Connect to join and Speak to be heard. Members have both by default; a bot added to a room has them only if they were approved (ask for them in RequestedPermissions). Room owners can take either away from a bot, or from all bots through a role. Without Speak, CanSpeak is false and Send drops frames; losing Connect ends the call (Ended completes with the reason).

Networking. Calls use UDP to the node hosting the call (port 50000 on most nodes). The bot sends from any local port; a firewall that blocks outgoing UDP stops calls (the call fails with "The connection was lost."). If the other person's node hosts the call, the bot connects there without asking, which shows that node the bot's IP address.

Worktrees (shared files)

Worktree channels hold a room's shared files and folders, encrypted like messages. A bot reads and changes them like a member, within its permissions in that channel: Edit files to add and change files, Delete files to delete, Manage files for locked ones.

var room = bot.Room(roomId);
var files = room.Worktree(room.Worktrees[0].Id);          // or the channel id you know

foreach (var item in files.Items)                         // folders before what's in them
{
    Console.WriteLine(files.PathOf(item));                // docs/notes.md
}

if (files.Find("docs/notes.md") is { } notes)
{
    var text = await files.ReadTextAsync(notes);          // with everyone's live edits
    await files.WriteTextAsync("docs/notes.md", text + "\nChecked by the bot.\n");
    await files.NameVersionAsync(notes, "Checked");       // like a commit message
}

await files.WriteFileAsync("reports/2026/week-45.pdf", pdfBytes);   // folders are made as needed
await files.MoveAsync(files.Find("reports/2026/week-45.pdf")!, "reports/latest.pdf");
await files.DeleteAsync(files.Find("old.txt")!);           // needs Delete files; RestoreAsync brings it back

Text files are edited, not replaced. WriteTextAsync on an existing text file sends only the characters that changed, as ordinary edits, so people editing the same file live keep their cursors and their typing. Other files get a new whole version. Paths are the names people see, joined with /; two items with the same name in a folder are told apart as the apps show them (notes (2).md).

Hearing about changes. Worktree changes arrive in OnRoomEvent (kinds WorktreeChange, TextEdit, TextSplit, VersionLabel); e.WorktreeItem says which file or folder.

bot.OnRoomEvent(async e =>
{
    if (e.WorktreeItem is { Type: WorktreeItemType.File } file && file.Name.EndsWith(".csv"))
    {
        var files = e.ThisRoom.Worktree(e.Channel!);
        var rows = await files.ReadTextAsync(file);
        // ...
    }
});

In a private room, a bot reads from when it joined, like people who join later: files added before then may not be visible to it. MaintainAsync() does what an app with the worktree open does (snapshots, keeping old files alive), for bots that look after a worktree. room.CreateWorktreeAsync(name) adds a worktree channel (needs Manage channels).

NodeApps

Bots use NodeApps as people do: an app runs on its node and sends screens, and the bot reads them and presses their buttons. bot.Apps has the home node's own apps and advertised apps; rooms have app channels and nodeapp:// embeds. Every open app is a BotAppSession: Texts and Buttons to read the screen, PressAsync(button, values) for the next one. Dispose it when done.

// One of the home node's apps.
var apps = await bot.Apps.ListAsync();
await using var counter = await bot.Apps.OpenAsync(apps[0].App);
Console.WriteLine(string.Join(" / ", counter.Texts));               // Count: 0
await counter.PressAsync("add", new Dictionary<string, InputValue> { ["step"] = InputValue.Of(3L) });

// An advertised app, by its AppID (checked like a handle), through forwarders.
if (await bot.Apps.FindAsync("nodeapp://weather") is { } weather)
{
    await using var session = await bot.Apps.OpenAsync(weather);
    await session.PressAsync("show", new Dictionary<string, InputValue> { ["town"] = InputValue.Of("Oslo") });
}

// In a room: an app channel (needs Manage apps), or an embed in a message (needs Embed apps).
var room = bot.Room(roomId);
var channel = await room.CreateAppChannelAsync("poll", pollApp, SessionMode.RoomPseudonyms);
await using var poll = await room.OpenAppAsync(channel);
await room.SendAppAsync(RoomChannel.MainId, "counter", SessionMode.Room, "Who can get it highest?");
await using var embed = await room.OpenEmbedAsync(RoomChannel.MainId, messageId);

Who learns what. Advertised apps are reached as the apps reach them: from a throwaway account, through the bot's node and two others to the app's forwarder. The app's node never learns which bot it is or which node it uses. The bot's own apps on its home node know it's this account, as for people.

Bots that own their node (see Bots that run a node) also manage its apps: PreviewAsync(package) shows what a .JawApp asks for, then InstallAsync, RemoveAsync, SetListedAsync(app, listed, appId) (the Apps tab, optionally with an AppID) and SetNetworkAsync(app, on). Other bots get a BotException.

A room app's state belongs to the room's key. In a private room, someone who joins later gets keys from their invite on, so the session moves to the new key: the next member who holds both keys carries it over when they open the app.

Room events

bot.OnRoomEvent(async e =>
{
    if (e.Kind == RoomEventKind.Join)
    {
        await e.Bot.SendAsync(e.Room, RoomChannel.MainId, $"Welcome, {e.UserName}!");
    }
    else if (e.Kind == RoomEventKind.Edit && e.TargetMessage is { } edited)
    {
        // edited.Text is the new text
    }
});

OnRoomEvent gets everything the room accepted from someone else, as people see it in the apps: joins, leaves, invites, edits, deletes, files, role, channel and permission changes, owner changes. RoomEventContext has Event, Kind, State (the room right after it), TargetMessage (for edits, deletes, clicks and link cards) and WorktreeItem (for worktree changes). Events the room refused never arrive.

The bot's profile, and finding people

await bot.Me.SetProfileAsync(displayName: "DiceBot", statusMessage: "Type /roll");
await bot.Me.SetPresenceAsync(ProfileMessages.PresenceChoice.Busy);

var someone = await bot.People.FindHandleAsync("@someone");  // who holds a handle
var profile = await bot.People.ProfileAsync(someone!);       // their display name, status, picture
var rooms = await bot.People.SearchRoomsAsync("book club");  // public rooms
var helpers = await bot.People.SearchBotsAsync("polls");     // other bots

bot.Me.SetPictureAsync(imageBytes) takes any common image (JPEG, PNG, BMP, GIF...) and makes the 128×128 PNG profiles need from its centre square, or from the square you pass as crop; BotPicture.FromImage does the same on its own. The display name can change any time; the Handle option is the @handle, which can't change once it's locked in.

Bots that run a node

A node's owners are the accounts its operator lists (Jaw:Owners). If that includes the bot, bot.Node does what owners do in the apps' Settings:

if (await bot.Node.IsOwnerAsync())
{
    await bot.Node.SetProfileAsync("Makers' node", "For people who make things", picture: logoBytes);
    await bot.Node.FeatureBotAsync(helperBotId, true);   // shown above bot search
    await bot.Node.PinRoomAsync(welcomeRoomId, true);    // shown first in the room directory

    var settings = await bot.Node.GetSettingsAsync();    // storage budgets, usage, suggestions
    var update = await bot.Node.GetUpdateAsync();        // how it updates, what it runs, what's available
    if (update?.Available is not null) await bot.Node.InstallUpdateAsync();
}

For bots that aren't owners, the node refuses these (BotException), and GetSettingsAsync and GetUpdateAsync return null.

Doing things on your own

Outside a handler, use the JawBot methods (and bot.Room(id), bot.Direct(user), bot.Me, bot.People) 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.EditFilesAdd and edit files in worktreesPer channel
RoomPermissions.DeleteFilesDelete files in worktreesPer channel
RoomPermissions.ManageFilesLock and unlock files in worktreesPer channel
RoomPermissions.ConnectJoin voice channelsPer channel
RoomPermissions.SpeakSpeak in voice channelsPer channel
RoomPermissions.ManageAppsAdd app channelsRoom-wide
RoomPermissions.EmbedAppsPut apps in messagesPer 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.

A bot gets only what was approved. Members can edit and delete worktree files, and connect and speak in voice channels, by default; a bot added to a room can't unless those were approved (or an operator allows them in a channel). A bot that joined a public room by itself has what every member has until it's approved.

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.
OnRoomEvent(handler)Handles everything that happens in the bot's rooms (see Room events).
AppsA BotApps: the home node's apps, advertised apps through forwarders, and owners' app controls (see NodeApps).
Room(roomId)A BotRoom: the room's state and everything a member can do there (see Everything else in a room).
CreateRoomAsync(name, topic, kind, visibility, invite, description, tags)Creates a room with the bot as owner.
Direct(user)A BotConversation: history, send, edit, delete.
MeThe bot's own profile and presence.
PeopleHandles, profiles, presence, public rooms and other bots.
OnDirectChange(handler)Handles edits and deletes of direct messages sent to the bot.
NodeThe home node, and what its owners can do (see Bots that run a node).
OnCall(handler)Handles incoming calls: IncomingCall has Caller, CallerName, AnswerAsync(), DeclineAsync() (see Voice calls).
CallAsync(user)Rings someone who talked to the bot; returns the BotCall once they answer.
Calls, DirectCall, VoiceCall(room, channel)The calls the bot is in: all of them, its direct call, or its call in one voice channel.
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, Files, DownloadAsync, ReplyAsync, DeleteAsync, plus the common members
DirectMessageContextOnDirectMessageBot, From, FromName, Text, ReplyAsync, MessageId, ThisChat
DirectChangeContextOnDirectChangeBot, From, FromName, Kind, MessageId, Before, Text, Links, ThisChat, ReplyAsync

Common members (room contexts): Bot, Room, RoomName, Channel, User, UserName, SendAsync(text), SendAsync(text, components...), ThisRoom. RoomEventContext (OnRoomEvent) adds Event, Kind, State, TargetMessage, WorktreeItem.

BotWorktree

From BotRoom.Worktree(channel) (BotRoom.Worktrees lists the worktree channels; CreateWorktreeAsync(name) adds one). State, Items, Children(folder), Deleted, Find(path), PathOf(item), IsText(file), ReadAsync(file), ReadTextAsync(file), CreateFolderAsync(path), WriteTextAsync(path, text), WriteFileAsync(path, bytes, mimeType?), MoveAsync(item, newPath), DeleteAsync, RestoreAsync, LockAsync, UnlockAsync, NameVersionAsync(file, name), MaintainAsync(). Refusals throw BotException.

BotCall

Send(opusFrame) (false if dropped), Frames (a channel of VoiceFrame(Speaker, Opus); the oldest are dropped past 500), Participants, CanSpeak, Peer (direct calls), Room and ChannelId (voice channels), Ended (completes with why), HangUpAsync(). BotRoom.JoinVoiceAsync(channel) joins a voice channel.

Namespaces

Jaw.Bots (the bot, options, contexts, BotException), Jaw.Core.Bots (BotCommandOption), Jaw.Core.Rooms (RoomId, RoomPermissions, MessageComponent, ButtonStyle, SelectOption), Jaw.Core.Worktrees (WorktreeItem, WorktreeItemType), Jaw.Core.Identity (UserId), Jaw.Core.Files (FileAttachment), Jaw.Core.Protocol (ProfileMessages.PresenceChoice).

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://jaw.network/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.