DEV Community

jareer nauman
jareer nauman

Posted on Originally published at keencraft.tech

Multi-Location Voice Agents: Bind the Call to a Location Before Any Tool Runs cross sites.

The caller wanted 2 p.m. at the south office. The agent found 2 p.m. and booked it. The event landed on the north office calendar, because that was the calendar the demo used. The model pronounced the time perfectly.

That bug is the whole problem with multi-location voice agents. A single-location agent assumes one number, one set of hours, one staff list, and one calendar. Adding a second site doesn't add a setting. It breaks that assumption everywhere.

I lead development on Dynaris, a live AI front desk where each workspace's tools are isolated so one location can't book another's calendar. This post covers the patterns that make that work. The code below is a simplified illustration of the pattern, not production source.

The four failures that show up in week one

  1. Right time, wrong office. The slot exists. It exists somewhere else.
  2. Wrong hours. A shared hours string quotes the flagship's 5 p.m. close for a site open until 7.
  3. Shared provider double-booked. A clinician works mornings at one site and afternoons at another. Each site's calendar looks free at 2 p.m.
  4. Records leaking across sites. A phone-number lookup returns another office's contact, notes, or mailbox, and the agent can read it out loud.

All four have the same root cause: the location was treated as context in a prompt instead of a boundary in the system.

Rule 1: bind the call to a location before any tool runs

The location has to be resolved before the agent can touch a calendar or a record. There are three ways a call maps to a location:

Routing How location is resolved What breaks
One number per location The dialed number is the location Callers who dial the wrong office
One central number The agent asks, or matches an existing record Guessing, or defaulting to one calendar
Hybrid Local numbers skip the question; the central line asks Two paths writing location differently

With one number per location, binding is a lookup at call setup:

LOCATION_BY_NUMBER = {
    "+15125550101": "loc_south",
    "+15125550102": "loc_north",
}

def bind_call(dialed_number: str) -> str | None:
    """Resolve the location before the agent gets any tools."""
    return LOCATION_BY_NUMBER.get(dialed_number)
Enter fullscreen mode Exit fullscreen mode

If it returns None (a central line), the agent's first job is to resolve the location by asking or by matching a record. Until it does, it gets no booking tools at all. What it must never do is fall back to a default calendar. That fallback is exactly how the north office fills up with south office patients.

Rule 2: the location is not a tool parameter

Here's the mistake that causes failure #1: giving the model a book_appointment(location_id, slot) tool and trusting the prompt to pass the right location_id. The prompt is not a permission. Eventually the model passes the wrong one.

Instead, build the tools after binding, with the location closed over, so the model can't choose it:

from dataclasses import dataclass
from datetime import date, datetime

@dataclass
class Slot:
    start: datetime
    end: datetime

def overlaps(a: Slot, b: Slot) -> bool:
    return a.start < b.end and b.start < a.end

def build_tools(location_id: str, calendars, availability):
    """Tools scoped to one location. location_id is not an argument."""

    async def list_open_slots(service: str, provider_id: str, day: date) -> list[Slot]:
        open_slots = await calendars.open_slots(location_id, service, provider_id, day)
        busy = await availability.busy_blocks(
            provider_id, day, exclude_location=location_id
        )
        return [s for s in open_slots if not any(overlaps(s, b) for b in busy)]

    async def book(slot: Slot, provider_id: str, contact_id: str, idempotency_key: str):
        # Re-check at write time: the slot can be taken between offer and confirm.
        busy = await availability.busy_blocks(
            provider_id, slot.start.date(), exclude_location=location_id
        )
        if any(overlaps(slot, b) for b in busy):
            raise SlotTaken(slot)
        return await calendars.create_event(
            location_id, slot, provider_id, contact_id, idempotency_key
        )

    return [list_open_slots, book]
Enter fullscreen mode Exit fullscreen mode

Two details matter here. The re-check inside book() handles the race between offering a time and confirming it, which is common when two sites share a provider. And the idempotency_key means a retry after a timeout doesn't create a second appointment.

On Dynaris this boundary goes further than a closure: each workspace runs its own MCP tool servers, so the agent serving one location is connected only to that location's calendar, mailbox, and records. A closure is the in-process version of the same idea. Either way, isolation is enforced by what the agent is connected to, not by what it's told.

Rule 3: shared staff need a busy/free service, not shared calendars

Failure #3 is where people undo their own isolation. To check a shared provider's other site, the tempting fix is to give site A's agent read access to site B's calendar. That brings failure #4 straight back, because now site A's agent can see site B's patients.

The fix is a small service that answers one question, "is this person busy?", and returns nothing else:

from fastapi import FastAPI

app = FastAPI()

@app.get("/providers/{provider_id}/busy")
async def busy_blocks(provider_id: str, day: date, exclude_location: str):
    blocks = await store.provider_blocks(provider_id, day)
    return [
        {"start": b.start.isoformat(), "end": b.end.isoformat()}
        for b in blocks
        if b.location_id != exclude_location
    ]
Enter fullscreen mode Exit fullscreen mode

No patient name, no reason for visit, no notes. Just occupied intervals. Booking site A then becomes two reads and one write:

  1. Read site A's open slots for that provider.
  2. Read the provider's busy blocks everywhere else.
  3. Offer only the overlap that is free at both.
  4. Write the appointment to site A's calendar only.

Rule 4: one person, many appointments

The location owns the appointment. The person can exist once for the whole group. Match on phone or email before creating a contact, so a patient seen at two offices ends up as one person with two appointments, each carrying its own location field. The two duplicates that actually hurt are two contacts for one person, and one appointment written onto the other office's calendar.

Reporting: one row per location

Leadership needs a cross-location view, but it should count outcomes, not hand anyone another site's records. Report the same metrics per location (answer rate, booked rate, transfers, after-hours bookings, missed-call recovery) side by side. A group average hides the site that never books.

Rolling it out

Turning on every site at once multiplies the wrong-office bug by the number of calendars. Pilot one location in parallel with the front desk, verify every booking lands on the right calendar with the right provider, then add locations one at a time, each as its own workspace with its own tools.


The full version, including routing tradeoffs for central numbers, urgent-call escalation per location, and when a vertical product is the better buy, is on keencraft.tech.

Top comments (0)