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):

ButtonEffect
Claim / PassOn 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 caseOn 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 callOn the welcome and status messages — off-call doctors are never paged.
End caseThe 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]
);

What's deterministic vs. model judgment

DecisionMade by
Emergency escalationKeyword prefilter — before any LLM call
Conversation wording & when to dispatchThe platform LLM, via the find_doctor tool
SpecialtyModel-proposed, deterministically validated — allowlist clamp with keyword fallback
Doctor assignmentFirst-/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 unreachableFixed apology + emergency-number reminder; paged patients always get the paging confirmation
Loop safetyThe 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.