Skip to main content

@tavrn/bot – bots for tavrn

@tavrn/bot is the bot runtime for Node.js (20 or newer). It turns an app token into a running bot that:

  • keeps the bot’s identity in a key file and its encryption state in a state directory,
  • is a full end-to-end encryption member: it reads and writes encrypted channels like any device,
  • delivers messages (decrypted) to your code and sends replies (encrypted where needed),
  • answers commands (/weather berlin) with replies, “thinking…” plus follow-ups, or replies only the invoker sees,
  • reacts, edits, pins, attaches files, keeps files channels, starts threads, runs polls, moderates, opens DMs and shows its presence through typed helpers (“What a bot can do”, the full table in docs/COVERAGE.md),
  • follows its installations to other nodes (federation) on its own and waits out rate limits.

The protocol behind it is APPLICATIONS.md (§2 tokens, §3 bot devices and encryption, §4 installation, §5 commands, §6.3 hook requests). The package builds on @tavrn/sdk (API client, WebSocket) and @tavrn/e2ee (MLS session manager, key storage). License: Apache-2.0 (your bot may use any license; the MPL-2.0 files of @tavrn/crypto and @tavrn/e2ee stay MPL-2.0).

  1. Create an application. In the web client: settings → Applications → New application. Choose a name, a username for the bot (its address becomes <username>@<your node>), whether anybody may add it (public) and the permissions it asks for. The bot account is created with it.
  2. Create a token. In the application, Tokens → New token. The secret is shown once: keep it like a password (an environment variable or a secret store, never in the code). Every token is a separate device of the bot (at most 5, e.g. one per shard or for staging); its scopes cap what it may do at all.
  3. Add the bot to a room. Open the application’s add link (https://<node>/add/<application id>, shown in the application) and pick a room where you hold Manage room. The consent screen shows the permissions; the bot joins with a role of exactly the confirmed ones. Users of other nodes can open the link in their own client (/add/<domain>/<id>).
  4. Run the bot.
import { createBot } from "@tavrn/bot";
const bot = createBot({
node: "https://node.example", // home node of the bot (URL or domain)
token: process.env.TAVRN_BOT_TOKEN!, // the app token
keyFile: process.env.TAVRN_BOT_KEY_FILE, // default ./tavrn-key-<username>.txt
stateDir: process.env.TAVRN_BOT_STATE_DIR, // default ./.tavrn-state
});
bot.on("message", async (m) => {
if (m.mentionsMe) await m.reply(`You said: ${m.text}`);
});
bot.command("weather", async (i) => {
await i.reply(`Weather in ${String(i.args.city)}: sunny`);
});
await bot.start();
await bot.setCommands([{ name: "weather", description: "Forecast", args: [{ name: "city", description: "City", type: "string", required: true }] }]);
process.on("SIGTERM", () => void bot.stop().then(() => process.exit(0)));

The example in examples/echo-weather/ does exactly this and runs against a development node with just bot-example. examples/proposals/ is a community bot: /propose posts an embed, opens a thread, counts 👍 reactions, edits the proposal when it is accepted and shows its presence (pnpm --filter @tavrn/bot example:proposals).

Node.js 22 and newer have a global WebSocket. On Node.js 20 start with --experimental-websocket or pass WebSocketImpl (e.g. import WebSocket from "ws"). With ws the runtime also sets maxPayload (16 MiB), so a node cannot make the bot buffer a larger frame; the built-in WebSocket has no such option and refuses an oversized frame only after receiving it, so pass ws when the bot’s memory must be bounded strictly.

OptionEnvironment (example bot)Meaning
nodeTAVRN_BOT_NODEHome node: a URL (https://node.example, also an internal address such as http://tavrn:8080) or the domain. The node must be the one that issued the token.
tokenTAVRN_BOT_TOKENThe app token (typ = app).
keyTAVRN_BOT_KEYThe key file’s text or its key: value; takes precedence over keyFile.
keyFileTAVRN_BOT_KEY_FILEPath of the key file, default ./tavrn-key-<username>.txt. Created on the first start.
stateDirTAVRN_BOT_STATE_DIREncrypted local state, default ./.tavrn-state. One process and one token per directory.
joinPolicyTAVRN_BOT_JOIN (lazy)eager (default) or lazy joining of encryption groups, see below.
logger{ debug, info, warn, error }(message, fields); default JSON lines on stderr (TAVRN_BOT_DEBUG=1 adds debug).
autoDeferMsCommands are deferred automatically when the handler has not answered after this long (default 2000 ms; the node waits 3 s).
ghostPolicystrict (default) or accept: what the bot does about unknown devices in an encryption group, see “Unknown devices” below.
historyBackupfalse (default): no history backup on the node. true: back up the epoch keys (encrypted) and restore them into a fresh state directory. See “History backups” below.
rateLimit429 answers (APPLICATIONS.md §2.7): by default the runtime waits the announced Retry-After and repeats the request ({ retries: 3, maxWaitMs: 30000 }); false hands every 429 to your code.
requestTimeoutMsDeadline of one API request (default 30000 ms, the rateLimit waits on top): a node that does not answer fails the call with TavrnApiError network.timeout instead of stalling the bot. File transfers have 10 minutes.
stopTimeoutMsHow long stop() waits for running handlers and background work (default 30000 ms).
maxDownloadBytesLargest file the bot downloads (attachments, files channels; default 256 MiB): downloads are held in memory, a larger one fails with TavrnApiError response.too_large. A download also never reads more than the file’s declared size.

These variables belong to the bot process, not to the node.

EventPayload
readyBotInfo (address, application, device, home domain)
messageBotMessage: text, content, author, authorId, authorKind, mentionsMe, encrypted, conversation, roomId, threadId, attachments, editedAt, signature (plaintext: the author device’s signature check), reply(), replyInThread(), react(), download()
undecryptablean encrypted message the bot could not read (sent before it joined, unknown device, …)
install / uninstallthe bot was added to or removed from a room (node, roomId)
tokenExpiringthe token expires in 7 or 1 days
tokenRotatedrotateToken() produced a new secret
pollVotea member voted, changed or retracted a vote (conversation, messageId, user, record, retracted; also bot.onPollVote(fn))
reactiona reaction was added or removed: node, conversation, messageId, emoji, count (after the change), added, user (null while the node does not name it), encrypted; also bot.onReaction(fn)
messageEditsomebody edited a message: the new version as a BotMessage, decrypted
messageDeletea message was deleted (conversation, messageId)
memberJoin / memberLeavesomebody joined or left a room (roomId, userId, user; scope members:read)
threada thread was created, updated (title, archive, lock, counters) or deleted (action, threadId, conversation, thread)
dmRequestsomebody who is not the owner opened a DM with the bot (conversation, from); answer with acceptDm / declineDm
filesthe content of a folder of a files channel changed (conversation, roomId, channelId, folderId; null = top level); scope messages:read
eventevery WebSocket event of every node, unchanged
errorconnection ended (token_refused, replaced), a listener failed

bot.send(conversation, content, { replyTo, mentions }) sends text or a content object ({ type: "text", body, embeds }) to a conversation: conversation is the qualified id <node>/<id> of a received message (m.conversation) or the bare id of a conversation on the home node. In encrypted conversations the content is encrypted for the conversation’s group (the bot joins first if needed) together with its signed context (the thread and the reply the envelope carries, ENCRYPTION.md §5.2), so the node cannot move it into another thread or point it at another message; in unencrypted ones it is sent in plaintext, signed by the bot device. The bot’s own messages are not delivered back to it. Text content is checked before anything is sent or encrypted: a body of at most 4000 characters and valid embeds (EmbedsInvalidError, code message.embeds_invalid). { files } attaches files (below).

Threads (PROTOCOL.md §7e): m.threadId names the thread a message belongs to (null in the channel itself); m.reply() answers in the same thread, bot.send(conversation, content, { threadId }) writes into a thread, and m.replyInThread(content, { title? }) answers in the message’s thread, in the thread it already started, or in a new thread started from it (the title, encrypted in encrypted channels, defaults to the start of the message). Threads need no extra scope: reading falls under messages:read, writing under messages:write.

Polls (PROTOCOL.md §7g): a poll is a message, its votes are records next to it (one per member).

const poll = await bot.createPoll(m.conversation, {
kind: "choice", question: "Release today?", options: ["Yes", "No", "Tomorrow"], maxChoices: 1,
}, { closesAt: new Date(Date.now() + 3600_000) });
bot.onPollVote(async (e) => {
const { tally } = await bot.votes(e.conversation, e.messageId); // checked records, one vote per member
if (tally.voters >= 3) await bot.send(e.conversation, `Leading: ${tally.best.join(", ")}`);
});
await bot.vote(m.conversation, poll.id, { choices: ["o1"] }); // date polls: { answers: { s1: "yes" } }

createPoll takes a PollInput (or a ready { type: "poll", … } object) and needs the permission create_polls; encrypted polls and votes are sealed for the conversation’s group like messages, plaintext ones are signed by the bot device. votes() verifies every record against the voter’s device and the poll’s hash and leaves out stale and late votes; retractVote() takes the bot’s vote back. Voting needs messages:write, reading votes messages:read.

Every helper takes the conversation as bot.send does (a qualified id <node>/<id>, a bare id of the home node, or a received message/interaction); room helpers take a room the same way (m, "<node>/<room id>" or a bare id). Each names the scope it needs; the room permissions decide where. Inputs are checked with the protocol’s limits first (RangeError/TypeError, also for a malformed conversation id), refusals of the node arrive as TavrnApiError with its code (network.timeout when the node did not answer in time), and the runtime’s own failures as BotError (for example not_member, ghost, encryption_unavailable when the conversation’s encryption could not be brought up to date: nothing was sent, try again, or encryption_downgrade when the node reports an end-to-end encrypted conversation as unencrypted: nothing was sent). The complete list, with what the node keeps closed to bots, is docs/COVERAGE.md.

Reactions (messages:write to react, messages:read for the events). Reactions are plaintext metadata, also in encrypted channels. Every change arrives as a reaction event with the new count and the reactor (user, userId), also on messages the bot has never seen; the bot’s own reactions are not reported. reactors() lists who reacted with an emoji, page by page, for any message the bot may read (sign-ups, reaction polls, starboards):

bot.onReaction(async (r) => {
if (r.emoji === "👍" && r.added && r.count >= 3) await bot.send(r.conversation, "Three 👍: accepted!", { replyTo: r.messageId });
});
await m.react("👀"); // or bot.react(conversation, messageId, "<:party:01J…>")
await bot.unreact(m.conversation, m.id, "👀");
const { items, next } = await bot.reactors(m.conversation, m.id, "👍", { limit: 100 }); // then { after: next }

From an older node that sends only the counts, changes are computed for messages the bot has seen since it started and user/userId are null.

Embeds (APPLICATIONS.md §6.4): { type: "text", body: "", embeds: [{ title, description, url, color, fields, footer, timestamp }] }, at most 10 per message, no remote images; encrypted like the rest of the content.

Files (messages:write, attach_files): send(conversation, "Report", { files: [{ name: "report.pdf", data, mime }] }) uploads up to 10 files. In encrypted conversations each file is encrypted with its own key and only ciphertext reaches the node (its upload category is declared from the name and type, PROTOCOL.md §7a). m.attachments lists received files, await m.download(id?) returns { name, mime, size, data }, decrypted and checked against its hash.

Files channels (FILES.md §3 “Bots”; browsing messages:read, writing messages:write with the channel’s attach_files): bot.files keeps folders that outlive message retention, encrypted where the channel is (records and chunks like the web client; the node sees no name and no content):

const channel = "files.example.org/01J…"; // the files channel's qualified id (or a bare id on the home node)
const builds = (await bot.files.list(channel)).find((e) => e.kind === "folder" && e.name === "Builds")
?? (await bot.files.createFolder(channel, "Builds"));
await bot.files.upload(channel, { path: "./dist/report.pdf", folder: builds, rename: true }); // or { name, data, mime }
for (const e of await bot.files.list(channel, builds)) {
if (e.kind === "file" && !e.locked && e.name.endsWith(".log")) console.log((await bot.files.download(e)).data.length);
}
bot.on("files", (e) => console.log("changed:", e.conversation, e.folderId));

list returns every entry of a folder (folders first, then by name); entries whose record the bot cannot open (written before it could read the channel) come back locked with size and dates only. upload refuses a taken name with 409 files.name_taken unless rename: true numbers it (report (2).pdf). delete(entry) removes the bot’s own entries; others’ need manage_files (scope rooms:manage). The storage backend of a files channel is closed to bots.

History, edits, deletions, pins (messages:read / messages:write): bot.messages(conversation, { limit, before, after, around, threadId }) returns readable messages decrypted; bot.edit(conversation, id, "new text") edits the bot’s own message (encrypted again with the same signed context, or signed again); bot.deleteMessage(...) deletes its own, or anybody’s with moderation (manage_messages); pin, unpin, pins; typing(conversation) shows ”… is typing”; markRead.

Threads (messages:*; others’ threads need moderation): besides m.replyInThread(),

const t = await bot.createThread(m.conversation, { title: "Release 0.4", rootMessageId: m.id }); // or a forum post: { title, content, tags }
await bot.send(t.conversation, "Checklist: …", { threadId: t.id });
const { items } = await bot.threads(m.conversation, { archived: "false" }); // titles decrypted
await bot.archiveThread(t.conversation, t.id);
await bot.updateThread(t.conversation, t.id, { title: "Release 0.4.0", locked: true });

Titles in encrypted channels are encrypted and bound to their thread; the runtime picks the thread id where the node keeps client ids (threads.client_ids). Events: thread.

Polls: see above; bot.closePoll(conversation, id, at?) closes one early (author or manage_messages).

Rooms and members: bot.rooms(node?), bot.room(room) (always allowed), bot.members(room, { limit, after }) (members:read), bot.profile(address), bot.search(room, { q }) (plaintext channels only: the node indexes no encrypted content), events memberJoin / memberLeave.

Moderation (moderation; the room permissions still decide): bot.timeout(room, userId, minutes, { reason }) (0 lifts it, at most 28 days), bot.kick(room, userId), bot.ban(room, "user@node" | "domain"), bot.unban, bot.bans, bot.auditLog. userId is the member’s id on the room’s node: m.authorId, interaction.userId.

bot.on("message", async (m) => {
if (/https?:\/\/scam\.example\//i.test(m.text)) {
await bot.deleteMessage(m.conversation, m.id);
await bot.timeout(m, m.authorId, 60, { reason: "spam link" });
}
});

Reports (moderation and “Moderate members” in the room; MODERATION.md §1.4a): read-only and metadata only. bot.reports(room, { status, limit, before }) lists the room’s reports newest first, bot.report(id) reads one, and the report event (bot.onReport) tells about new and changed ones. A report names its kind, category, the reported user (target_address), status, evidence_count, action_count, times and the reported message_id; the reporter is a pseudonym (the same for the same reporter in that room), and the reason, snapshots and released messages never reach bots. Creating, triaging and acting on reports stay with people: escalate to them instead.

const modChannel = process.env.MOD_CHANNEL!; // "<node>/<channel id>" of the moderators' channel
bot.onReport(async (e) => {
if (e.action !== "create" || !e.roomId) return;
const open = await bot.reports({ node: e.node, roomId: e.roomId }, { status: "open", limit: 100 });
const report = await bot.report(e.reportId, e.node);
const about = open.reports.filter((r) => r.target_address === report.target_address).length;
if (about >= 3) await bot.send(modChannel, `${about} open reports about ${report.target_address}`);
});

Presence (presence): bot.setPresence({ status: "dnd", customStatus: { text: "Deploying", emoji: "🚀" } }), bot.setActivity({ type: "watching", name: "3 servers" }), bot.clearActivity(); applied on every node the bot is connected to (presence is kept per node).

DMs (dms to open, messages:* to read and write): await bot.dm("alice@node.example", "Your report is ready") opens (or reuses) the 1:1 DM on the home node, creates its encryption group and adds the user’s devices. The owner always gets a normal conversation, everybody else a message request under their own dm_policy. Requests to the bot arrive as dmRequest: acceptDm(e.conversation) / declineDm(e.conversation, { block }).

Voice channel status (rooms:manage, which unlocks voice_status): bot.setVoiceStatus(room, channelId, "Raid night"). Bots do not join voice (the runtime has no media stack).

Everything else the route table opens (room management, webhooks, voice moderation, invites, emojis): bot.api(node?) is the @tavrn/sdk client with the bot’s credentials on that node, e.g. await bot.api().webhooks.list(roomId). Content you write through it in encrypted channels is yours to encrypt.

Rate limits and connections. Requests that answer 429 are repeated after Retry-After (option rateLimit); the WebSocket reconnects with backoff, a resync reloads rooms and channels, a token refused at runtime ends the bot with token_refused, a replaced connection with replaced.

bot.command(name, handler) registers a handler; bot.setCommands([...]) announces the definitions (the same as the owner’s editor, §5.1). The handler gets an Interaction with args, user, conversation, locale, permissions and these answers (only the first one counts, §5.7):

  • reply(content, { ephemeral?, mentions? }): a message in the conversation (marked “used /weather”), or with ephemeral a reply only the invoker sees;
  • ephemeral(content): shorthand;
  • defer({ ephemeral? }): the invoker sees ”… is thinking”; then followUp(content) (at most 10, within 15 minutes). reply after defer sends a follow-up;
  • error(message): a short notice for the invoker. The node sees it, so keep it generic.

Threads (PROTOCOL.md §7e): threadId names the thread a command was invoked in (null outside threads), and the node puts the reply and the follow-ups there. replyInThread(content, { title?, ephemeral?, mentions? }) answers in that thread, or, invoked in the channel itself, starts a thread of the channel (the title, encrypted in encrypted channels, defaults to the start of the text) and answers there; later follow-ups go there too. reply, followUp and replyInThread take { threadId } to answer in another thread of the conversation.

A handler that throws or never answers is answered with a generic error notice. In encrypted channels the arguments arrive encrypted: the runtime decrypts them, checks the signature of the invoker’s device and refuses arguments made for another command, older than five minutes or replayed (§5.8); replies and follow-ups are encrypted for the channel and sign the interaction they answer (ctx.interaction, ENCRYPTION.md §5.2), so the node cannot present them as the answer to another invocation. Thread titles the runtime creates for replyInThread carry no signed context (the node chooses the thread id); clients accept them as titles of older clients. “Ephemeral” is a delivery property there, not a cryptographic one: any member of the epoch could decrypt an ephemeral reply if the node handed it over.

Messages of the bot can carry components (APPLICATIONS.md §5.10): up to five rows of up to five buttons or one select menu. The client draws them from its own palette; the bot chooses only the texts, the style (primary, secondary, success, danger, link) and an emoji. bot.component(customId | RegExp, handler) handles clicks and choices, bot.modal(customId | RegExp, handler) the forms the bot opened. The handler gets the command Interaction with messageId, customId, values (a select’s choices) or fields (a form’s input), and two more answers: update(content) edits the message that carries the component, modal(form) opens a form on the invoker’s device. A raid planner:

import { button, modal, row, select, textInput, withComponents } from "@tavrn/bot";
const signups = new Map<string, string>(); // user → class
const card = () =>
withComponents(
`Raid on Friday 20:00 · ${signups.size} signed up`,
row(
button({ label: "Sign up", customId: "raid:signup", style: "success", emoji: "✅" }),
button({ label: "Decline", customId: "raid:decline", style: "danger" }),
button({ label: "Note for the lead", customId: "raid:note" }),
),
row(select("raid:class", [{ label: "Tank", value: "tank" }, { label: "Healer", value: "healer" }, { label: "Damage", value: "dps" }], { placeholder: "Your class" })),
);
bot
.command("raid", (i) => i.reply(card()))
.component("raid:signup", (i) => {
signups.set(i.user, signups.get(i.user) ?? "?");
return i.update(card());
})
.component("raid:decline", (i) => {
signups.delete(i.user);
return i.update(card());
})
.component("raid:class", (i) => {
signups.set(i.user, i.values?.[0] ?? "?");
return i.ephemeral(`You go as ${i.values?.[0]}.`);
})
.component("raid:note", (i) => i.modal(modal("raid:note-form", "A note for the raid lead", textInput("note", "Note", { style: "paragraph", maxLength: 500 }))))
.modal("raid:note-form", (i) => i.ephemeral(`Noted: ${i.fields.note ?? ""}`));

The runnable version is examples/raid-planner.

The builders check what they build with the node’s rules and throw a TypeError naming the offending field. Only the bot that wrote a message receives its interactions; custom_id comes back from the user’s client, so treat it as untrusted input. In encrypted channels the values arrive encrypted: the runtime decrypts them, checks the invoker’s device signature and refuses values made for another message, component or form, older than five minutes or replayed; whether a choice is one of the select’s options is the handler’s check there (validateComponentValues of @tavrn/sdk does it with the components the bot sent), because the node cannot see them. update encrypts the new content with the message’s thread as its signed context (pass { replyTo } when the message was a reply).

A bot is an ordinary participant of tavrn’s encryption (ENCRYPTION.md):

  • Identity. On the first start the runtime creates the bot’s master secret, writes the key file (mode 0600, an existing file is never replaced) and publishes the identity key derived from it. Every later start needs the same key: without it the runtime stops with key_missing (“restore the key file or ask the owner to reset the bot identity”), with another one with key_mismatch.
  • Devices. Every token is a device of the bot. The runtime attests the token’s device (and the device’s share key, ENCRYPTION.md §3.1) with the identity key and keeps KeyPackages uploaded, so people can add the bot to private channels and DMs.
  • Groups. The bot joins the encryption group of every encrypted channel it can view (external commit) and accepts Welcomes for private channels and DMs. With joinPolicy: "eager" it joins right after an installation; with "lazy" only when it needs a channel (a message, a command, send), which avoids an epoch per channel in large rooms. A bot can never read what was sent before it joined.
  • Never plaintext where it must be encrypted. DMs and group DMs are always end-to-end encrypted. The runtime never writes them in plaintext, nor a channel whose encryption group it holds or that it saw encrypted in the session, whatever the node reports: such a write (send, edit, poll vote, thread, files-channel upload) fails with BotError("encryption_downgrade") and nothing is sent. Edits follow the conversation, not the edited message. By design, a channel that an admin switched from encrypted to plaintext stays write-blocked for a bot that holds its encryption group (the runtime cannot tell a real switch from a node that lies about it): the bot can write there again only once it no longer holds the group (removed from the channel’s group, or started with a fresh state directory). A channel the bot only saw encrypted, without holding its group, is writable again after a restart.
  • Local state. The MLS state lives in stateDir/state.json, encrypted with a key derived from the master secret: the directory alone is useless to a thief, the key file is the secret. Keep the directory on a persistent volume: without a history backup (the default) it is the only place the epoch keys of past messages live.
  • “No bots”. Channels, categories and rooms can forbid bots. The node hides such channels from bots and refuses their joins; clients treat a bot that is still in such a channel’s group as an unknown device (ghost warning) and moderators’ clients remove it.
  • Encrypted channels in new rooms. Rooms created since the setting bots_in_encrypted_channels exists keep bots out of every encrypted channel unless the channel’s settings say “Bots allowed” (rooms that existed before keep allowed). An installed bot therefore reaches only plaintext channels and the encrypted channels a room manager opened for it; the runtime joins such a channel as soon as it is opened (channel.update) and loses it when it is closed again. Every member sees in the room’s welcome channel when a bot joins or leaves an encrypted channel.

Before anything is encrypted the runtime checks the conversation’s group for devices that are not what their user’s identity key confirms (ENCRYPTION.md §4.6). People see a warning and decide; nobody watches a bot, so ghostPolicy decides for it:

  • strict (default): the leaf of a device that was confirmed and is now retired (deleted, waiting for its owner’s cleanup) is accepted. Anything else refuses the message with BotError("ghost"), which names the conversation and the devices: a key that was never confirmed, a member whose identity key changed without a continuity chain from the old key (an identity reset; a rotation with a valid chain is fine), a device of a non-member. The bot sends again once the group is clean (the members’ clients remove such devices) or the change was confirmed.
  • accept: the message goes out anyway and every accepted device is logged as a warning. Use it only where reaching the conversation matters more than the guarantee that nobody else reads along.

By default (historyBackup: false) the runtime keeps no history backup on the node: nothing is uploaded and nothing is restored. If the state directory is lost, start the bot with a new token (a new device: the old device’s key lived only in the lost directory) and the same key file; it joins its channels again and reads only messages sent from then on.

With historyBackup: true the runtime backs up the epoch keys of every conversation to the bot’s home node, encrypted with a key derived from the bot’s master secret, uploads pending backups on stop() and restores all backups into a fresh state directory, so the bot can read what it was entitled to before. The trade-off:

  • For it: bots that must answer about older messages (search, summaries, moderation bots) survive a lost or replaced state directory, e.g. a container without a persistent volume.
  • Against it: whoever obtains the key file (and an app token to fetch the backups) can then read the whole history the bot ever saw, not only what is sent after the leak; without backups a leaked key file only matters together with the state directory or for messages after a rejoin. Most bots react to new messages only and should keep the default.

The example bot (examples/echo-weather) keeps the default.

tavrn-key-<username>.txt holds the master secret (key: line and 24 words). Whoever has it can read everything the bot can read and impersonate the bot’s devices. Keep it out of the repository, back it up like a private key, mount it read-only into containers. There is no recovery key for bots: if the file is lost, the owner resets the identity (application → Reset identity); the bot then creates a new key file on its next start, contacts see “security key changed”, and messages from before cannot be read any more. If the file leaked, reset the identity and the tokens.

App tokens do not refresh; they expire after at most TAVRN_APP_TOKEN_MAX_DAYS (365 by default). Seven days and one day before, the bot gets tokenExpiring (and the owner a notice). Two ways out:

  • the owner resets the token in the application (new secret, same device and encryption state), or
  • the bot calls await bot.rotateToken() (at most once per 24 hours): same token row and device, a new secret, the old one stays valid for 24 hours. The runtime switches at once and emits tokenRotated with the secret: store it where the next start reads TAVRN_BOT_TOKEN from.

A revoked or reset token ends the connection (error with token_refused); a second process with the same token replaces the older connection after two (replaced). Run one process per token and state directory.

A bot lives on its owner’s node but can be installed in rooms of other nodes (if both nodes allow foreign bots). The runtime lists its installations (GET /applications/me/installations), opens a federated session on every room node (a federation token from the home node, exchanged at that node), keeps it renewed and listens there; a new installation elsewhere (application.install) connects that node right away. Messages, commands and send() work the same on every node; m.node tells where a message came from.

Bots that receive requests from the node (interactions URL, event subscriptions, §6.3) verify them with verifyHookRequest() / HookVerifier, re-exported from @tavrn/sdk.

Terminal window
pnpm --filter @tavrn/bot test # unit tests against a fake node (test/fake-node.ts); integration tests skipped without a node binary
pnpm --filter @tavrn/bot typecheck
TAVRN_NODE_BIN=/path/to/tavrn-node pnpm --filter @tavrn/bot test # also the tests against real nodes

The integration tests in test/node/ are opt-in: they run only when TAVRN_NODE_BIN names a tavrn-node binary (a debug build of tavrn-node, or the binary copied out of its container image; just test-node in the repository root). test/node/harness.ts starts it with --dev on a free port with its own SQLite database (two federated nodes for the remote installation test).


This page is generated from tavrn-js/bot/README.md (Apache-2.0) when the website is built; its links were adapted for this site.