Appearance
Cross-shop availability and the commit-tool pattern
One-line summary. Two coupled changes to the AutoOps booking path: (1) extend
autoOpsGetAvailabilityto always fan out across the calling shop plus every shop in its routing config, returning a unified multi-shop menu in one call; (2) introduce aconfirmAppointmentSelectioncommit tool the agent invokes when the caller agrees to a slot, which carries the booking target as structured args. The post-call workflow trusts those args over today's transcript LLM extraction. Cross-shop booking falls out of the commit tool'sshopClientIdparameter for free.
Why this is a proposal, not a PR
This changes two production surfaces (the availability tool and the post-call booking workflow) and introduces a new commit primitive that should eventually apply to every booking, not just the cross-shop case. The blast radius warrants a documented design before code lands. Implementation follows acceptance.
Problem statement
Two related issues with the current AutoOps booking path:
1. Single-shop horizon. autoOpsGetAvailability only checks the calling shop. If a customer calls EAS Centennial for an oil change but Centennial has nothing this week, the agent can only decline or take a message, even though Chatfield or South Park may have plenty of openings within EAS's enterprise routing rules.
2. Booking target is re-derived from the transcript. When the call ends, runAutoOpsBookingWorkflow calls runAutoServiceExtraction to run an LLM pass over the transcript to figure out which slot the customer agreed to. The agent already had structured data from autoOpsGetAvailability showing the exact slot, but that data is thrown away and re-parsed from natural language. Failure modes include misinterpreting day, time, service, or appointment type. The bookings still ultimately work today because EAS is single-shop and the transcripts are usually clear, but the path is structurally fragile and has no signal to extend to cross-shop booking.
The cross-shop case makes today's transcript-extraction fragility much harder to tolerate. A transcript-only path can in principle work: the agent mentions the shop name in its confirmation, and an LLM extraction with access to both the transcript and the availability tool's tagged-by-shop response could correlate the confirmed time to its source shop. In practice this would silently mis-book to the wrong shop in some fraction of calls because of name disambiguation, partial mentions, mishearings, and similar-shop-name collisions. A structured handoff via the commit tool is what makes the booking target reliable enough to ship.
Pushback worth pre-empting
A common objection: "the LLM is stateless, so the post-call extractor has the same context as the in-call agent and can re-derive the booking target from the transcript. The commit tool is just latency without information." This is incorrect in Avoca's current implementation. The post-call extractor receives a flat user/bot transcript string, not the in-call agent's structured tool-result history. The commit tool's value is structural certainty about a decision the in-call agent already made, not new information. Full architectural finding with code references: Does the post-call LLM see the same context as the in-call agent?.
Current architecture
Failure surfaces today:
- Transcript LLM extraction can pick the wrong slot, wrong day, wrong service, or fail silently when transcripts are ambiguous.
- The AutoOps client is built from
getAutoOpsConfig(teamId)whereteamIdis the calling team. Cross-shop booking is structurally blocked: even if the workflow knew the customer agreed to a slot at Shop B, it would still book against Shop A's tenant.
Proposed architecture
What changed:
- Availability tool returns a unified multi-shop menu on every call, with shop attribution on every slot.
- Agent calls a new commit tool when the caller agrees, locking in the booking target as structured data.
- Workflow reads the commit tool's args from
toolCalls[]instead of inferring booking target from transcript. - AutoOps client is resolved by
shopClientIdfrom the commit args, not the calling team's hardcoded id. Cross-shop booking becomes a parameter value, not a separate code path.
Change 1: extend autoOpsGetAvailability to fan out across shops
Behavior.
- Read the calling shop's routing config to determine which sibling shops to query.
- Fan out parallel availability checks to the calling shop plus every configured sibling shop. Always. No "local-first then fall back" conditional.
- Return a unified
slotsarray where every entry carriesshopClientIdandshopName. - Sort by some sensible policy (chronological by
slotTimeUtcis the simplest start).
The fan-out is always-on because the agent does not know in advance whether the caller will be picky about time, day, or shop. Having the full menu in context from the first tool call lets the agent answer follow-up questions ("what about Thursday?", "can we do another shop?") without a second round trip.
Return shape (additive, backwards compatible):
ts
{
// existing fields unchanged
message: string;
service: { id, name, ... };
availability: Array<{
date: string;
timeslots: Array<{
time: string; // slotTimeUtc
label: string; // "7:00 AM"
shopClientId: string; // NEW. Which shop this slot belongs to.
}>;
}>;
// NEW: per-shop metadata. Agent reads this when surfacing a cross-shop option
// so it can give the caller real details (address, distance) rather than just a name.
shops: Array<{
shopClientId: string;
shopName: string; // "EAS Chatfield"
address: string; // "6900 W Belleview Ave, Littleton, CO"
distanceMilesFromCallingShop: number; // 0 for the calling shop itself
}>;
}Per-shop metadata is in its own block (not duplicated on every slot) to keep the response compact when there are many slots across many shops.
Routing config storage (V1). Hardcoded inline map in the tool handler, keyed by calling-shop clientId. Each sibling entry carries the data the agent needs to describe the option to the caller (shop name, address, distance):
ts
interface SiblingShop {
shopClientId: string;
shopName: string;
address: string;
distanceMilesFromCallingShop: number;
}
const SHOP_ROUTING: Record<string, SiblingShop[]> = {
'cl_<EAS_CENTENNIAL>': [
{
shopClientId: 'cl_<EAS_CHATFIELD>',
shopName: 'EAS Chatfield',
address: '6900 W Belleview Ave, Littleton, CO',
distanceMilesFromCallingShop: 4.2,
},
{
shopClientId: 'cl_<EAS_SOUTH_PARK>',
shopName: 'EAS South Park',
address: '...',
distanceMilesFromCallingShop: 8.1,
},
],
// ...
};Distances are pre-computed once per shop pair and baked into the config, rather than calculated at call time. The numbers are static enough (shops don't move) to warrant the simplicity. Refactor to a shop_routing_targets table when the rule set grows or non-EAS clients adopt the pattern. The shape is the same either way; the storage swap is a small refactor.
Prompt rule to add. The tool returns slots from local plus all sibling shops in the routing config. The agent's job is to present them in a way that respects the caller's expectation that they called the local shop. Guidelines:
Local is primary. Never proactively offer a sibling shop when local has a reasonable match for the caller's preferred time.
When the caller's preferred time is not available locally, offer the closest local alternative first, then mention that the exact time is available at a nearby location, then offer to provide details. Preferred phrasing:
"We have 8am at Centennial. But if you need 7am, we have 7am at a nearby location. If you're interested in that, I can provide details."
This respects the local-primary principle (closest local offered first), surfaces the sibling option as a fallback for callers who want the exact time, and keeps the caller in control by asking before pivoting.
If the caller asks for details on the nearby option, read out shop name, address, and approximate distance from the calling shop. All three come from the
shopsblock in the availability response. Example: "It's our Chatfield location at 6900 West Belleview Avenue in Littleton, about 4 miles from Centennial. Would that work?" Do not skip details and jump straight to confirming the booking; the caller hasn't agreed yet because they don't know where they'd be going.Never push a sibling shop when the caller has not expressed flexibility on location. The caller phoned Centennial. Assume Centennial is the preference unless they say otherwise.
Use the same Milestone single-offer pattern for the actual booking once the caller picks a slot.
Change 2: new confirmAppointmentSelection commit tool
Purpose. Memorialize the agent's booking decision as structured data in the call record before the call ends. Replaces transcript LLM extraction as the source of truth for the booking target.
Tool signature:
ts
confirmAppointmentSelection({
shopClientId: string; // which shop the slot belongs to
serviceId: string; // AutoOps service id for the chosen service at that shop
slotTimeUtc: string; // exact slot time from the availability response
appointmentType: 'Waiter' | 'DropOff' | 'Pickup';
isNewCustomer: boolean;
})Server behavior (V1).
- Validate shape: required fields present, parseable timestamp,
shopClientIdin calling shop's routing allowlist (the calling shop itself always passes). - Return success.
- Do NOT re-check availability. See accepted risks.
The memorialization is automatic. Vapi logs every tool call (name + arguments) in the call record. The post-call workflow reads toolCalls[] to find this one and trusts its args.
Prompt rule to add:
Before saying "I'll get this submitted" (or any phrase confirming the booking), call
confirmAppointmentSelectionwith the exact slot the caller agreed to. Use theshopClientId,serviceId, andslotTimeUtcfrom theautoOpsGetAvailabilityresult for the chosen slot.
Why a no-op server matters. The tool call's round trip is masked by the existing request-start message ("Got it, submitting your request"). The customer experiences no perceived latency. The cost is one extra ~200ms server hop per booking call; the benefit is deterministic structured handoff for every booking.
Change 3: workflow reads commit-tool args, falls back to transcript
File: run-booking-autoops.ts.
New logic in runAutoOpsBookingWorkflow:
ts
// Before runAutoServiceExtraction:
const commitArgs = findCommitToolArgs(toolCalls);
// ^ looks for the latest confirmAppointmentSelection tool call in the call record
const extraction = await runAutoServiceExtraction(ctx, log);
// Booking target: prefer commit-tool args, fall back to extraction.
const bookingTarget = commitArgs ?? extractBookingTargetFromExtraction(extraction);
// Resolve AutoOps client by shopClientId from the commit, not by calling teamId.
const autoOpsConfig = await getAutoOpsConfigByClientId(bookingTarget.shopClientId);
const client = new AutoOps(autoOpsConfig.apiKey, autoOpsConfig.clientId);
// Customer info (name, phone, vehicle, issue) still comes from extraction.
const customer = extraction.normalizedData;The fallback path is load-bearing. It keeps in-flight calls and any conversation where the agent didn't invoke the commit tool from breaking when this ships. Worth keeping the fallback in place permanently as a defense in depth, even after the agent reliably calls the commit tool every time.
getAutoOpsConfigByClientId helper. May exist already as a thin wrapper around the autoops_team_configs lookup; verify during build. If not, add it. Lookup key is the AutoOps clientId, not the team_id, because the chosen shop may not be the calling team.
Security model
The clientId is a tenant identifier, not a credential. The API key is the authentication, and it stays server-side in autoops_team_configs. The agent never sees an API key.
The thing that needs protection is authorization to act on a given shopClientId from a given call. A prompt-injected or confused agent could otherwise pass an arbitrary clientId to the commit tool and try to write a booking to a tenant it has no business booking to. The server-side enforcement is:
ts
// Inside the confirmAppointmentSelection handler:
const callingShopClientId = getCallingShopClientId(req);
const allowlist = [callingShopClientId, ...SHOP_ROUTING[callingShopClientId] ?? []];
if (!allowlist.includes(args.shopClientId)) {
return { success: false, error: 'shopClientId not in routing allowlist' };
}This same allowlist check should be applied at the AVAILABILITY fan-out (Change 1) and at the WORKFLOW booking call (Change 3), so the rule is enforced at every layer the clientId crosses. Defense in depth.
Other security notes:
- Keep PII out of the commit tool's args. Booking target only (shop, slot, service, type). Customer name, phone, vehicle stay in the transcript extraction layer.
- No new exposure surface for clientIds. They already appear in Vapi call records and Datadog logs today.
- The
getAutoOpsConfigByClientIdlookup must verify that the clientId is owned by an Avoca-managed team. Without this, a malformed config or a typo in the routing map could resolve to no team at all and the booking would fail loudly; not a security issue but a robustness one.
V1 scope and accepted risks
In scope for V1:
autoOpsGetAvailabilityextension with always-on sibling-shop fan-out, hardcoded routing map.confirmAppointmentSelectiontool with shape validation + allowlist enforcement.- Workflow change to prefer commit-tool args, with transcript-extraction fallback retained.
- Prompt rule additions to the EAS blueprint for both new behaviors.
Out of scope for V1:
- Routing config in DB. Keep it inline until rules stabilize.
- Re-check at commit time. The slot may be taken between commit and post-call workflow. Accepted because today's risk (LLM transcript misinterpretation) is materially worse.
- Cross-CRM routing. AutoOps only. Same-CRM, same-tenant only.
- Feature flag gating. The fallback path in the workflow makes this safe to ship without one; the worst case is the commit-tool args are missing and we fall back to today's behavior.
Accepted risk: slot taken between commit and workflow execution.
Between the moment the agent calls the commit tool and the moment the post-call workflow runs the actual AutoOps booking call, the chosen slot can be taken by someone else. The customer is told "I'll get this submitted" and the workflow then fails to book. Customer doesn't learn until the shop calls back.
The window is the time between commit and workflow execution. Typically seconds-to-minutes, so the practical exposure is small. Today's behavior is worse in expected value: transcript LLM extraction can land on the wrong slot, wrong day, wrong service entirely.
V2 path: add a final availability re-check inside confirmAppointmentSelection:
- Server calls
autoOpsGetAvailabilityfor the chosen shop/service at commit time. - Confirms
slotTimeUtcis still in the returned set. - Returns error if not, so the agent can recover live ("That slot just got taken, would 8 AM work instead?").
- Cost: roughly 500ms extra latency per booking, masked by the request-start message.
Trigger to upgrade: call data showing a measurable rate of commit-time slots that are no longer available at workflow execution time. Until then, V1's simplicity wins.
Architectural note worth carrying forward
The general pattern is broader than this feature: in-call structured commit via a no-op tool, post-call workflow trusts structured args over transcript inference. Anywhere the workflow currently re-derives a decision the agent already made, the same upgrade applies. Naming this pattern explicitly (something like "tool-call-as-commit-point" or "in-call decision memorialization") gives the team vocabulary for recognizing and applying it elsewhere.
See also
architecture/commit-tool-context-analysis— finding that answers "does the post-call LLM see the same context as the in-call agent?" Establishes why the commit tool's value is structural certainty (not new information) and why option (c) (commit tool) is strictly better than option (b) (rewrite the extractor to consume structured messages). Cited code paths verify the gap.architecture/tool-calls/— canonical reference for how tool calls work in Avoca. The deterministic-dispatch-with-structured-rejection variant of the move-LLM-reasoning-into-tool pattern is documented there.archive/transfer-call-time-routing/(shipped 2026-05-15 via PR #10521) — sibling architectural plan that applies the same "move LLM reasoning into a deterministic tool" approach to transfer-window decisions.checkTransferWindowis the first instance of the pattern;autoOpsConfirmAppointmentwill be the second when this plan ships.