Skip to content

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-flight cross-shop-availability-commit-tool plan 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

  1. The system prompt (plain English). Instructions like "If the caller asks about billing, hand off to the billing agent."
  2. 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 typeExecution venueAvoca server sees the invocation?Result returned to LLM?
Vapi-native (transferCall, endCall, handoff)Vapi runtimeNoNo (action, not response message)
Custom function (autoOps* and all Avoca handlers)Avoca backend webhook (server.url)YesYes (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:

SurfaceTime gate at tool-list inclusion?Mechanism
Blueprint (createMultiAgent)YesgetFilteredTransferDestinations runs filterDestinationsByTimeWindow at compile time; tool omitted when filtered list is empty
Non-Blueprint (syncWarmTransferToolForTeam)NoPersistent 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":

  1. Source data: transfer_destinations table (per-team rows; each has active, time_based_enabled, enabled_on_holidays, plus identity fields like number, name, description). Each destination links to one or more transfer_destination_windows rows (FK transfer_id; fields day_of_week, start_time, end_time).
  2. Gate: getFilteredTransferDestinations({ teamId, voiceAssistantId }) in apps/web/lib/supabase/transfer-destinations.ts runs a pipeline: DB query (active=true) → filterByVoiceAssistantAccessfilterDestinationsByTimeWindowfilterDestinationsByHoliday.
  3. Time-window check: filterDestinationsByTimeWindow (lines 219-265) fetches the relevant transfer_destination_windows, computes (currentDay, currentTime) in the team timezone via getCurrentDayAndTime(teamId), and filters per-destination: destinations with time_based_enabled=false always pass; destinations with time_based_enabled=true survive only if the current time falls within one of their windows.
  4. Compile-time inclusion: createMultiAgent (in agent-factory.ts) calls the filter at lines 461 (cold transfer) and 419 (assisted transfer). If the filtered list is empty, coldTransferUnavailable = true (line 477) and ToolName.TRANSFER_CALL is removed from the sub-agent's tools array at line 482-510.
  5. Payload build: When destinations exist, buildAssistedTransferTool (line 812) → getVapiOpenAIModelToolbuildWarmTransferToolPayload in apps/web/lib/vapi/warm-transfer-tool-builder.ts shapes the destinations into the Vapi transferCall tool 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 as transferCall).
  • destinations[0].type: 'assistant' + assistantName. Resolution is by name against squad members. Not assistantId; Blueprint doesn't register assistants.
  • description. Compiled from the Edge's Liquid condition (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:

  1. Tool-type interception. type: 'handoff' is dispatched inside Vapi, not forwarded to the assistant's server.url.
  2. Destination resolution. assistantName is matched against the current squad's members[].assistant.name. (We use assistantName, never assistantId.)
  3. Context engineering. The plan's type rewrites the new assistant's seed messages per the matrix below.
  4. 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

OptionTranscriptTool callsTool resultsSystem promptvariableValuesNotes
allFull historyForwardedForwardedReplacedPreservedAvoca's current default
userAndAssistantMessagesUser + assistant onlyStrippedStrippedReplacedPreservedUse when receiver doesn't need tool history
previousAssistantMessagesOnly pre-handoff turnsStrippedStrippedReplacedPreservedPCI / sensitive flows; prevents tool-result leak
lastNMessagesLast N (configurable)Depends on cutoffDepends on cutoffReplacedPreservedToken-budget control
noneNoneNoneNoneReplacedPreservedBlank-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 any autoOpsConfirmAppointment-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 assistantName against the current squad. Sibling shops live in different compiled squads, so cross-shop handoff via this primitive is impossible without changes.
  • assistantId destinations not used. Blueprint doesn't register persistent assistants; nothing has an assistantId to reference.
  • Inline destinations (assistant: CreateAssistantDTO, squad: <inline>) not used. Vapi's schema accepts them and Avoca already emits the exact CreateSquadDto shape they require (every call's initial payload), but no code path emits an inline-destination handoff tool. This is the gap cross-blueprint-handoff closes.
  • contextEngineeringPlan is hard-coded. No per-Edge override. Cross-Blueprint handoff with userAndAssistantMessages (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 of MultiAgent.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 assistantName doesn'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 a toSubAgentName that 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-request webhook ("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's toolCalls[] array. The post-call workflow reads toolCalls[] 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-start message ("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): filterDestinationsByTimeWindow runs at createMultiAgent compile 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. Reads time_based_enabled + transfer_destination_windows at 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 a Legacy suffix (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.json lives at apps/web/lib/autoops/shop-routing.json and 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_windows table (FK transfer_idtransfer_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 via getCurrentDayAndTime(teamId)getTeamTimezone(teamId) (defaults America/New_York).
  • Other places hours-like data lives but is NOT consulted by the transfer gate:
    • office_business_hours table — used only by the admin UI + booking-rate analytics.
    • on_call_schedules (Live-Rep) — separate concern.
    • generateTransferHourVariables in system-prompt-editor/transfer-hours-variables.ts — emits prompt-side Liquid copy only; not a gate input.
    • holidays table — 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 by NGROK_BASE_URL), workflow events go to sub-agent assistant.server.url (priority 2, controlled by RESPONDER_COMMON_WORKFLOW_URL). Both surfaces required for FDE loop to work. Detailed in how-to/local-dev-env.md.

TBD — more failure modes as we encounter them. Each entry: symptom + root cause + diagnostic + fix.

Plan back-references