API Reference
Everything a bot can do to Atlas, programmatically. Every function exists on the bot (bot.sendText(chatId, …)) and, bound to the current chat, on the handler context (ctx.sendText(…)). The SDK ships full TypeScript types for all of it.
Build native screens with the SDK UI libraries, then open pages or bottom sheets using existing chat buttons. The guides include import paths, types, and a complete food-ordering example.
Messaging
| Function | What it does |
|---|---|
sendText(chatId, text) | A normal chat bubble from the bot — markdown renders (bold, italics, links, code). Guard-gated; pushes a notification to the chat's humans by default. |
sendChoices(chatId, prompt, options) | Choice buttons — see below. Sends return an eventId. |
editMessage(chatId, eventId, text, {choices?, disabled?}) | Edit one of the bot's own messages in place — same bubble, new text and/or frozen buttons. No new notification. |
bot.dmRoom(userId) | Find or create the bot's 1:1 chat with any user — the "send a message to someone else" primitive. Reuses the chat the person already opened from search; never creates duplicates across restarts. |
onMessage(handler) / onCommand(name, handler) | Receive messages and routed button values (a tap's value like /claim T001 arrives here). Handlers get ctx: roomId, body, sender, phoneNumber, command/args/argText, attachment, choice, event (the raw chat event), history(), members(), and every sender below. |
onMemberJoin(handler) | Fires when someone joins one of the bot's chats. |
ctx.senderName | The sender's display name when known, falling back to the readable part of their id. |
ctx.phoneNumber | The sender's verified Atlas-account phone number in E.164 form (for example +15555550123), or null when unavailable. The SDK resolves and caches it automatically. |
ctx.copyTo(chatId) / bot.copyMessage(chatId, event) | Relay a received message — text or media — into another chat. Media rides the same content url, no re-download/re-upload. |
ctx.history() | The recent conversation as role-tagged turns — what think() sees. |
Choice buttons
Tappable chips under the bot's message — Atlas's inline keyboard. Tapping sends the option's value back as an ordinary reply, so your receive path is one code path for taps and typing. Older app versions render a numbered list instead; nothing breaks.
await ctx.sendChoices("Which time works best?", ["Morning", "Afternoon"]);
// Label and value can differ — labels for humans, values for your router:
await ctx.sendChoices("New case T001", [
{ label: "Claim", value: "/claim T001" },
{ label: "Pass", value: "/pass T001" }
]);
// On the way back in:
bot.onMessage((ctx) => {
ctx.body; // "/claim T001" — the tapped value (or whatever was typed)
ctx.choice; // "/claim T001" when the app marked it as a tap, else null
});
// LIVE messages: hold the event id, edit the bubble in place later —
// freeze the buttons once the moment passes (claimed, passed, canceled).
const sent = await ctx.sendChoices("New case T001", [
{ label: "Claim", value: "/claim T001" },
{ label: "Pass", value: "/pass T001" }
]);
await bot.editMessage(chatId, sent.eventId, "New case T001 — taken by Dr. Chen", {
choices: [
{ label: "Claim", value: "/claim T001", disabled: true },
{ label: "Pass", value: "/pass T001", disabled: true }
],
disabled: true // the whole widget freezes; chips stay visible as a record
});
Disabled works at both levels: per option ({label, value, disabled: true}) and whole-widget (disabled: true on the edit). Frozen chips render dimmed and stop responding — on every member's device. Doctor Finder uses this everywhere: the losers' dispatches gray out with "Taken by Dr. Chen", a pass freezes your own, a patient cancel grays out all of them.
Grids and silent buttons
// Nested arrays = a grid, one inner array per row (max 4 rows).
// silent: true = the tap fires an INVISIBLE callback — nothing posts to
// the chat; your handler gets ctx.isCallback and answers by EDITING the
// menu message in place.
const menu = await ctx.sendChoices("Coffee menu — page 1", [
["Espresso", "Latte", "Cortado"],
[{ label: "Next page ▸", value: "/page 2", silent: true }]
]);
bot.onCommand("page", async (ctx) => {
if (!ctx.isCallback) return;
await ctx.editMessage(menu.eventId, "Coffee menu — page 2", {
choices: [
["Flat white", "Mocha", "Drip"],
[{ label: "◂ Back", value: "/page 1", silent: true }]
]
});
});
Reply keyboards
The other keyboard flavor: preset answers rendered as a strip above the user's input bar, not under a message. It stays until used (oneTime), removed by the bot, or dismissed by the user; taps post visible replies.
await ctx.sendKeyboard("How was your visit?", ["Great", "Okay", "Bad"], { oneTime: true });
// later, to clear a persistent keyboard:
await ctx.removeKeyboard("Thanks for the feedback!");
Media — every medium, both directions
| Function | Renders as |
|---|---|
sendImage(chatId, bytes, {filename?, mimetype?}) | A photo bubble. |
sendVideo(chatId, bytes, {filename?, mimetype?}) | A video bubble. |
sendVoice(chatId, bytes, {filename?, mimetype?}) | A playable voice note. |
sendDocument(chatId, bytes, {filename?, mimetype?}) | A document with filename and size. |
sendContact(chatId, {name, phones?, emails?, org?}) | A contact card — the SDK builds the vCard; the app opens its full contact preview. |
sendLocation(chatId, lat, lng) | A tappable location. |
Inbound media arrives on the same handler as text:
bot.onMessage(async (ctx) => {
if (!ctx.attachment) return;
const { type, filename, mimetype, size } = ctx.attachment;
// type: "photo" | "video" | "voiceNote" | "document" | "contact" | "location"
const { bytes } = await ctx.attachment.download();
});
Read state
| Function | What it does |
|---|---|
| automatic | The SDK marks each handled message read, so people see read ticks in bot chats exactly like with humans. Opt out with receipts: false. |
markRead(chatId) | Mark the chat read up to the newest handled message, explicitly — "mark all as read" for a chat. |
Chats & members
| Function | What it does |
|---|---|
invite(chatId, userId) | Invite a user into one of the bot's chats via the platform — the path the recipient's app auto-accepts (the raw fallback stays pending until accepted). Renders as a "joined" system row. |
| incoming invites | Auto-joined by default (autoJoinInvites); group invites obey /setjoingroups. |
setRoomName(chatId, name) | Rename the chat — renders as a system row; the no-UI channel for status like "Closed — Dr. Chen · dermatology". |
ctx.members() / ctx.isDirect | Who's in the chat; whether it's a 1:1. |
createGroup(chatId, title, members) | Create a group the bot OWNS and pull members in with platform join tokens their apps auto-accept — the group just appears for everyone. The Doctor Finder case room is the worked example. |
kickUser(chatId, userId, reason?) | Remove a member from one of the bot's chats. Needs room power — guaranteed in groups the bot created with createGroup. The removed member's app keeps the chat read-only with its history and a "You were removed" system row. Returns false if the server refuses. |
leaveRoom(chatId) | The bot exits the chat for good — the wind-down move for a finished createGroup room (send the farewell and any rename FIRST; a departed bot can't post). Members keep the chat; Doctor Finder pairs this with kickUser when a case closes so only the patient keeps the record. |
Calls
Bot chats are text-only — no call button, by design. When a conversation needs voice, createGroup the humans together: a group is a normal chat and its call button just works. No flags, no permissions, no special cases.
Push notifications
| Function | What it does |
|---|---|
| automatic | Every successful send pushes a lock-screen notification to the chat's humans, titled with the bot's name. Opt out with notifications: false. |
notify(chatId, preview) | Push explicitly — e.g. a reminder without a message, or a custom preview line. The bot must be a member of the chat; per-bot rate limits apply. |
Atlas app commands
A bot can ask the user's Atlas to do something on its behalf — the same tool surface Atlas's own on-device agent drives. Commands ride the chat and execute on the user's device behind a hard curated catalog (below) with per-command argument sanitizers — spoof-guarded to the chat's bot peer, deduped on retry, and every outcome echoed back to the bot as a result event. Anything off-catalog is dropped, not queued.
const app = bot.app(chatId); // or ctx.app inside a handler
// Reads return their payload in reply.result.result:
const contact = await app.lookupContact("Mario");
console.log(contact.result?.result); // "Mario Rossi — +1 555 0100 …"
const inbox = await app.listMessages({ chat: "Maya", count: 10 });
// Writes report ok/failure; creates hand back the new item's id:
await app.sendAsUser("On my way!", "Maya");
const bucket = await app.createBucket("Appointments");
await app.renameBucket(bucket.result?.createdId, "Dermatology");
// Calls, device routes, notifications:
await app.startCall("voice", "Maya");
await app.composeSms("+1 555 0100", "Running late");
await app.scheduleNotification({ title: "Meds", body: "3pm dose", date: "2026-07-09 15:00", id: "meds" });
// General forms: runCommand waits for the echo, sendCommand doesn't.
const reply = await bot.runCommand(chatId, "search.messages", { query: "insurance" }, { timeoutMs: 20_000 });
await bot.sendCommand(chatId, "action.create", { title: "Follow up with Dr. Chen" });
The return value
Every app.* method and runCommand return Promise<CommandReply>; sendCommand returns Promise<SendResult> (no waiting):
interface CommandReply {
sent: boolean; // false = blocked by the outbound guards
reason?: string; // why (cooldown, consecutive cap, …)
commandId?: string; // the command's id — the echoed result carries the same one
result?: CommandResult; // undefined = timed out waiting for the device
}
interface CommandResult {
id: string; // matches commandId
command: string; // the command this answers
ok: boolean; // the executor ran (see note below)
result: string | null; // the payload: list text, lookup text, confirmation, …
createdId: string | null; // new item's id — createAction and createBucket only
}
ok means the command reached its executor and ran: for device-route and create/delete commands a false is a real failure (with result saying why). Agent-tool commands (marked agent tool below) always execute and report their outcome — including "couldn't find that" — as prose in result, so read the text, not just the flag. A result of undefined on the reply means the wait timed out (default 20s) — the user's device is offline or hasn't synced yet, not a failure; a deduped retry also stays silent.
General forms
| Function | Returns | What it does |
|---|---|---|
bot.app(chatId: string) / ctx.app | AppCommands | The typed command surface below, bound to one chat. Every method sends and waits for the echo. |
bot.runCommand(chatId: string, command: string, params?: Record<string, any>, options?: { timeoutMs?: number }) | Promise<CommandReply> | Send any catalog command and wait for its result. Param values are stringified on the wire (the app decodes a flat string map). |
bot.sendCommand(chatId: string, command: string, params: Record<string, any>) | Promise<SendResult> — { sent, reason?, commandId? } | Fire-and-forget form. The device still echoes a result event; you're just not waiting for it. |
The catalog
All methods below return Promise<CommandReply>. agent tool = executed by the on-device agent's own executor; the payload comes back as prose in result.result.
| Function | Command · what the user's Atlas does · what comes back |
|---|---|
| To-dos | |
createAction(todo: { title: string; notes?: string; priority?: string }) | action.create — adds a to-do to the user's actions. result.createdId = the new action's id (keep it for update/delete). |
listActions(scope?: string) | action.list (agent tool) — lists to-dos with status; the bot chat by default, "all" for every chat. Payload in result.result. |
updateAction(update: { titleQuery: string; status?: string; priority?: string }) | action.update (agent tool) — fuzzy title match; status ∈ open | in progress | done | cancelled. |
deleteAction(actionId: string) | action.delete — by exact id (from createAction's createdId). ok=false when the id doesn't resolve. |
| Buckets | |
createBucket(name: string) | bucket.create — a bucket in the chat, always the generic type. result.createdId = the bucket's id. |
renameBucket(workflowId: string, name: string) | bucket.rename — by the created id. |
pinBucket(workflowId: string) | bucket.pin — by the created id. |
pullNotes() | notes.pull (agent tool) — aggregates the chat's notes into result.result. |
reviewProject(lookbackDays?: number) | project.review (agent tool) — notes + open/critical actions + next steps, in result.result. Default lookback 7 days. |
| Notifications | |
scheduleNotification(notification: { title: string; body: string; date: Date | string | number; id?: string }) | notification.schedule — a device reminder at date (Date, ISO, unix, or "yyyy-MM-dd HH:mm"). Ids are namespaced per bot. |
cancelNotification(id: string) | notification.cancel — cancels one of your bot's own notifications; you can never touch another's. |
| Chats | |
createChat(chat: { name?: string; members?: string[] | string }) | chat.create (agent tool) — creates a chat/group, resolving members by contact name. Summary (who was added, any name misses) in result.result. |
openChat(name: string) | chat.open (agent tool) — navigates the user's screen to the named chat. |
listChats() | chat.list (agent tool) — the user's chats, in result.result. |
renameChat(title: string) | chat.rename — renames the bot's own chat (a bot can never target another chat). |
| Messaging as the user | |
sendAsUser(text: string, to?: string) | message.send (agent tool) — sends text AS the user, to the named contact/chat, or into the bot chat when to is omitted. |
listMessages(options?: { chat?: string; count?: number }) | message.list (agent tool) — recent messages with reference ids, in result.result. Those ids feed messageAction/deleteMessageForEveryone. |
messageAction(action: string, message: string, text?: string) | message.action (agent tool) — action ∈ reply | save | unsave | archive | unarchive | copy | speak (trash is permanently refused for bots); message = a reference from listMessages; text = the reply body for reply. |
deleteMessageForEveryone(messageId: string) | message.delete — delete-for-everyone by exact device-local id, staged with the same undo window the UI gets. |
| Search & recall | |
searchMessages(query: string) | search.messages (agent tool) — full-text search across every chat. |
searchChat(query: string, topK?: number) | search.chat (agent tool) — semantic search over this chat's history. |
recallAbout(topic: string, kinds?: string) | recall.about (agent tool) — stored facts + attachments about a topic. |
listEntities(kind: string) | entity.list (agent tool) — people/places/orgs the user has mentioned, by kind. |
retrieveFile(query: string, options?: { type?: string; order?: string; dateFrom?: string; dateTo?: string; limit?: number }) | file.retrieve (agent tool) — posts the matched file INTO the bot chat; it arrives back as an ordinary media message your bot can download. |
| Memory | |
storeMemory(content: string, kind?: string) | memory.store (agent tool) — saves a fact to the user's memory (kind defaults to "fact"). |
forgetAbout(topic: string, options?: { kinds?: string; limit?: number }) | memory.forget (agent tool) — deletes stored facts about a topic. |
annotateItem(itemQuery: string, note: string) | item.annotate (agent tool) — attaches a note/correction to an existing item. |
tagItem(itemQuery: string, label: string) / untagItem(itemQuery: string, label: string) | item.tag / item.untag (agent tool) — add/remove a label on an item. |
retagItem(itemQuery: string, oldLabel: string, newLabel: string) | item.retag (agent tool) — atomic label rename. |
| Contacts | |
lookupContact(name?: string) | contact.lookup (agent tool) — the user's synced contacts (names, numbers, details) in result.result; omit name to list broadly. |
| Calls | |
startCall(kind: string, contact?: string) | call.start (agent tool) — places a call from the user's device; kind ∈ voice | video, contact by name. The bot is the dialer, never a participant. |
endCall() | call.end (agent tool) — hangs up the active call. |
muteCall(muted: boolean) | call.mute (agent tool) — mic on/off in the active call. |
setCallCamera(enabled: boolean) | call.camera (agent tool) — camera on/off (a voice call upgrades to video in place). |
| Device & OS | |
deviceContext(include?: string) | device.context (agent tool) — date/time/timezone/approximate location, in result.result. |
dialPhone(phoneNumber: string) | phone.call (agent tool) — opens the iOS dialer with the number; the user taps call. |
openUrl(url: string) | url.open — opens a web link. http(s) only; other schemes are stripped on-device and the command no-ops. |
openMaps(target: { latitude?: number; longitude?: number; address?: string; label?: string }) | maps.open — opens Maps at coordinates or an address. |
composeMail(mail: { to: string; cc?: string; bcc?: string; subject?: string; body?: string }) | mail.compose — opens a pre-filled mail draft (mailto: route); the user sends it. |
composeSms(to: string, body?: string) | sms.compose — opens a pre-filled SMS draft (sms: route); the user sends it. |
Params are sanitized on-device per command (unknown keys stripped, identifiers namespaced, fixed values forced — a bot's confirm never passes), and commands execute through the same gated tool registry that powers Atlas's own agent and Experiences. Deliberately off-catalog: bulk wipes (wipe_all, delete-all), the user's profile/custom instructions, Atlas toggling, experience install, and anything needing a typed user confirmation.
The platform LLM
| Function | What it does |
|---|---|
ctx.think() | One model turn over the chat history with your instructions. Works with zero configuration — the bot's token is its model access. |
ctx.think({ extraHistory, tools }) | Add per-call context or per-call tools that close over the current chat (how Doctor Finder offers find_doctor). |
bot.tools.register(name, {description, parameters, handler}) | Bot-wide tools, offered on every think() and executed on your server (model→tool→model, depth-capped). |
llm: false | Construction-time opt-out for fully deterministic bots. |
Guards & state
| Surface | What it does |
|---|---|
guardOptions | Tune the loop-safety kit (marker, stale, dedupe, cooldown, consecutive cap) — all on by default; see the rules. Guards gate every sender on this page, media and commands included. |
dataPath / stateStore | Where the stream cursor and DM-chat cache persist (one JSON file by default; injectable). bot.store exposes it at runtime for your own keys. |
bot.handleSyncPayload(payload) | Drive the bot with fixture events — the whole runtime is testable without the platform. |
bot.start() / bot.stop() / bot.init() | The message-stream loop: resumes from the cursor, exponential backoff, never answers old mail. init() resolves bot.userId and loads the BotFather-managed settings. |
Advanced: the raw client
Everything above is guard-gated sugar over bot.client — the low-level chat API, there when you outgrow the sugar. The Doctor Finder uses it to create its case groups. No guards apply at this layer; you're on your own recognizance.
| Function | What it does |
|---|---|
client.createRoom({name, invite, isDirect}) | Create a fresh chat the bot OWNS — full power to invite, rename, and post. Pair with directory.caseShare so both humans' apps auto-join (the case-group pattern). |
client.join(chatId) / client.leave(chatId) | Enter or leave a chat explicitly (auto-join normally handles inbound invites). |
client.joinedRooms() / client.joinedMembers(chatId) | Enumerate the bot's chats and who's in one — how dmRoom rehydrates after a restart. |
client.sendEvent(chatId, type, content) | Send an arbitrary custom event — the primitive under receipts and app commands. |
client.uploadMedia(bytes, {filename, mimetype}) / client.downloadMedia(url) | Raw content-store access; the media senders wrap these. |
client.setDisplayName(…) / client.setAvatarUrl(…) | The bot's own profile — normally managed via BotFather's /setname and /setuserpic. |
client.getAccountData(…) / client.setAccountData(…) | The bot's platform-stored settings — how BotFather's /setprivacy & friends reach a running bot. |
createDirectoryClient({adminProxySecret}) | Operator surface (needs the platform secret): register/remove directory entries, roomShare/roomInvite (owner-token invites the app auto-accepts), caseShare (register a bot-created group so join tokens resolve). |