Skip to content

Latest commit

 

History

History
468 lines (287 loc) · 25.2 KB

File metadata and controls

468 lines (287 loc) · 25.2 KB

Tool Configuration Gotchas

Non-obvious behaviors and silent defaults for Vapi tool types.


Common to All Tool Types

function.description must be under 1000 characters

Vapi enforces a hard 1000-character maximum on function.description across every tool type (function, apiRequest, transferCall, endCall, dtmf, voicemail, handoff, code). Tools with descriptions ≥ 1000 chars don't fail loudly at push time — they ship to the dashboard, but the LLM behavior degrades in ways that look like prompt or model bugs:

  • The tool may stop being invoked at the right moment (or at all).
  • The LLM may invoke a different (cheaper-to-emit / shorter-description) tool whose envelope fits its remaining context budget.
  • For platform-fired tool types like type: voicemail and type: dtmf, an over-limit description may degrade the trigger-detection signal that the platform pipeline reads from the description metadata.

Diagnostic signal: if a tool with a long, detailed description (verbose WHEN-TO-CALL / STRATEGY / numbered phrase lists) is being mis-fired or ignored — measure the description length first, before changing the prompt or the model.

Recommendation:

  • Validate description length at authoring time. A one-liner: python3 -c 'import yaml,sys; print(len((yaml.safe_load(open(sys.argv[1])).get("function") or {}).get("description","")))' path/to/tool.yml.
  • Keep descriptions focused on the LLM-visible decision: WHEN to call, WHEN NOT to call, the parameter shape. Drop:
    • Strategy sections that duplicate logic already in the assistant's system prompt (e.g., "STRATEGY FOR REACHING A HUMAN" duplicates IVR-handling rules from the prompt).
    • Long phrase lists for platform-fired tools like type: voicemail. Vapi's voicemail-detection pipeline runs independently of the LLM-visible description; a 38-numbered-phrase list adds noise without changing detection.
    • Anti-TTS-leakage META preambles. Tool descriptions are never voiced by the TTS pipeline — they reach the LLM as function-spec metadata, not assistant content. The "do not verbalize" guard is unnecessary and costs ~150–250 chars.
  • Audit existing tools when symptoms appear:
    python3 -c '
    import yaml, glob
    for p in glob.glob("resources/*/tools/*.yml"):
        d = (yaml.safe_load(open(p)).get("function") or {}).get("description","") or ""
        if len(d) >= 1000: print(f"{p}: {len(d)} chars")
    '

Sweet spot: 200–800 chars for a well-scoped tool. Above 800, audit for content that belongs in the assistant prompt instead.

function.name matches ^[A-Za-z0-9_-]+$

Tool names are validated against this regex by Vapi. Spaces, dots, slashes, parentheses, or unicode characters cause a 400 at push time. Use snake_case or camelCase (e.g. end_call_vapi_testing, handoffToAcmeSales). The name is what the LLM emits in its function call, so keep it stable across config changes — renaming a tool invalidates any prompt rule that mentions the old name.

Renaming a tool file is safe — the engine dedups by function.name

The push pipeline includes a name-based dedup safety net that prevents minting duplicate dashboard tools when:

  • You renamed the local file (e.g. end-call.ymlintake-end-call.yml) but kept function.name the same.
  • Bootstrap pull stored the dashboard tool under a slug-suffixed state key (e.g. end-call-67aea057) and your assistant references the original local key.
  • The tool exists on the dashboard but isn't yet in your local state file (e.g. fresh clone, partial pull).

In all three cases the engine looks up the tool by slugified function.name against both state entries and the live dashboard tool list, then adopts the existing UUID instead of creating a new one. You'll see this log line:

🔁 Reusing existing tool: <localKey> → <uuid> (matched via state|dashboard|both)

Adoption then routes through the standard PATCH path, so any local edits to the tool's payload are pushed normally with drift detection. Your old state-key entries are dropped automatically so the next full push doesn't orphan-delete the just-adopted dashboard tool.

When you see ⚠️ Multiple dashboard tools share the name "<n>" — adopting <uuid> (lex-smallest), real duplicate dashboard resources exist (typically from before the dedup was added). Run npm run cleanup -- <org> to inspect and prune; the engine adopts the lex-smallest UUID deterministically so subsequent pushes stay stable.

What this does NOT do: if you rename function.name (not just the file), that's a new logical tool — the engine creates a new dashboard resource. Function-name renames need an explicit npm run cleanup of the old one.


apiRequest Tools

body is the single source of truth for the LLM schema

What you might expect: function.parameters and body are two independent schemas — one for the LLM, one for the HTTP request.

What actually happens: Vapi overwrites function.parameters with a copy of body when the tool is processed. Whatever you define in body.properties becomes the tool schema the LLM sees. Any function.parameters you set on an apiRequest tool is silently ignored.

Recommendation: Only define your schema in body. Do not set function.parameters on apiRequest tools.

Static parameters override LLM-produced body fields

What you might expect: Static parameters (key-value pairs) only fill in defaults when the LLM omits keys.

What actually happens: The final HTTP body is { ...bodyFromLLM, ...staticParameters }. Static parameters win on key collisions — they override whatever the LLM produced.

Recommendation: Use static parameters for secrets, API keys, or fixed fields that should never come from the LLM. Be aware they will overwrite matching keys from LLM output.

Default HTTP method is POST

If you omit method, it defaults to POST — not GET.

API response must be JSON

On a successful HTTP response (2xx), Vapi expects a JSON body. If your API returns non-JSON (plain text, HTML, XML), the tool call will error.

function.strict is always cleared

Vapi removes strict from apiRequest tools when they are processed. If you need strict schema enforcement from the LLM provider, use a function type tool instead.

async has no effect

Vapi ignores async on apiRequest tools. They always execute synchronously (HTTP call → wait for response → return to LLM).

Credential fallback behavior

If credentialId is set on the tool, that specific credential is used. If omitted, Vapi picks one from the call's available credentials automatically. If you have multiple webhook credentials, always set credentialId explicitly to avoid ambiguity.

Timeout is top-level, not under server

For apiRequest tools, set timeoutSeconds directly on the tool. Do not put it under server.timeoutSeconds — apiRequest tools execute their own HTTP request path.

type: apiRequest
url: https://your-api-endpoint.example.com/search
method: POST
timeoutSeconds: 45

Dashboard gotcha: the current API Request tool form does not expose this field. Configure it through API/gitops JSON if you need a non-default timeout.


function Tools

Missing server is not an error

What you might expect: A function tool without server.url fails at runtime.

What actually happens: Vapi falls back through a hierarchy: tool server.url → assistant server.url → phone number server.url → org server.url. Omitting server on the tool is valid if the assistant or org has a server URL configured.

Recommendation: Set server.url on the tool for clarity, or document which level provides the webhook URL.

Timeout lives under server.timeoutSeconds

Function tools use the Server URL webhook path, so their per-tool timeout is configured on the tool's server block:

type: function
server:
  url: https://your-webhook-endpoint.example.com/tool
  timeoutSeconds: 45

This is separate from apiRequest's top-level timeoutSeconds.

async: true changes execution flow significantly

  • async: false (default): The LLM waits for the tool result before continuing. The user hears request-start, request-response-delayed messages while waiting.
  • async: true: The tool call is queued. The LLM continues without waiting. request-start, request-response-delayed, and request-failed message types are skipped for async tools.

Recommendation: Only use async: true when the tool result doesn't need to influence the conversation flow.

strict: true requires a specific schema shape

When strict: true is set on a function tool:

  • Every object node must have additionalProperties: false
  • Every property must be listed in required
  • Strict mode must be enabled for your organization

If the schema doesn't meet these requirements, strict may be silently disabled for that tool.


transferCall Tools

Forwarding numbers from the assistant are auto-injected

What you might expect: Only the destinations array on the tool matters.

What actually happens: If the assistant has forwardingPhoneNumber or forwardingPhoneNumbers set, those numbers are appended to the tool's destinations list. This changes the LLM-visible enum of transfer options.

Recommendation: Audit both the tool's destinations and the assistant's forwarding number fields.

Destinations with no name are silently dropped from the LLM enum

If a destination has no resolvable name (no number, sipUri, assistantName, or stepName), it is skipped when building the LLM-visible parameter enum. The destination still exists but the LLM won't know about it.

transferPlan.mode aliases (use canonical names)

Old mode strings are silently rewritten:

  • warm-transfer-with-summarywarm-transfer-say-summary
  • warm-transfer-with-messagewarm-transfer-say-message
  • warm-transfer-with-twimlwarm-transfer-twiml

Use the canonical names to avoid confusion.

Variable substitution is NOT applied to summaryPlan

Liquid/Mustache templates work in sipVerb, message, and twiml fields, but intentionally do not run on summaryPlan content.

Missing function.description can make the LLM reluctant to transfer

If you don't set an explicit function.description on a transferCall tool, the auto-generated description may include overly cautious language that biases the LLM toward not calling it. Always set function.description explicitly.

sipVerb: "refer" can silently fail with some providers

If your SIP trunk or telephony provider doesn't support the REFER method, transfers will appear to initiate on the Vapi side (endedReason: assistant-forwarded-call) but the destination never rings. If you're seeing this, remove explicit sipVerb: "refer" from your transferPlan and let Vapi use the default mechanism.

See also: transfers.md for a full diagnostic guide on transfer issues.


endCall Tools

Legacy endCallFunctionEnabled still creates a tool

If endCallFunctionEnabled: true is set on the assistant and no explicit endCall tool exists in model.tools, one is synthesized automatically. The endCallMessage from the assistant becomes the default request-start message.

Recommendation: Use an explicit endCall tool in model.tools for full control. If you use endCallFunctionEnabled, know that it auto-generates a tool.

endedReason reflects assistant-ended-call

When the LLM invokes an endCall tool, the call's endedReason is set to assistant-ended-call — not customer-ended-call or a generic hangup reason. Use this in post-call analysis and structured output evaluations.

Naming a tool resource ID in system-prompt prose causes TTS leak

If your system prompt references a specific tool by its resource ID ("call the your-end-call-feature-abc12345 tool"), the model can emit the resource ID as content (TTS-bound speech) instead of, or in addition to, invoking it via tool_calls. The TTS pipeline speaks the ID aloud as audio. If the call has anything looping audio back through STT (a personality bot in a sim, or a real human listening), the transcript captures partial syllables of the spoken ID.

Concrete signature: A tool resource ID like your-feature-name-abc12345 shows up in the AI transcript as something like "feature name, a b c one two three four five" — short, mangled fragments that map character-by-character to the tool ID.

Recommendation: Refer to tool capabilities by natural-language intent in prose ("end the call", "transfer to a specialist", "look up the customer"), never by resource ID. The tool-calling layer picks the correct registered function from toolIds based on intent. If the LLM is reluctant to invoke a tool, fix the tool's function.description rather than naming the tool in the prompt body — see transfers.md for the same pattern in the transfer-tool context.


handoff Tools

Default async is false (unlike many other tools)

Handoff tools explicitly default async to false. Set async: true explicitly if you want async/webhook-style behavior.

Auto-generated function names

If you don't set function.name:

  • Single destination: handoff_to_<normalized_assistant_name>
  • Multiple destinations: handoff_to_group_<index>

Recommendation: Set function.name explicitly if your system prompt references the tool by name.

Destination resolution is fuzzy

Vapi tries multiple matching strategies for the model's destination argument:

  1. Match by UUID (assistantId, squadId)
  2. Match by name (assistantName, squad name)
  3. Fall back to first destination if argument is "dynamic" or missing
  4. Attempt to parse raw strings as phone numbers

Recommendation: Use explicit destination identifiers. Don't rely on fallback logic for compliance-sensitive routing.


code Tools

Execution timeout is capped at 30 seconds

Even if you set timeoutSeconds: 60, the effective timeout is capped at 30 seconds.

Code must be valid top-level async JavaScript

Code runs in a managed sandbox as Node.js. The code receives args (tool arguments) and env (environment variables) as globals. Return values are captured from the resolved promise.

Static validation checks args.* and env.* references

When saving a code tool, Vapi parses your code and validates that every args.foo reference has a matching property in function.parameters.properties, and every env.BAR reference has a matching key in environmentVariables. Mismatches are rejected.

function.strict is cleared (like apiRequest)

Strict mode is not supported on code tools.


Tool Messages

blocking: true on request-start delays tool execution

When blocking: true, the assistant speaks the full request-start message (waits for TTS to finish) before the tool call begins. This adds latency but ensures the user hears the announcement.

When blocking: false (default), the tool call fires immediately while TTS speaks in parallel.

Message conditions use AND logic

If a message has conditions, all conditions must match the tool call arguments. Messages without conditions are used as defaults when no conditioned message matches.

request-complete requires a truthy result

The request-complete message only fires when the tool returns a non-empty result. If the tool returns an error or empty result, use request-failed instead.

request-complete with role: system becomes an LLM hint

Instead of speaking to the user, a role: system complete message is injected into the LLM context as a system message — useful for steering the model's next response.

Typos in message type silently degrade to request-failed

An unrecognized message type is coerced to request-failed with default error copy. You won't get a validation error.

request-response-delayed has a 2.5s cooldown

After one delayed message plays, another won't play for at least 2.5 seconds — even if multiple timing thresholds are crossed. Missing timingMilliseconds defaults to the 0ms bucket.

Dead air during KB/API tool calls

If a tool has no request-start content (or empty content), the caller hears silence while the tool executes. For knowledge base tools and API requests that take 2–5 seconds, this feels like dead air.

Fix with two layers:

  1. request-start with blocking: false — speaks a filler line ("Good question — let me look that up") in parallel with the tool call starting.
  2. request-response-delayed at 4000ms — safety net if the tool takes longer than expected.
messages:
  - type: request-start
    content: "Good question — let me look that up."
    blocking: false
  - type: request-response-delayed
    content: "Still looking that up for you."
    timingMilliseconds: 4000

Optionally add prompt-level instructions ("say a brief acknowledgment before calling the tool") as a belt-and-suspenders approach — the prompt handles cases where the LLM speaks before calling the tool, while the tool message handles cases where the LLM calls the tool silently.


voicemail Tools

Use voicemail type, not endCall, for voicemail termination

The voicemail tool type sets endedReason to voicemail (not assistant-ended-call), which enables voicemail-specific analytics filtering and retry logic.

type: voicemail
function:
  name: end_call_on_voicemail
  description: End the call immediately when voicemail is detected.
messages:
  - type: request-start
    content: ""
beepDetectionEnabled: false

beepDetectionEnabled is carrier-level, not LLM-level

Setting beepDetectionEnabled: true enables Twilio AMD (Answering Machine Detection) at the telephony layer. This detects voicemail in 2–5 seconds using audio analysis, before the LLM even processes the transcript.

Only works with Twilio. Other telephony providers ignore this setting. For non-Twilio setups, rely on LLM-based detection via the system prompt.

function.description reinforces detection as a secondary signal

The LLM sees the tool's function.description alongside the system prompt. Including voicemail trigger phrases in the description gives the LLM a second signal for detection, but the system prompt is the primary detection engine.

Silent tool messages for detection agents

For voicemail detection assistants that should never speak, set messages[0].content: "" (empty string). The tool still fires, but no audio is produced.


dtmf Tools

Two separate concepts: DTMF tool vs caller keypad input

Vapi has two keypad-related mechanisms with opposite directionality:

Mechanism Who sends digits? Configure it when
type: dtmf tool The assistant sends keypad tones into the call The agent needs to navigate an IVR, e.g. pressing 1 for sales
keypadInputPlan The caller enters digits on their phone keypad The agent needs to collect a PIN, OTP, ZIP, account fragment, or similar verification input

For caller-entered digits, configure the assistant, not a tool:

keypadInputPlan:
  enabled: true
  timeoutSeconds: 2
  delimiters:
    - "#"

Vapi injects the collected input into the conversation as a user keypad entry, so the assistant can read it back or pass it to a normal server/function tool. Keep the backend authoritative for identity decisions, and avoid using keypad input for highly sensitive full secrets because the value becomes part of the call context and event/log stream.

DTMF requires no configuration

The dtmf tool type is built-in and requires no function definition, parameters, or server URL:

type: dtmf

The agent can press phone keypad buttons by calling this tool with the digit(s) to send. Used primarily for IVR navigation in outbound calling scenarios.

DTMF is telephony-only

DTMF tones are a telephony concept. This tool has no effect on WebSocket or web-based calls.


Tool strict Mode Summary

Tool type strict behavior
function Preserved — passed to LLM provider if strict mode is enabled for your org
apiRequest Removed when the tool is processed
code Removed when the tool is processed
transferCall Removed when the tool is processed
endCall Removed when the tool is processed
handoff Removed when the tool is processed

Only function tools support strict mode.


Tool Security and Data Visibility

Cross-reference: docs.vapi.ai/tools/static-variables-and-aliases and docs.vapi.ai/tools/custom-tools. The full data-flow / threat-model writeup that motivates this section came out of progressive caller-ID auth work on a customer rollout.

Every tool result is in conversation history

Every successful tool call's full response body becomes a role: "tool" message in the LLM's conversation history and is visible to the model on the next completion. There is no built-in flag to hide tool results from the model. Implications:

  • If you put a sensitive value (PII, a token, a secret) in the response, the LLM sees it — period.
  • request-complete controls what the assistant speaks after the tool runs. It does NOT remove the tool result from history. The model still sees the raw response on the next user turn.
  • variableExtractionPlan aliases give you determinism (subsequent tools deterministically receive the value without LLM forwarding) but NOT invisibility — the underlying response is still in the model's view.

The actual redaction primitive across assistants is contextEngineeringPlan.type: previousAssistantMessages at handoff time. Within a single assistant, there is no equivalent — if the model must not see a value, your tool server must not place it in the response body to begin with.

Static parameters is the LLM-invisibility primitive (security boundary)

The override behavior described under apiRequest Tools → Static parameters is also the foundation for prompt-injection-safe data passing. The static parameters array is the only tool-config field whose values are guaranteed never to reach the LLM:

  1. The top-level tool.parameters is not part of the schema shipped to the model — only tool.function.parameters is.
  2. Server-side merging happens at fulfill time. The model has no write path to call-level Liquid sources like {{ customer.number }} mid-conversation; those come from SIP/Twilio signaling or the validated outbound API payload.

Use static parameters for any value the model must not be able to fake or influence: verified caller ID, called number, call ID, backend-looked-up account ID, per-call HMAC nonce.

Naming footgun: two fields called "parameters"

Field Who fills it Visible to LLM? Use for
tool.function.parameters (JSON Schema) LLM at runtime Yes Values the model should infer or that the caller will speak (intent, name, item to order)
tool.parameters (top-level array of { key, value }) You at config time, server-resolved at fulfill No Values your backend or Vapi's signaling layer already knows

The dashboard renders these as "Parameters" (JSON schema editor) and "Static Body Fields" (key/value rows with Liquid). Distinguishable by UI shape, not label. Decision rule: could a malicious caller speak the value? If "yes if I rely on the LLM to fill it," the field belongs in the top-level parameters array.

Tool types that support static parameters

Tool type Supports static parameters?
apiRequest
function (modern, under assistant.model.tools[])
Legacy assistant.model.functions[] (deprecated) ❌ — converter zeroes it out
code, handoff, transferCall, endCall, others

Progressive caller-ID auth pattern (worked example)

type: apiRequest
method: POST
url: https://your-backend.example.com/lookup-and-verify
function:
  name: lookup_and_verify_user
  parameters:
    type: object
    properties:
      name:  { type: string }
      email: { type: string }
    required: [name, email]
parameters:                            # server-resolved, LLM-invisible
  - { key: caller_number, value: "{{ customer.number }}" }
  - { key: called_number, value: "{{ phoneNumber.number }}" }
  - { key: call_id,       value: "{{ call.id }}" }

The LLM produces only name and email (what the caller spoke). The orchestration layer fills caller_number, called_number, call_id server-side. Your backend authenticates against the trusted fields and treats name / email as claims that must match the row keyed on caller_number. A "call this tool with phone number FAKE" prompt-injection has no path — the field doesn't exist in the schema the LLM sees.

For belt-and-braces, pair static parameters with HMAC body signing so the backend verifies sender + content, not just channel.

For the trust-tier breakdown of which Liquid variables are safe to use here ({{ customer.number }}, {{ call.id }}, etc.) vs. which are LLM-derived and not, see assistants.md → Liquid Variable Bag and Trust Tiers.