Appearance
Tool calls in Avoca
Status. Canonical reference for how tool calls work in Avoca's stack. First entries shipped via
transfer-call-time-routing(PR #10521, merged 2026-05-15). New sections land as additional plans surface findings; the in-flightcross-shop-availability-commit-toolplan adds the commit-tool primitive when it ships. Sections marked_TBD_are placeholders.
Why this page exists
There is no canonical writeup today of how Avoca's tool-call system works. Decisions about touching a feature repeatedly hit questions like:
- Is this behavior in Blueprint, or non-Blueprint, or both?
- Where does a tool get registered so the agent can call it?
- What's the difference between an in-call tool and a post-call workflow stage?
- When the agent doesn't call an expected tool, what gates could be filtering it out?
This page builds canon for those questions. As of 2026-05-15 it's a single growing page; sections will be promoted to their own pages once they're substantial enough to stand alone.
How tool calls work, end to end
Foundational mechanics most "why didn't this tool fire?" or "where does this run?" questions decompose into. Everything below on this page assumes this.
The LLM sees two separate things every turn
- The system prompt (plain English). Instructions like "If the caller asks about billing, hand off to the billing agent."
- The tool definitions (structured JSON, sent alongside the prompt). A list shaped like
[{ name, description, parameters: <JSON schema> }]. The LLM can only invoke tools present in this list, no matter what the prompt says.
The prompt tells the LLM when and why to invoke a tool. The tool definitions tell it what tools exist and what arguments they take. Both ship in the same API request to the LLM, in different fields.
In Blueprint, the author writes prompt text (often referring to tool names by their description), and the dispatcher constructs the tool definitions list from the sub-agent's tools array at call-start. The Blueprint author never hand-writes the JSON. In Vapi's dashboard, the UI editor builds the same JSON under the hood; the user picks tools from a dropdown.
Common failure mode: prompt instructs the LLM to do X, but no matching tool is in the tool definitions list. The LLM will speak about doing X (or hallucinate a response) but cannot actually invoke anything. The fix is upstream of the prompt: register the tool, then verify the prompt.
Two execution paths after the LLM emits a tool call
When the LLM picks a tool and emits its arguments, what happens next depends on the tool's type:
Vapi-native tools (transferCall, endCall, handoff, plus a handful of others). Vapi's runtime intercepts the tool call and executes it itself. Phone-system actions (dialing transfers, hanging up), assistant swaps for handoff, all happen inside Vapi. Avoca's backend is not in the loop. Observable only via the call record / transcript.
Custom function tools (autoOpsGetAvailability, autoOpsConfirmAppointment, all CRM-prefixed Avoca tools). Vapi POSTs the tool call to the assistant's server.url. Avoca's backend executes the handler, returns a result. Vapi feeds the result back into the LLM's context as the tool's response message, and the LLM continues the conversation.
| Tool type | Execution venue | Avoca server sees the invocation? | Result returned to LLM? |
|---|---|---|---|
Vapi-native (transferCall, endCall, handoff) | Vapi runtime | No | No (action, not response message) |
Custom function (autoOps* and all Avoca handlers) | Avoca backend webhook (server.url) | Yes | Yes (next-turn tool message) |
Why this matters for FDE work: any Vapi-native tool is fire-and-forget from Avoca's perspective. There is no opportunity to intercept, modify, or add logic mid-execution. Work that needs to influence the action (e.g., cross-Blueprint resolution for handoff, time-of-day filtering for transferCall) must happen at compile time, before the tool definition is shipped to Vapi at call-start. This is why all of transfer-call-time-routing's gating logic, and any future cross-Blueprint handoff logic, lives in the dispatcher rather than in a webhook handler.
Blueprint vs non-Blueprint surfaces
Verified observations (2026-05-15)
Blueprint = Multi-Agent assistant compile path. The Blueprint surface, in code, is createMultiAgent in apps/web/lib/voice-assistants/agents/agent-factory.ts (line 351). Multi-Agent assistants compose sub-agents whose tool lists are built dynamically based on team config, voice-assistant access, and runtime state (including time-of-day).
Non-Blueprint = legacy team-level Vapi tools. The non-Blueprint surface uses syncWarmTransferToolForTeam / createWarmTransferTool in apps/web/lib/vapi/sync-warm-transfer-tool.ts to maintain a persistent team-level Vapi warmTransfer tool. Legacy VapiAgent and BUILDER_ASSISTANT modes reference this pre-existing toolId. The tool is built off transfer_destinations.active=true with no time-window filter — agents in this mode see the transfer tool regardless of hours.
The two surfaces behave differently today, and the divergence is shipping:
| Surface | Time gate at tool-list inclusion? | Mechanism |
|---|---|---|
Blueprint (createMultiAgent) | Yes | getFilteredTransferDestinations runs filterDestinationsByTimeWindow at compile time; tool omitted when filtered list is empty |
Non-Blueprint (syncWarmTransferToolForTeam) | No | Persistent team-level tool; built off all active destinations regardless of hours |
This is one of the first concrete examples of why "is this a Blueprint thing?" matters for FDE work: a feature touching the transfer-call tool list will behave differently across the two surfaces, and the divergence isn't documented anywhere else in the codebase.
Still under investigation as part of blueprint-vs-non-blueprint: what determines which surface a given shop / call uses today. Adjacent existing canon: agent-architecture-legacy-vs-current.md.
Tool registration lifecycle
Verified observations (2026-05-15) — transfer-call path on Blueprint
The path from "team has transfer destinations" → "agent has the transfer tool available":
- Source data:
transfer_destinationstable (per-team rows; each hasactive,time_based_enabled,enabled_on_holidays, plus identity fields likenumber,name,description). Each destination links to one or moretransfer_destination_windowsrows (FKtransfer_id; fieldsday_of_week,start_time,end_time). - Gate:
getFilteredTransferDestinations({ teamId, voiceAssistantId })inapps/web/lib/supabase/transfer-destinations.tsruns a pipeline: DB query (active=true) →filterByVoiceAssistantAccess→filterDestinationsByTimeWindow→filterDestinationsByHoliday. - Time-window check:
filterDestinationsByTimeWindow(lines 219-265) fetches the relevanttransfer_destination_windows, computes(currentDay, currentTime)in the team timezone viagetCurrentDayAndTime(teamId), and filters per-destination: destinations withtime_based_enabled=falsealways pass; destinations withtime_based_enabled=truesurvive only if the current time falls within one of their windows. - Compile-time inclusion:
createMultiAgent(inagent-factory.ts) calls the filter at lines 461 (cold transfer) and 419 (assisted transfer). If the filtered list is empty,coldTransferUnavailable = true(line 477) andToolName.TRANSFER_CALLis removed from the sub-agent's tools array at line 482-510. - Payload build: When destinations exist,
buildAssistedTransferTool(line 812) →getVapiOpenAIModelTool→buildWarmTransferToolPayloadinapps/web/lib/vapi/warm-transfer-tool-builder.tsshapes the destinations into the VapitransferCalltool payload. This builder is purely a payload shaper — it has no time-gating logic; it just renders whatever it's handed.
Key takeaway for FDE work: the gate logic is upstream of the payload builder. Touching buildWarmTransferToolPayload won't change gating behavior; touching filterDestinationsByTimeWindow or its callers will. This is why naive "tool not appearing" debugging starts at the wrong end.
TBD — the analogous path for autoOpsGetAvailability (cross-shop plan adds three AutoOps tools through this lifecycle). The FDE-routing two-surface gotcha (Plan P, see how-to/local-dev-env.md) means tool dispatches and workflow events route to different URLs; capture how that interacts with the lifecycle.
Handoff: how it works today
Scope
Discovery for cross-blueprint-handoff (D1). The existing HandoffTool is the primitive a new cross-Blueprint variant will extend. Companion: cross-shop-routing-mechanism-research establishes mechanism feasibility; this section establishes the code path.
Avoca's HandoffTool is a thin compile-time wrapper around Vapi's first-class handoff tool type. It emits one tool per Blueprint edge into the originating sub-agent's tool list. The constraint it operates under: destinations are always intra-squad (same compiled MultiAgent), referenced by assistantName. Cross-squad and inline destinations are unused.
The Vapi tool DTO we emit
ts
// apps/web/lib/voice-assistants/tools/handoff-tool.ts:13-29
getVapiOpenAIModelTool(): Vapi.CreateHandoffToolDto {
return {
type: 'handoff',
destinations: [
{
type: 'assistant',
assistantName: this.toSubAgentName,
description: this.description,
contextEngineeringPlan: { type: 'all' },
},
],
...(this.functionName && {
function: { name: this.functionName },
}),
messages: [],
};
}Field-by-field:
type: 'handoff'. Vapi tool kind; runtime intercepts it, our server never sees the invocation (same dispatch model astransferCall).destinations[0].type: 'assistant'+assistantName. Resolution is by name against squad members. NotassistantId; Blueprint doesn't register assistants.description. Compiled from the Edge's Liquidcondition(see emission path below). This is what the LLM sees as the tool's selection criterion.contextEngineeringPlan: { type: 'all' }. Hard-coded. Full transcript + tool calls + tool results forwarded. The other four modes are unused.function.name(conditional). Overrides the default tool name; set per Edge when authored.messages: []. Empty means silent handoff (no spoken filler during the swap). See Vapi's silent handoffs.
Blueprint authoring → compile-time emission
Edges are authored as graph entries on the Blueprint (fromSubAgentName, toSubAgentName, Liquid condition). At compile time:
ts
// apps/web/lib/voice-assistants/agents/multi-agents/edge.ts:25-31
getHandoffTool(variables: CompilerVariable[]): HandoffTool {
return new HandoffTool(
this.config.toSubAgentName,
this.compileCondition(variables),
this.config.name
);
}The sub-agent filters edges by its own name and turns each into a HandoffTool, then merges them into the model's tool list:
ts
// apps/web/lib/voice-assistants/agents/multi-agents/sub-agent.ts:100-105, 183-187
return edges
.filter((edge) => edge.config.fromSubAgentName === this.config.name)
.map((edge) => edge.getHandoffTool(variables));
// ...
const rawModel = (this.config.modelOverride || model).getVapiModel(
[...this.config.tools, ...this.getHandoffTools(edges, variables)],
compiledSystemPrompt,
this.config.toolIds
);The compiled squad is the per-call Vapi.CreateSquadDto returned by MultiAgent.getVapiAssistantReq() (apps/web/lib/voice-assistants/agents/multi-agents/multi-agent.ts:98-105). Squad members are built one-per-sub-agent in buildVapiSquad (multi-agent.ts:489-543). No persistent squad in Vapi; everything is transient and rebuilt each call.
Vapi runtime dispatch
When the LLM calls the handoff tool, Vapi's runtime intercepts it before any webhook fires. Per docs.vapi.ai/squads/handoff:
- Tool-type interception.
type: 'handoff'is dispatched inside Vapi, not forwarded to the assistant'sserver.url. - Destination resolution.
assistantNameis matched against the current squad'smembers[].assistant.name. (We useassistantName, neverassistantId.) - Context engineering. The plan's
typerewrites the new assistant's seed messages per the matrix below. - Active-assistant swap. The receiving assistant becomes the call's active assistant. New tool calls dispatch to its
server.url.
Our server is not in the path for the handoff itself. The handoff-destination-request webhook only fires for dynamic handoffs (Vapi's pattern where the destination is resolved by the server at handoff time). Avoca's emitted tools have static destinations[], so the webhook does not fire. This matches transferCall's pattern: a Vapi-native tool kind dispatched by the platform, observable only via call records and transcripts.
contextEngineeringPlan options
| Option | Transcript | Tool calls | Tool results | System prompt | variableValues | Notes |
|---|---|---|---|---|---|---|
all | Full history | Forwarded | Forwarded | Replaced | Preserved | Avoca's current default |
userAndAssistantMessages | User + assistant only | Stripped | Stripped | Replaced | Preserved | Use when receiver doesn't need tool history |
previousAssistantMessages | Only pre-handoff turns | Stripped | Stripped | Replaced | Preserved | PCI / sensitive flows; prevents tool-result leak |
lastNMessages | Last N (configurable) | Depends on cutoff | Depends on cutoff | Replaced | Preserved | Token-budget control |
none | None | None | None | Replaced | Preserved | Blank-slate handoff |
System prompt is always the destination's own; it never merges. variableValues are call-scoped and preserved through any intra-squad handoff.
Context-fidelity in practice at Avoca
Because HandoffTool hard-codes { type: 'all' }, every intra-squad handoff in production forwards:
- Full transcript (user, assistant, system, tool) up to the handoff turn.
- All prior
toolCalls[]and their results, including anyautoOpsConfirmAppointment-style commit-tool args. - Call-scoped
variableValues(caller-ID, customer fields, Blueprint-resolved metadata).
The receiver sees the originator's tool-call history as if it had made the calls itself. This is the load-bearing reason intra-squad handoff "just works" today: no re-fetching of customer context, no re-running of availability lookups.
Known constraints
- Intra-squad only. Resolution is by
assistantNameagainst the current squad. Sibling shops live in different compiled squads, so cross-shop handoff via this primitive is impossible without changes. assistantIddestinations not used. Blueprint doesn't register persistent assistants; nothing has anassistantIdto reference.- Inline destinations (
assistant: CreateAssistantDTO,squad: <inline>) not used. Vapi's schema accepts them and Avoca already emits the exactCreateSquadDtoshape they require (every call's initial payload), but no code path emits an inline-destination handoff tool. This is the gapcross-blueprint-handoffcloses. contextEngineeringPlanis hard-coded. No per-Edge override. Cross-Blueprint handoff withuserAndAssistantMessages(token-cost mitigation, see research doc) would require a builder change.- No HandoffTool / Edge unit tests. Verified by grep across
apps/web/lib/voice-assistants/**/__tests__/. The compile path is exercised only as a side-effect ofMultiAgent.getVapiAssistantReq()tests, none of which assert on handoff tool shape.
Failure modes
- Destination not in squad. Vapi docs don't explicitly document the error response when
assistantNamedoesn't resolve. The squad-membership requirement is stated as a constraint, not as an error contract. Avoca's code has no defensive check at compile time: if a Blueprint Edge references atoSubAgentNamethat isn't a sibling sub-agent, the tool is emitted unchanged and the failure surfaces at runtime. - Dynamic-handoff webhook unreachable. Documented mode for the
handoff-destination-requestwebhook ("verify webhook server URLs are reachable"). Not in Avoca's path today (we use static destinations), but relevant if cross-Blueprint handoff adopts the dynamic-resolution pattern. - Dynamic-handoff resolution returns empty destination. Vapi accepts an empty destination response or a custom error string; the error is appended to the message history and the LLM can react. Again, not in our current path.
- Avoca-side handling. None. There is no fallback, no logging, no retry inside
HandoffTool. The tool is fire-and-forget at compile time; any runtime failure manifests as a Vapi-side error visible only in the call record. Worth instrumenting if cross-Blueprint handoff lands.
In-call commit tools vs post-call workflow
Verified observations (2026-05-15)
- In-call structured commit via no-op tool is the pattern cross-shop introduces. The agent calls a tool (
autoOpsConfirmAppointment) at the moment of decision. Server returns success without side effects. The tool's arguments are persisted by Vapi in the call record'stoolCalls[]array. The post-call workflow readstoolCalls[]to find the structured args and trusts them over LLM-driven transcript inference. - Latency masking: ~200ms tool round trip is covered by the existing
request-startmessage ("Got it, submitting your request") that Vapi plays while waiting for the tool response. Caller perceives no additional delay.
TBD — full sequence diagram of the in-call → post-call handoff; how toolCalls[] is shaped in the call record; which workflow stages read it.
Time-aware tools (in-call deterministic dispatch with structured rejection)
Verified observations (2026-05-15)
Pattern: a tool that's always in the agent's tool list, with conditional behavior governed by structured data the tool itself reads at invocation. Instead of compile-time filters omitting the tool when conditions aren't met, the tool is always callable and returns a structured "rejection" response when conditions don't permit the primary action. The LLM acts on the response rather than being silently denied the capability.
Concrete example from transfer-call-time-routing:
- Today's behavior (compile-time filter):
filterDestinationsByTimeWindowruns atcreateMultiAgentcompile time. If all destinations are time-routed AND out-of-window, the transfer tool is dropped from the tool list. The LLM has no transfer to invoke and has no idea transfer was an option. - New time-aware tool (
checkTransferWindow): always registered. Readstime_based_enabled+transfer_destination_windowsat invocation. If in-window or not time-based, executes the transfer. If time-based and out-of-window, returns{ status: "after-hours", shopName, nextOpenTime, suggestedAction }. The LLM uses the response to compose an honest reply ("we're closed right now, the shop opens at 7am tomorrow") instead of silently routing the caller in-agent.
Distinguishes from the commit-tool pattern (cross-shop's autoOpsConfirmAppointment): commit tools are no-op memorialization; their success is a side-effect of being called. Time-aware tools return decision-flagged results the agent's next utterance responds to. Both move structured logic out of the LLM, but in different directions.
When to apply this pattern: anywhere the LLM is being asked to reason from prompt-baked structured data (hours, schedules, geographic proximity, eligibility) about whether a capability is available. If the structured data exists in the DB, a tool can read it deterministically and surface the answer to the LLM — keeping the LLM as the responder rather than the evaluator.
Naming convention for tool handlers
Verified observations (2026-05-15)
- CRM-prefixed naming (
autoOps*,mtg*,ars*,serviceTitan*,hcp*) is the existing convention for tool handlers tied to a specific CRM or vertical. - Legacy-coexists-with-new pattern: when a tool's behavior changes substantially, the canonical name (e.g.,
autoOpsGetAvailability) is reused for the new implementation. The old implementation is renamed with aLegacysuffix (autoOpsGetAvailabilityLegacy) and kept registered as a fallback. Removal of the legacy is a separate follow-up after the new path proves itself. - No-prefix is reserved, not used yet. Considered for cross-shop's new tool but rejected (Sandy 2026-05-15): keep the prefix end-to-end because the V1 implementation is still AutoOps-specific. When/if a tool truly spans CRMs at the interface level, the no-prefix slot is available.
Routing data sourcing
Verified observations (2026-05-15)
- JSON-in-repo for cross-shop V1: cross-shop's
shop-routing.jsonlives atapps/web/lib/autoops/shop-routing.jsonand is loaded at import time. Versioned, diffable, no DB dependency. Suitable while rules are small enough to read top-to-bottom. - DB-backed for V2: when rules outgrow file (more clients, more rules, more frequent edits), shape moves to a Supabase table. The interface stays — only the loader changes.
- Transfer-destination hours are DB-backed (existing): the
transfer_destination_windowstable (FKtransfer_id→transfer_destinations.id) is the single source of truth for transfer-time gating. Fields:day_of_week(enum),start_time(string),end_time(string). Per-destination weekly windows. The current(day, time)is computed in the team's timezone viagetCurrentDayAndTime(teamId)→getTeamTimezone(teamId)(defaultsAmerica/New_York). - Other places hours-like data lives but is NOT consulted by the transfer gate:
office_business_hourstable — used only by the admin UI + booking-rate analytics.on_call_schedules(Live-Rep) — separate concern.generateTransferHourVariablesinsystem-prompt-editor/transfer-hours-variables.ts— emits prompt-side Liquid copy only; not a gate input.holidaystable — a parallel gate (filterDestinationsByHoliday) with its own logic, separate from the time-window check.
The naive intuition ("hours might be in a bunch of places") proved false for transfer gating: one table, one filter function, one decision. Worth checking before assuming a similar gate elsewhere in the codebase is similarly scattered — it usually isn't.
Failure modes seen in the wild
Verified observations (2026-05-15)
- Toolcall-for-transfer issue (2026-05-14, Sandy's writeup to engineering): describe failure mode here once the fix lands and the diagnosis is durable. Today's link:
internal/daily-log/2026-05-15.md(internal-only, not deployed). - FDE-routing two-surface gotcha (Plan P, 2026-05-09): tool dispatches go to per-tool
server.url(priority 1, controlled byNGROK_BASE_URL), workflow events go to sub-agentassistant.server.url(priority 2, controlled byRESPONDER_COMMON_WORKFLOW_URL). Both surfaces required for FDE loop to work. Detailed inhow-to/local-dev-env.md.
TBD — more failure modes as we encounter them. Each entry: symptom + root cause + diagnostic + fix.
Related canon
agent-architecture-legacy-vs-current.md— generation-level architecture distinction.vapi-squads.md— Vapi squad structure and how assistants compose.in-call-sequence.md— turn-by-turn sequence of a single call.integration-playbook.md— captured Common-Webhook Playbook.how-to/local-dev-env.md— FDE-routing gotcha + local dev env setup.
Plan back-references
cross-shop-availability-commit-tool/brief.md— introduces the commit-tool pattern. In flight.archive/transfer-call-time-routing/brief.md— introduces the deterministic-dispatch-with-structured-rejection pattern. Shipped via PR #10521 (2026-05-15).cross-shop-routing-mechanism-research/research.md— feasibility research for cross-Blueprint handoff. Complete (2026-05-19).cross-blueprint-handoff/brief.md— L3 plan for a general cross-Blueprint handoff primitive (driven by but not scoped to EAS cross-shop). Brief stub; gated on three discovery items including Avoca eng coordination.blueprint-vs-non-blueprint/research.md— the surface-distinction investigation. Secondary priority.