atlas-bot-sdk
v0.1.1
Published
Chat bots for Atlas: a plain Matrix account, an outbound-only /sync loop, the auto-responder guard kit, and the platform LLM. Typed ESM, zero runtime dependencies.
Readme
Atlas Bot SDK
Chat bots for Atlas. A bot is an Atlas bot account plus an outbound-only message stream: no webhook, no public URL, no endpoint registration, no Atlas source code. Membership is the routing table, the chat history is the queue, and the default chat UI is the whole interface. One BotFather token is the entire configuration.
Ships as compiled ESM with full type declarations; zero runtime
dependencies, Node 18+. npm test builds and runs the suite (node:test).
The pieces
| File | What it is |
|---|---|
| src/bot.ts | createAtlasBot — the runtime: message stream, command/message routing, history, every sender, think() |
| src/wire.ts | The app-visible wire contracts: metadata, choices widget, receipts, media, commands |
| src/guards.ts | The guard kit (🤖 marker loop-breaker, stale guard, dedupe, cooldown, consecutive cap) — on by default |
| src/llm.ts | The platform LLM client + the model→tool→model loop |
| src/matrix.ts | The minimal chat-API client (messages, media upload/download, membership) + the provisioning surface BotFather uses |
| src/config.ts | Baked-in production endpoints — why a token is the only config (env vars still override for dev) |
| src/botfather.ts | BotFather — chat-based bot provisioning on Atlas accounts |
| src/yaml-lite.ts | Dependency-free YAML subset for bot config files |
| examples/doctor-finder/ | The worked example: LLM triage on deterministic rails → dispatch with tap-to-claim → case groups |
A minimal bot
import { createAtlasBot } from "atlas-bot-sdk";
const bot = createAtlasBot({
accessToken: process.env.ATLAS_BOT_TOKEN, // from BotFather — the whole config
instructions: "You are a helpful assistant for the Acme team."
});
bot.tools.register("lookup_order", {
description: "Look up an order by id",
parameters: { type: "object", properties: { id: { type: "string" } } },
handler: ({ id }) => myDatabase.orders.get(id) // your server, your code
});
bot.onCommand("start", (ctx) => ctx.sendText("Hi! Ask me about your order."));
bot.onMessage(async (ctx) => {
const turn = await ctx.think(); // platform LLM + your tools
if (turn.reply) await ctx.sendText(turn.reply);
});
bot.start();Deploy anywhere with outbound HTTPS — Railway, a VPS, your laptop. Downtime loses nothing: the chat history queues events, the stream resumes from the persisted cursor, and the stale guard keeps a restarted bot from answering old mail.
What a bot can do
Everything below exists on the bot (bot.sendText(chatId, …)) and, bound to
the current chat, on the handler context (ctx.sendText(…)):
- Messages —
sendText; every send also pushes a lock-screen notification to the chat's humans (notifications: falseopts out). - Choice buttons —
sendChoices(chatId, prompt, options): tappable chips in the app, a numbered list on older clients; the tap comes back as an ordinary reply (ctx.choicecarries the value when the app marks it). - Live messages — sends return an
eventId;editMessage(chatId, eventId, text, {choices, disabled})edits the same bubble in place. Freeze buttons once the moment passes (claimed by someone else, passed, canceled) — per option or whole-widgetdisabled: true; frozen chips stay visible but stop responding. - Menus — nested choice arrays make button grids (rows of rows, max
4);
silent: trueoptions fire an invisible callback instead of a visible reply (ctx.isCallback) — answer by editing the menu message in place. - Reply keyboards —
sendKeyboard(chatId, prompt, options, {oneTime})puts preset answers in a strip above the user's input bar until used, removed (removeKeyboard), or dismissed. - Relay & identity —
copyMessage(chatId, event)/ctx.copyTorelays text or media (same content url, no re-upload);ctx.senderNamegives the sender's display name. Markdown renders in bot messages. - Media, both directions —
sendImage/sendVideo/sendVoice/sendDocument/sendContact(builds the vCard) /sendLocation; inbound attachments arrive asctx.attachmentwith adownload(). - Read ticks — automatic: the SDK marks handled messages read
(
receipts: falseopts out);markRead(chatId)is the explicit form. - Chats & members —
createGroup(chatId, title, members)makes a bot-owned group everyone's app auto-joins (THE pattern when people need to talk or call — bot chats are text-only),invite, auto-join of incoming invites,setRoomName(renders as a system row),dmRoom(userId)to message anyone. Winding down:kickUser(chatId, userId, reason?)removes a member (needs room power — guaranteed in groups the bot created) andleaveRoom(chatId)exits the bot itself. Remaining members keep the chat read-only with its history — Doctor Finder does both when a case closes. - Notifications —
notify(chatId, preview)pushes explicitly. - App commands — the user's Atlas executes a curated command on the
bot's behalf and echoes the outcome back. The typed surface is
bot.app(chatId)/ctx.app: messaging as the user (sendAsUser,listMessages,messageAction), chats (createChat,listChats,openChat), search & recall (searchMessages,searchChat,recallAbout,retrieveFile), memory (storeMemory,forgetAbout, tagging), to-dos, buckets, contacts (lookupContact), calls (startCall,endCall,muteCall,setCallCamera), notifications, and device routes (deviceContext,dialPhone,openUrl,openMaps,composeMail,composeSms). Everyapp.*method WAITS for the device's result — reads return their payload inreply.result.result, creates return the new item's id inreply.result.createdId.runCommand(chatId, command, params, {timeoutMs})is the general awaiting form;sendCommandstays fire-and-forget. Legacy sugar (addTodo,createBucket,scheduleNotification,cancelNotification) remains. Params are sanitized on-device per command; bulk wipes, self-configuration, and confirm-gated destruction are off-catalog by design. - The platform LLM —
ctx.think()(works with zero config; the token is the model access),ctx.think({extraHistory, tools})for per-call tools,bot.tools.registerfor bot-wide ones,llm: falseto opt out.
BotFather
Create and manage bots in chat, with the full command set (/newbot,
/mybots, /setname, /setdescription, /setabouttext, /setuserpic,
/setcommands, /token, /revoke, /setprivacy, /setjoingroups,
/deletebot, /cancel). Interactive prompts (choosing a bot,
Enable/Disable, delete confirmation) are buttons.
/newbot walks name → username (must end in bot), provisions the account,
and hands back the access token. /setprivacy, /setjoingroups, and
/setcommands are written to the bot's platform-stored settings, where the
SDK reads them at startup — settings apply without the developer redeploying.
BotFather itself is run by the platform operator; the required credentials
are documented in examples/run-botfather.js, not here.
The LLM
ctx.think() calls the platform model, authenticated with the bot's token —
the developer never holds a provider key, and bot tokens authenticate at the
platform directly. The platform enforces the base-rules preamble server-side
(identify as a bot; never claim untraced actions; never impersonate), so the
rules aren't strippable.
Guard rails (on by default)
Sends carry the 🤖 marker and marked inbound is ignored (no bot⇄auto-responder
ping-pong); catch-up events older than 5 minutes are dropped; event ids are
deduped; per-chat cooldown 1s; after 8 unanswered sends in 30 minutes the bot
goes quiet in that chat until a human speaks. Guards gate every sender —
media and app commands included. Disabling any of these is a deliberate
opt-out via guardOptions.
