Most calls to a clinic can be handled end to end by an agent. Some can’t, and shouldn’t be: a clinical question, an upset caller, a billing dispute, someone who simply wants a person. How the agent hands those calls off decides whether patients trust it.
A bad transfer is easy to recognise from the caller’s side. You explain your problem, get transferred, and explain it again to someone who has no idea who you are. Or you’re transferred to a line that’s closed. Or the transfer fails and the call just ends.
This post covers the routing design behind transfers on our platform at Simbie AI.
Separate “why” from “where”
The first version of transfers, like most first versions, had phone numbers in the prompt. “If the caller asks about billing, transfer to +1 555…”. It breaks the moment a clinic has more than one office, changes a number, or has different hours for different lines, and every change means editing a prompt and re-testing an agent.
The redesign splits the decision in two:
- The agent decides why the call needs a human, and expresses it as an intent.
- The backend decides where that intent should go, using a routing table per clinic.
The scenario resolver
When the agent calls transfer_call, it passes a reason and whatever it has learned. The resolver turns that into a scenario code, using the intent plus what the backend already knows about the call: is this an existing patient, which office did they call, is it within hours, has the agent already tried to book.
type TransferRequest = {
callId: string;
intent: "billing" | "clinical_question" | "new_patient" | "speak_to_staff" | "other";
summary: string; // the agent's one-line account of the call so far
};
export async function resolveTransfer(req: TransferRequest, ctx: CallContext) {
const scenario = resolveScenario(req.intent, ctx.callState); // e.g. "NEW_PATIENT_NORTH_OFFICE"
const route = await routingTable.lookup(ctx.clinicId, scenario, ctx.now);
if (!route || !route.isOpen) return fallback(req, ctx);
const handoff = buildHandoff(req, ctx); // who, why, what's already done
await ctx.callState.record("transfer_requested", { scenario, route: route.id });
switch (route.kind) {
case "line": return ctx.telephony.transfer(route.number);
case "office": return transferToOffice(route, handoff, ctx);
case "warm": return warmTransfer(route, handoff, ctx);
}
}Scenario codes are also what we report on afterwards. “How many new-patient calls for the north office ended in a transfer, and why?” is a query, not a transcript-reading exercise.
The routing table
Routing lives in configuration per clinic: scenario → office → line, plus hours, holiday overrides and an overflow rule. Clinic staff and our team can change where a scenario goes without touching the agent, and a routing change doesn’t need a new agent version.
Four ways a call can leave the agent
Same-office line. The simple case: a blind transfer to the right queue at the office the caller dialled.
Cross-office transfer with hand-off context. Clinics with several offices often have one number per office, but patients call whichever number they have. When the right destination is another office’s agent, the backend passes a structured hand-off along with the call: who the caller is (if matched), why they’re calling, and what’s already been done. The receiving agent starts from that context instead of from “Hello, how can I help?” That’s what makes a multi-office clinic feel like one front desk instead of several.
Warm transfer to staff. For calls that need a person, staff see the caller’s details and the agent’s summary in the web app before they pick up. The caller doesn’t have to repeat themselves, and staff can decline and send it back if they’re the wrong person.
Fallback: take a message. If no one answers, the line is closed, or the transfer fails, the call doesn’t end. The agent tells the caller, takes a message, and the backend files it into the EHR as a telephone encounter on the patient’s chart, where clinic staff already work. Nothing is lost in a voicemail box nobody checks.
Making transfers testable
Transfers are high-stakes and annoying to test by phone. Two things helped:
- Routing decisions are pure functions of scenario, clinic config and time. They’re unit-tested with a table of cases per clinic: “new patient, north office, Saturday” → fallback.
- The telephony action is behind an interface, so the call simulation framework can assert “this call would have been transferred to the downtown office with this hand-off” without placing a real call.
What I’d keep from this design
- Keep phone numbers out of prompts. Prompts describe reasons; config holds destinations.
- Make the hand-off a first-class, structured object, not a sentence in a transcript.
- Every branch must end with a human eventually seeing the call. The fallback is part of the design, not an error path.
- Name your scenarios. Once they have codes, you can measure, route and improve them independently.