Non-obvious behaviors and silent defaults for Vapi tool types.
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: voicemailandtype: 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.
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.
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.yml→intake-end-call.yml) but keptfunction.namethe 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.
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.
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.
If you omit method, it defaults to POST — not GET.
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.
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.
Vapi ignores async on apiRequest tools. They always execute synchronously (HTTP call → wait for response → return to LLM).
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.
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: 45Dashboard 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.
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.
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: 45This is separate from apiRequest's top-level timeoutSeconds.
async: false(default): The LLM waits for the tool result before continuing. The user hearsrequest-start,request-response-delayedmessages while waiting.async: true: The tool call is queued. The LLM continues without waiting.request-start,request-response-delayed, andrequest-failedmessage types are skipped for async tools.
Recommendation: Only use async: true when the tool result doesn't need to influence the conversation flow.
When strict: true is set on a function tool:
- Every
objectnode must haveadditionalProperties: 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.
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.
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.
Old mode strings are silently rewritten:
warm-transfer-with-summary→warm-transfer-say-summarywarm-transfer-with-message→warm-transfer-say-messagewarm-transfer-with-twiml→warm-transfer-twiml
Use the canonical names to avoid confusion.
Liquid/Mustache templates work in sipVerb, message, and twiml fields, but intentionally do not run on summaryPlan content.
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.
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.
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.
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.
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 explicitly default async to false. Set async: true explicitly if you want async/webhook-style behavior.
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.
Vapi tries multiple matching strategies for the model's destination argument:
- Match by UUID (
assistantId,squadId) - Match by name (
assistantName, squadname) - Fall back to first destination if argument is
"dynamic"or missing - Attempt to parse raw strings as phone numbers
Recommendation: Use explicit destination identifiers. Don't rely on fallback logic for compliance-sensitive routing.
Even if you set timeoutSeconds: 60, the effective timeout is capped at 30 seconds.
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.
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.
Strict mode is not supported on code tools.
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.
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.
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.
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.
An unrecognized message type is coerced to request-failed with default error copy. You won't get a validation error.
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.
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:
request-startwithblocking: false— speaks a filler line ("Good question — let me look that up") in parallel with the tool call starting.request-response-delayedat 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: 4000Optionally 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.
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: falseSetting 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.
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.
For voicemail detection assistants that should never speak, set messages[0].content: "" (empty string). The tool still fires, but no audio is produced.
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.
The dtmf tool type is built-in and requires no function definition, parameters, or server URL:
type: dtmfThe 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 tones are a telephony concept. This tool has no effect on WebSocket or web-based calls.
| 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.
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 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-completecontrols 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.variableExtractionPlanaliases 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.
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:
- The top-level
tool.parametersis not part of the schema shipped to the model — onlytool.function.parametersis. - 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.
| 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 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 |
❌ |
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.