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.
- 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.23
33.8 MB · SHA-256 d63e721265da9a9bf6442a407c125d982111a80492d30fb9cfd42e8fb29e2fbe
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.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
- 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://jaw.network/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: your @handle 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://jaw.network/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, clicks, messages and room events older than this (sent while the bot was offline) are skipped. |
LinkPreviews | false | Make 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). |
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 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,
| 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.EditFiles | Add and edit files in worktrees | Per channel |
RoomPermissions.DeleteFiles | Delete files in worktrees | Per channel |
RoomPermissions.ManageFiles | Lock and unlock files in worktrees | Per channel |
RoomPermissions.Connect | Join voice channels | Per channel |
RoomPermissions.Speak | Speak in voice channels | Per channel |
RoomPermissions.ManageApps | Add app channels | Room-wide |
RoomPermissions.EmbedApps | Put apps in messages | 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.
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
- 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. |
OnRoomEvent(handler) | Handles everything that happens in the bot's rooms (see Room events). |
Apps | A 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. |
Me | The bot's own profile and presence. |
People | Handles, profiles, presence, public rooms and other bots. |
OnDirectChange(handler) | Handles edits and deletes of direct messages sent to the bot. |
Node | The 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. |
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, Files, DownloadAsync, ReplyAsync, DeleteAsync, plus the common members |
DirectMessageContext | OnDirectMessage | Bot, From, FromName, Text, ReplyAsync, MessageId, ThisChat |
DirectChangeContext | OnDirectChange | Bot, 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 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://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 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. |