Doctor Finder
The worked example: one bot routes sick people to on-call doctors through ordinary chats. The platform model runs the conversation; a deterministic referee makes every decision that matters. Built on the atlas-bot-sdk npm package, with tests for every flow below.
The flow
Patient's bot chat Doctor's bot chat
────────────────── ─────────────────
"I have an itchy rash on my arm"
├─ red-flag prefilter (no LLM) ─────→ emergency? point to emergency care, stop
├─ the model asks the follow-ups (duration, severity)
├─ model calls find_doctor {specialty, summary}
│ └─ allowlist + on-call matcher validate ─→ dispatch
└─ dispatch ──────────────────────────→ "New patient (dermatology) — …"
[ Claim ] [ Pass ] ← tap, or type
/claim T001 ← first claim wins
┌─────────────────────────────┘
▼
★ NEW CASE GROUP — "Dr. Maya Chen · dermatology" ★
· created by the bot (it owns the chat — full power)
· patient + doctor pulled in with atlas-join tokens;
their apps auto-accept and the group just appears
· conversation AND calls happen here (human group —
the call button just works, no flags, no unlocking)
· a "Follow up with Dr. Chen" todo lands on the patient's
Atlas (app commands — curated and sanitized, always on)
[ Close case ] ← doctor taps, in their bot chat
· case chat renamed "Closed — …"
· patient's bot chat: "I'm back — tell me if anything else comes up."
The patient side
Patients just talk — the platform model words the conversation from TRIAGE_INSTRUCTIONS (collect symptoms, duration, severity; one short question at a time; never diagnose). But the red-flag prefilter runs before anything else, including the model:
export function hasRedFlag(text, flags = DEFAULT_RED_FLAGS) {
const normalized = text.toLowerCase();
return flags.some((flag) => normalized.includes(flag));
}
A hit answers with the emergency message (locale-neutral — "call your local emergency number", never a country-specific one) and stops — the emergency decision is never delegated to LLM judgment. When the model knows enough, it calls the find_doctor tool; the tool's handler is the deterministic core:
// The model proposes {specialty, summary}; the allowlist clamp and the
// on-call matcher decide what really happens — the model never pages
// anyone directly.
const safeSpecialty = SPECIALTIES.includes(specialty)
? specialty
: specialtyFor(summary); // keyword fallback
const candidates = dispatcher.match(safeSpecialty);
If the model is unreachable, the bot degrades to a fixed apology with the emergency-number reminder — and a paged patient is always told a doctor is coming, model or no model.
The doctor side
Doctors are just roster entries — real Atlas users the bot DMs when a case matches, referenced by the phone number on their Atlas account, never by a raw platform id. The whole interface is buttons; nobody types a command (underneath, each tap routes a value like /claim T001 through onCommand):
| Button | Effect |
|---|---|
| Claim / Pass | On every dispatch. Claim takes the case — first claim wins, one active case per doctor — and opens the case group with the patient. Pass declines; if every candidate passes, the patient is told to check back. |
| Close case | On the claim confirmation (and any later nudge): farewell message, "Closed — …" rename, then the doctor is removed and the bot leaves — the patient keeps the case chat as a read-only record, and their bot chat goes back to triage duty. |
| Go on call / Go off call | On the welcome and status messages — off-call doctors are never paged. |
| End case | The patient's escape hatch, offered whenever they message during an open case. |
The roster is the developer's data, in YAML on their box. Each doctor is named by phone; one directory.resolvePhones call at startup maps every number to its Atlas account, and a number that isn't on Atlas skips that doctor with a log line instead of paging a ghost:
doctors:
- name: Dr. Maya Chen
phone: "+18765550101"
specialties: [dermatology, general]
on_call: true
The case is a group the bot owns
On claim, the bot creates a fresh group chat — so it holds full power there (renames, invites, no power walls) — registers it with the platform (case-share), and invites both humans with atlas-join tokens their apps auto-accept:
// One call: creates a chat the bot owns, registers it with the
// platform, and pulls both humans in — their apps auto-accept
// and the group just appears. Calls work there out of the box.
const caseRoomId = await bot.createGroup(
ticket.patientRoomId,
title,
[patientId, doctor.atlasUser]
);
- Each party keeps exactly one bot chat — dispatches for doctors, triage for patients. The case group is the only chat they ever share, and it's a normal human group: the call button just works there.
- Raw invites never surface in Atlas (the spam gate) — the
atlas-jointoken is what makes both apps auto-accept, exactly like the app's own "Add from Contacts" flow. - Closing is one tap — the Close-case button in the doctor's chat: wrap-up message, "Closed — …" rename, patient's bot chat back on triage duty, doctor freed for the next claim.
- A closed case winds itself down — the doctor is removed (
kickUser) and the bot exits (leaveRoom). Only the patient keeps the case chat: read-only, history intact, "Closed — …" in the title. The doctor's copy freezes the same way with a "You were removed" system row — no lingering three-way group, no stale call targets.
What's deterministic vs. model judgment
| Decision | Made by |
|---|---|
| Emergency escalation | Keyword prefilter — before any LLM call |
| Conversation wording & when to dispatch | The platform LLM, via the find_doctor tool |
| Specialty | Model-proposed, deterministically validated — allowlist clamp with keyword fallback |
| Doctor assignment | First-/claim-wins in matching.js — the referee, one authority per ticket |
| Case lifecycle (open → claimed → closed) | The dispatcher — one authority per ticket, one active case per doctor |
| Model unreachable | Fixed apology + emergency-number reminder; paged patients always get the paging confirmation |
| Loop safety | The SDK guard kit |
The full example
The complete bot, verbatim — the same four files the SDK's test suite runs, with the imports an npm consumer writes. Drop them in a folder next to package.json ({"type":"module"}), npm install atlas-bot-sdk, and run bot.js.
# Doctor Finder roster — the matching data lives on the bot developer's
# side, never in Atlas. Edit freely; the bot reloads on restart.
#
# Doctors are referenced by the PHONE NUMBER on their Atlas account — the
# identity you actually know. The bot resolves each phone to the account
# at startup; a doctor whose number isn't on Atlas is skipped with a log.
doctors:
- name: Dr. Maya Chen
phone: "+18765550101"
specialties: [dermatology, general]
on_call: true
- name: Dr. Sam Okafor
phone: "+18765550102"
specialties: [cardiology, general]
on_call: true
- name: Dr. Ines Rodrigues
phone: "+18765550103"
specialties: [pediatrics]
on_call: false
# Emergency phrases answered deterministically, before any model call.
red_flags:
- chest pain
- "can't breathe"
- cannot breathe
- trouble breathing
- unconscious
- severe bleeding
- overdose
- suicidal
- stroke
- seizure
/// Triage policy for the doctor-finder bot — pure functions, no I/O, so the
/// load-bearing decisions are testable and deterministic. The platform LLM
/// drives the *conversation*; it is never trusted with the emergency
/// decision, the specialty allowlist, or who actually gets paged.
export const DEFAULT_RED_FLAGS = [
"chest pain", "can't breathe", "cannot breathe", "trouble breathing",
"unconscious", "passed out", "severe bleeding", "overdose",
"suicidal", "stroke", "seizure"
];
export function hasRedFlag(text, flags = DEFAULT_RED_FLAGS) {
const normalized = text.toLowerCase();
return flags.some((flag) => normalized.includes(flag));
}
export const EMERGENCY_MESSAGE =
"This sounds like it could be an emergency. Please call your local " +
"emergency number or go to the nearest emergency room right now. " +
"I'm a routing assistant, not emergency care.";
/// The only specialties the dispatcher pages. The model proposes one of
/// these; anything else is clamped by the keyword fallback below.
export const SPECIALTIES = ["dermatology", "cardiology", "pediatrics", "orthopedics", "general"];
/// Keyword → specialty routing. Falls back to "general". Used as the
/// deterministic validator when the model's proposal is off-list.
const SPECIALTY_KEYWORDS = [
{ specialty: "dermatology", keywords: ["rash", "skin", "itch", "acne", "mole", "hives"] },
{ specialty: "cardiology", keywords: ["heart", "palpitation", "blood pressure"] },
{ specialty: "pediatrics", keywords: ["my child", "my baby", "toddler", "my son", "my daughter"] },
{ specialty: "orthopedics", keywords: ["ankle", "knee", "shoulder", "sprain", "fracture", "broken"] }
];
export function specialtyFor(symptoms) {
const normalized = symptoms.toLowerCase();
for (const entry of SPECIALTY_KEYWORDS) {
if (entry.keywords.some((keyword) => normalized.includes(keyword))) {
return entry.specialty;
}
}
return "general";
}
/// What the platform model is told. It words the conversation and decides
/// when it has enough to call find_doctor — the tool's handler re-validates
/// everything it proposes.
export const TRIAGE_INSTRUCTIONS =
"You are Doctor Finder, a routing assistant that connects patients with " +
"on-call doctors. Your only job is to learn three things, in the " +
"patient's own words: their symptoms, how long it has been going on, " +
"and how bad it is on a 1-10 scale. Ask one short, warm question at a " +
"time. Never diagnose, never suggest treatment, never speculate about " +
"causes. If anything sounds life-threatening, tell them to call their " +
"local emergency number immediately. Never name a specific emergency " +
"number like 911 — it differs by country. " +
"Once you know all three things, call the find_doctor tool " +
"with the best-fitting specialty and a one-line summary in the form " +
"\"symptoms · duration · severity/10\" — then stop; the platform " +
"handles the paging. Keep every reply to one or two short sentences.";
export const WELCOME_MESSAGE =
"Hi, I'm Doctor Finder. Tell me what's going on — your symptoms, in your " +
"own words — and I'll route you to the right doctor. If this is an " +
"emergency, call your local emergency number now.";
/// Matching + dispatch for the doctor-finder bot. This is the developer's
/// "server-side logic" in the platform design: it runs on their box, Atlas
/// never sees it, and its only visible effects are Matrix events. First
/// /claim wins — contested state is decided here, in one place, never by
/// two clients racing (the referee rule).
export function createDispatcher({ roster }) {
const doctors = (roster?.doctors ?? []).map((doctor) => ({
name: doctor.name,
atlasUser: doctor.atlas_user,
specialties: doctor.specialties ?? ["general"],
onCall: doctor.on_call !== false
}));
const tickets = new Map();
let counter = 0;
return {
doctors,
doctorByUserId(userId) {
return doctors.find((doctor) => doctor.atlasUser === userId) ?? null;
},
setOnCall(userId, onCall) {
const doctor = this.doctorByUserId(userId);
if (doctor) { doctor.onCall = onCall; }
return doctor;
},
/// On-call doctors for a specialty, falling back to generalists.
match(specialty) {
const exact = doctors.filter(
(doctor) => doctor.onCall && doctor.specialties.includes(specialty)
);
if (exact.length > 0) { return exact; }
return doctors.filter(
(doctor) => doctor.onCall && doctor.specialties.includes("general")
);
},
openTicket({ patientRoomId, summary, specialty, candidates }) {
counter += 1;
const id = `T${counter.toString(36).toUpperCase().padStart(3, "0")}`;
const ticket = {
id,
patientRoomId,
summary,
specialty,
candidates: candidates.map((doctor) => doctor.atlasUser),
notifiedRooms: {}, // doctor userId -> DM room id (for outcome notices)
dispatchEvents: {}, // doctor userId -> { roomId, eventId } (for live edits)
claimedBy: null,
caseRoomId: null,
passed: new Set(),
status: "open"
};
tickets.set(id, ticket);
return ticket;
},
ticket(id) {
return tickets.get(id) ?? null;
},
/// First claim wins; everyone else gets a deterministic "taken".
/// One active case per doctor: finish (/close) before claiming again.
claim(id, doctorUserId) {
const ticket = tickets.get(id);
if (!ticket) { return { ok: false, reason: "unknown" }; }
if (!ticket.candidates.includes(doctorUserId)) { return { ok: false, reason: "not_candidate" }; }
// Only OPEN tickets are claimable — canceled/unfilled/closed
// requests are dead, no matter how fast the tap raced the edit.
if (ticket.status !== "open") {
if (ticket.claimedBy === doctorUserId) { return { ok: false, reason: "already_yours", ticket }; }
if (ticket.claimedBy) { return { ok: false, reason: "taken", ticket }; }
return { ok: false, reason: "unknown", ticket };
}
const current = this.activeTicketForDoctor(doctorUserId);
if (current) { return { ok: false, reason: "busy", ticket: current }; }
ticket.claimedBy = doctorUserId;
ticket.status = "claimed";
return { ok: true, ticket };
},
/// Only the claiming doctor closes a case.
close(id, doctorUserId) {
const ticket = tickets.get(id);
if (!ticket || ticket.status !== "claimed") { return { ok: false, reason: "unknown" }; }
if (ticket.claimedBy !== doctorUserId) { return { ok: false, reason: "not_yours" }; }
ticket.status = "closed";
return { ok: true, ticket };
},
activeTicketForDoctor(doctorUserId) {
return [...tickets.values()].find(
(ticket) => ticket.status === "claimed" && ticket.claimedBy === doctorUserId
) ?? null;
},
activeTicketForPatientRoom(roomId) {
return [...tickets.values()].find(
(ticket) => ticket.status === "claimed" && ticket.patientRoomId === roomId
) ?? null;
},
openTicketForPatientRoom(roomId) {
return [...tickets.values()].find(
(ticket) => ticket.status === "open" && ticket.patientRoomId === roomId
) ?? null;
},
pass(id, doctorUserId) {
const ticket = tickets.get(id);
if (!ticket || ticket.claimedBy) { return { ok: false }; }
ticket.passed.add(doctorUserId);
const remaining = ticket.candidates.filter((candidate) => !ticket.passed.has(candidate));
if (remaining.length === 0) { ticket.status = "unfilled"; }
return { ok: true, ticket, remaining };
},
/// Patient-side cancel of a still-open (unclaimed) request.
cancel(id) {
const ticket = tickets.get(id);
if (!ticket || ticket.status !== "open") { return { ok: false }; }
ticket.status = "canceled";
return { ok: true, ticket };
}
};
}
/// Doctor Finder — the worked example for Atlas Bots. One bot, two flows,
/// and a fresh GROUP per case:
///
/// Patient: DMs the bot → red-flag prefilter (deterministic, pre-model) →
/// the platform LLM runs the triage conversation and calls find_doctor
/// when it knows enough → the tool's deterministic core validates the
/// specialty and pages on-call doctors in THEIR own Doctor Finder chats
/// (tappable Claim/Pass buttons).
///
/// Doctor: first /claim wins → the bot creates a case group it OWNS
/// (so it has full power there — no provisioned-room power walls) and
/// pulls BOTH humans in with atlas-join tokens; their apps auto-accept
/// and the group just appears. The conversation, and calls, happen in
/// the case group — and the patient's call button rings the claimed
/// doctor while the case is open. /close wraps it up and hands the
/// patient's bot chat back to triage duty.
///
/// Run: ATLAS_BOT_TOKEN=… node bot.js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createAtlasBot } from "atlas-bot-sdk";
import { createDirectoryClient } from "atlas-bot-sdk/directory";
import { parseYAML } from "atlas-bot-sdk/yaml-lite";
import { createDispatcher } from "./matching.js";
import {
hasRedFlag, specialtyFor,
DEFAULT_RED_FLAGS, EMERGENCY_MESSAGE, SPECIALTIES, TRIAGE_INSTRUCTIONS, WELCOME_MESSAGE
} from "./triage.js";
export function attachDoctorFinder(bot, {
roster,
redFlags = roster?.red_flags ?? DEFAULT_RED_FLAGS
} = {}) {
const dispatcher = createDispatcher({ roster });
const patientByRoom = new Map(); // patient roomId -> patient matrix id
const caseRooms = new Set(); // rooms the bot owns for active cases
const isDoctor = (sender) => dispatcher.doctorByUserId(sender) !== null;
/// The deterministic dispatch core, exposed to the model as the
/// find_doctor tool. The model proposes {specialty, summary}; the
/// allowlist clamp and the on-call matcher decide what really happens —
/// the model never pages anyone directly.
async function dispatch(ctx, { specialty, summary }) {
const safeSpecialty = SPECIALTIES.includes(specialty) ? specialty : specialtyFor(String(summary ?? ""));
const safeSummary = String(summary ?? "").slice(0, 200) || "patient needs help";
const candidates = dispatcher.match(safeSpecialty);
if (candidates.length === 0) {
return { ok: false, reason: "no doctors on call right now" };
}
patientByRoom.set(ctx.roomId, ctx.sender);
const ticket = dispatcher.openTicket({ patientRoomId: ctx.roomId, summary: safeSummary, specialty: safeSpecialty, candidates });
for (const doctor of candidates) {
const dmRoomId = await bot.dmRoom(doctor.atlasUser);
ticket.notifiedRooms[doctor.atlasUser] = dmRoomId;
// Tappable Claim/Pass buttons — silent, so the values route
// through the command layer invisibly; no human ever types (or
// sees) a command. The event id is kept so the dispatch can be
// LIVE-EDITED later: buttons freeze when someone else claims,
// or the request dies.
const sent = await bot.sendChoices(dmRoomId,
`New patient (${safeSpecialty}) — ${safeSummary}`,
[
{ label: "Claim", value: `/claim ${ticket.id}`, silent: true },
{ label: "Pass", value: `/pass ${ticket.id}`, silent: true }
]);
if (sent.eventId) {
ticket.dispatchEvents[doctor.atlasUser] = { roomId: dmRoomId, eventId: sent.eventId };
}
}
return { ok: true, ticketId: ticket.id, paged: candidates.length, specialty: safeSpecialty };
}
/// Live-edit a doctor's dispatch message: same bubble, outcome note,
/// frozen buttons. The record stays, the moment is visibly over.
async function freezeDispatch(ticket, doctorUser, note) {
const dispatch = ticket.dispatchEvents?.[doctorUser];
if (!dispatch?.eventId) { return; }
await bot.editMessage(dispatch.roomId, dispatch.eventId,
`New patient (${ticket.specialty}) — ${ticket.summary}\n${note}`,
{
choices: [
{ label: "Claim", value: `/claim ${ticket.id}`, disabled: true, silent: true },
{ label: "Pass", value: `/pass ${ticket.id}`, disabled: true, silent: true }
],
disabled: true
}).catch((error) => console.log(`[finder] dispatch edit failed: ${error.message}`));
}
/// Wind down a finished case room: farewell note, "Closed" rename,
/// then the doctor and the bot both LEAVE — the room was only ever
/// the case's meeting place. The patient keeps the chat (their app
/// renders a departed group read-only with the history intact).
async function departCaseRoom(ticket, doctor, farewell) {
const roomId = ticket.caseRoomId;
if (!roomId) { return; }
caseRooms.delete(roomId);
await bot.sendText(roomId, farewell);
await bot.client.setRoomName(roomId, `Closed — ${doctor?.name ?? "case"} · ${ticket.specialty}`)
.catch(() => {});
if (doctor?.atlasUser) {
await bot.kickUser(roomId, doctor.atlasUser, "case closed");
}
await bot.leaveRoom(roomId);
}
function findDoctorTool(ctx) {
return new Map([["find_doctor", {
description: "Page the on-call doctors that match the patient's need. " +
"Call exactly once, only when symptoms, duration, and severity are all known.",
parameters: {
type: "object",
required: ["specialty", "summary"],
properties: {
specialty: { type: "string", enum: SPECIALTIES },
summary: { type: "string", description: "One line: symptoms · duration · severity/10" }
}
},
handler: (args) => dispatch(ctx, args ?? {})
}]]);
}
// ---- doctor commands (all in the doctor's own Doctor Finder chat) ----
bot.onCommand("claim", async (ctx) => {
const doctor = dispatcher.doctorByUserId(ctx.sender);
if (!doctor) { return; }
const result = dispatcher.claim(ctx.args[0] ?? "", ctx.sender);
if (!result.ok) {
if (result.reason === "busy") {
await ctx.sendChoices("Finish your current case first.",
[{ label: "Close current case", value: `/close ${result.ticket?.id ?? ""}`, silent: true }]);
return;
}
const message = {
unknown: "I don't know that ticket.",
not_candidate: "That ticket wasn't dispatched to you.",
taken: "Too late — another doctor already took that one.",
already_yours: "That one's already yours."
}[result.reason];
await ctx.sendText(message);
return;
}
const ticket = result.ticket;
const patientId = patientByRoom.get(ticket.patientRoomId);
const title = `${doctor.name} · ${ticket.specialty}`;
// The case group: created BY the bot, so the bot owns it outright —
// it can invite, rename, and post without any power walls. This is
// where the humans talk AND call: bot chats are text-only surfaces,
// groups are where people meet.
const caseRoomId = await bot.createGroup(
ticket.patientRoomId,
title,
[patientId, doctor.atlasUser].filter(Boolean)
);
caseRooms.add(caseRoomId);
ticket.caseRoomId = caseRoomId;
await bot.sendText(caseRoomId,
`${doctor.name}, meet your patient. ${ticket.summary}\n` +
"This chat is your case room — you can call each other from here. " +
"Doctor: close the case from your Doctor Finder chat when you're done.");
// Command-bridge demo: a follow-up todo lands on the patient's
// Atlas — only if they've granted Doctor Finder that capability.
await bot.addTodo(ticket.patientRoomId, { title: `Follow up with ${doctor.name}` }).catch(() => {});
await bot.sendText(ticket.patientRoomId,
`${doctor.name} has taken your case — I've opened a chat for the two of you. ` +
"It should appear in your chats now.");
await ctx.sendChoices(
`You've got it. ${ticket.summary}\n` +
"Your case room with the patient is opening now.",
[{ label: "Close case", value: `/close ${ticket.id}`, silent: true }]);
// Live-edit every dispatch: the winner's freezes as taken-by-you,
// the losers' gray out with who took it — no extra messages.
await freezeDispatch(ticket, ctx.sender, "You took this case.");
for (const otherUser of Object.keys(ticket.notifiedRooms)) {
if (otherUser !== ctx.sender) {
await freezeDispatch(ticket, otherUser, `Taken by ${doctor.name}.`);
}
}
});
bot.onCommand("close", async (ctx) => {
const doctor = dispatcher.doctorByUserId(ctx.sender);
if (!doctor) { return; }
const id = ctx.args[0] ?? dispatcher.activeTicketForDoctor(ctx.sender)?.id ?? "";
const result = dispatcher.close(id, ctx.sender);
if (!result.ok) {
await ctx.sendText(
result.reason === "not_yours"
? "Only the doctor who claimed a case can close it."
: "No open case by that id. /mycase shows your active one.");
return;
}
const ticket = result.ticket;
await departCaseRoom(ticket, doctor,
`${doctor.name} has closed this case. Take care! You can keep or delete this chat.`);
await bot.sendText(ticket.patientRoomId,
"Your case is closed. I'm back — tell me if anything else comes up.");
await ctx.sendText(`Case ${ticket.id} closed.`);
});
bot.onCommand("pass", async (ctx) => {
if (!isDoctor(ctx.sender)) { return; }
const result = dispatcher.pass(ctx.args[0] ?? "", ctx.sender);
if (!result.ok) {
await ctx.sendText("That ticket is already settled.");
return;
}
await freezeDispatch(result.ticket, ctx.sender, "You passed.");
if (result.ticket.status === "unfilled") {
await bot.sendText(result.ticket.patientRoomId,
"All on-call doctors are unavailable right now. Please check back shortly — " +
"and if things get worse, call your local emergency number.");
}
});
const onCallButtons = (doctor) => [
doctor.onCall
? { label: "Go off call", value: "/oncall off", silent: true }
: { label: "Go on call", value: "/oncall on", silent: true }
];
bot.onCommand("oncall", async (ctx) => {
const doctor = dispatcher.doctorByUserId(ctx.sender);
if (!doctor) { return; }
const choice = (ctx.args[0] ?? "").toLowerCase();
if (choice !== "on" && choice !== "off") {
await ctx.sendChoices(
`You are currently ${doctor.onCall ? "ON" : "OFF"} call.`,
onCallButtons(doctor));
return;
}
dispatcher.setOnCall(ctx.sender, choice === "on");
await ctx.sendChoices(
`Done — you are now ${choice === "on" ? "ON" : "OFF"} call.`,
onCallButtons(dispatcher.doctorByUserId(ctx.sender)));
});
bot.onCommand("mycase", async (ctx) => {
if (!isDoctor(ctx.sender)) { return; }
const ticket = dispatcher.activeTicketForDoctor(ctx.sender);
if (!ticket) {
await ctx.sendText("No active case.");
return;
}
await ctx.sendChoices(
`Active case ${ticket.id}: ${ticket.summary}`,
[{ label: "Close case", value: `/close ${ticket.id}`, silent: true }]);
});
bot.onCommand("start", async (ctx) => {
const doctor = dispatcher.doctorByUserId(ctx.sender);
if (doctor) {
await ctx.sendChoices(
"Welcome, doctor. Dispatches arrive right here with Claim and Pass " +
"buttons — taking a case opens a group chat with the patient.",
onCallButtons(doctor));
return;
}
await ctx.sendText(WELCOME_MESSAGE);
});
// ---- patient flow ------------------------------------------------------
bot.onCommand("resume", async (ctx) => {
// Patient-side escape hatch: cancels a still-open request, or ends
// their active case, from the bot chat.
const openTicket = dispatcher.openTicketForPatientRoom(ctx.roomId);
if (openTicket && dispatcher.cancel(openTicket.id).ok) {
// Gray out every paged doctor's dispatch — the moment is over.
for (const doctorUser of Object.keys(openTicket.dispatchEvents ?? {})) {
await freezeDispatch(openTicket, doctorUser, "Canceled by the patient.");
}
await ctx.sendText("Okay — request canceled. Tell me what's going on whenever you're ready.");
return;
}
const ticket = dispatcher.activeTicketForPatientRoom(ctx.roomId);
if (ticket) {
dispatcher.close(ticket.id, ticket.claimedBy);
// Same wind-down as /close: doctor and bot depart, the patient
// keeps the (now read-only) case chat.
await departCaseRoom(ticket, dispatcher.doctorByUserId(ticket.claimedBy),
"The patient has ended this case. Take care!");
const doctorRoom = ticket.notifiedRooms[ticket.claimedBy];
if (doctorRoom) {
await bot.sendText(doctorRoom, `The patient ended case ${ticket.id}.`);
}
}
await ctx.sendText("I'm back. Tell me what's going on and I'll find you a doctor.");
});
bot.onMessage(async (ctx) => {
if (caseRooms.has(ctx.roomId)) { return; } // humans talking in a case group
if (isDoctor(ctx.sender)) {
const doctor = dispatcher.doctorByUserId(ctx.sender);
const ticket = dispatcher.activeTicketForDoctor(ctx.sender);
if (ticket) {
await ctx.sendChoices(
"Your case with the patient is in your case room.",
[{ label: "Close case", value: `/close ${ticket.id}`, silent: true }]);
} else {
await ctx.sendChoices(
"Dispatches arrive right here with Claim and Pass buttons.",
onCallButtons(doctor));
}
return;
}
if (dispatcher.activeTicketForPatientRoom(ctx.roomId)) {
await ctx.sendChoices(
"Your case is open — talk with your doctor in the case chat.",
[{ label: "End case", value: "/resume", silent: true }]);
return;
}
if (dispatcher.openTicketForPatientRoom(ctx.roomId)) {
// Paged, awaiting a claim — offer the way out instead of silence.
await ctx.sendChoices(
"I've paged the on-call doctors — hang tight.",
[{ label: "Cancel request", value: "/resume", silent: true }]);
return;
}
// The red-flag prefilter runs BEFORE any model call — emergencies
// never wait on an LLM.
if (hasRedFlag(ctx.body, redFlags)) {
await ctx.sendText(EMERGENCY_MESSAGE);
return;
}
// The platform model words the triage conversation and calls
// find_doctor when it knows enough; the deterministic rails above
// and inside dispatch() never move.
let turn;
try {
turn = await ctx.think({ tools: findDoctorTool(ctx) });
} catch (error) {
console.log(`[finder] think failed: ${error.message}`);
await ctx.sendText(
"I'm having trouble right now — please try again in a moment. " +
"If this is an emergency, call your local emergency number.");
return;
}
const dispatched = turn.toolCalls.find((call) => call.name === "find_doctor");
if (turn.reply) {
await ctx.sendText(turn.reply);
} else if (dispatched?.result?.ok) {
// A paged patient is always told something, model or no model.
const { paged, specialty } = dispatched.result;
await ctx.sendText(
`I'm paging ${paged} on-call ${specialty} doctor${paged === 1 ? "" : "s"} now; ` +
"when one takes your case I'll open a chat for the two of you.");
} else if (dispatched) {
await ctx.sendText(
"I couldn't find a doctor on call right now. Please try again soon — " +
"and if things get worse, call your local emergency number.");
}
});
return { dispatcher, caseRooms };
}
// ---- runnable entry ------------------------------------------------------
const here = path.dirname(fileURLToPath(import.meta.url));
export function loadRoster(rosterPath = path.join(here, "roster.yaml")) {
return parseYAML(fs.readFileSync(rosterPath, "utf8"));
}
/// The roster references doctors by the PHONE NUMBER on their Atlas
/// account — the identity a clinic actually knows — never by a raw matrix
/// id. One directory call at startup resolves every phone to the account;
/// numbers that aren't on Atlas are skipped with a log line.
export async function resolveRoster(roster, directory) {
const doctors = roster?.doctors ?? [];
const matches = await directory.resolvePhones(
doctors.map((doctor) => String(doctor.phone ?? "").trim()).filter(Boolean));
const resolved = [];
for (const doctor of doctors) {
if (!doctor.phone && doctor.atlas_user) {
// Escape hatch for accounts that have no phone number (e.g. a
// synthetic demo doctor on a bot account). Humans go by phone.
resolved.push(doctor);
continue;
}
const match = matches[String(doctor.phone ?? "").trim()] ?? null;
if (!match) {
console.log(`[finder] no Atlas account for ${doctor.name} (${doctor.phone ?? "no phone"}) — skipping`);
continue;
}
resolved.push({ ...doctor, atlas_user: match.matrixUserId });
}
return { ...roster, doctors: resolved };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
// The directory client resolves the roster's phones and powers
// createGroup's auto-accepted invites.
const directory = createDirectoryClient({
adminProxySecret: process.env.ATLAS_ADMIN_PROXY_SECRET
});
const bot = createAtlasBot({
accessToken: required("ATLAS_BOT_TOKEN"),
displayName: "Doctor Finder",
instructions: TRIAGE_INSTRUCTIONS,
dataPath: path.join(here, "data", "state.json"),
directory
});
const roster = await resolveRoster(loadRoster(process.env.DOCTOR_ROSTER), directory);
if (roster.doctors.length === 0) {
console.error("[finder] no roster doctors resolved — check the phone numbers and ATLAS_ADMIN_PROXY_SECRET");
}
attachDoctorFinder(bot, { roster });
bot.start();
}
function required(name) {
const value = process.env[name];
if (!value) {
console.error(`Missing required env var ${name}`);
process.exit(1);
}
return value;
}
Run it
npm install atlas-bot-sdk
ATLAS_BOT_TOKEN=<from BotFather> node bot.js
Put the doctors' Atlas phone numbers in roster.yaml; doctors DM the bot /start to see their commands. Every flow on this page runs against fixture payloads and a scripted model — no platform needed.