Skip to main content
This page applies to both LLM tools and Perception tools. Every tool dispatches via exactly one of two channels, picked per tool via the delivery field:
  • App message (default) - Tavus emits a conversation.tool_call event (or conversation.perception_tool_call for perception tools) on the data channel; your frontend handles it.
  • API call - Tavus makes an HTTPS request to a URL you configure, either a customer-controlled callback or a third-party API directly. Auth + body shape is configured per tool.
A tool with delivery.app_message: true and a delivery.api block at the same time is rejected. Setting both to disabled is also rejected. Pick exactly one.

App message delivery

The default. Your client receives the tool call inline with the conversation events.
App message delivery (default)
"delivery": { "app_message": true }
Each invocation arrives as a conversation.tool_call event carrying a tool_call_id. To return a result, send a conversation.tool_result event back with the matching tool_call_id - that’s how Tavus pairs the result with the in-flight dispatch and applies the configured on_resolve.
conversation.tool_result (sent by your client)
{
  "message_type": "conversation",
  "event_type": "conversation.tool_result",
  "conversation_id": "<conversation_id>",
  "properties": {
    "tool_call_id": "<id from the tool_call event>",
    "output": "It is 72 degrees and sunny in San Francisco.",
    "status": "success"
  }
}
FieldTypeRequiredDescription
tool_call_idstringMust match the tool_call_id from the original conversation.tool_call event.
outputstring | objectThe tool result. Strings are passed through; objects are JSON-serialized.
statusstring"success" (default) or "error". On error, the PAL acknowledges the failure instead of speaking the result.
If your client never sends a result, the dispatch eventually drops out of context. There’s no hard timeout on this path - the PAL just won’t have the data.

API delivery

Tavus calls an HTTPS endpoint each time the tool fires. The exact request shape depends on whether you’re hitting a third-party API directly (any auth.type other than hmac) or a customer-controlled callback (auth.type: hmac).
API delivery
"delivery": {
  "api": {
    "url": "https://api.example.com/tools/get_weather",
    "method": "POST",
    "auth": { "type": "hmac", "secret": "whsec_long_random_string" },
    "headers": { "X-Tenant": "acme" },
    "timeout": 10
  }
}

delivery.api fields

FieldTypeRequiredDescription
urlstringHTTPS URL Tavus will call when the tool fires. Must be publicly reachable; private/loopback/link-local addresses are rejected. May contain {placeholders} in the path and query string - see URL templating. The hostname must be static (no placeholders).
methodstringOne of GET, POST, PUT, PATCH, DELETE, HEAD. Defaults to POST.
headersobjectExtra request headers as a {string: string} map. Sent verbatim with every dispatch.
timeoutnumberSeconds Tavus will wait for your endpoint to respond, 0 < timeout <= 60. Defaults to 10. This is also the watchdog deadline: if the request hasn’t returned by then, the tool call is marked timeout.
authobjectHow Tavus authenticates to your endpoint. See Tool Authentication. Defaults to no auth headers.
body_templateobjectJSON object with {placeholders} in its string values. Renders to the request body and overrides the default auto-routing (see Request body). Only valid with POST / PUT / PATCH; pairing it with GET / HEAD / DELETE is rejected.
query_paramsobjectStatic and templated query-string parameters as {string: string}. Values containing {placeholders} are interpolated from LLM arguments; plain strings pass through.
content_typestringOverride the request Content-Type header (defaults to application/json). Useful for application/x-www-form-urlencoded and similar.

URL templating

{placeholders} in the URL path or query string are filled at request time from the LLM’s tool arguments and URL-encoded. The hostname must be static.

Reserved system placeholders

In addition to the LLM’s tool arguments, Tavus injects a small set of system placeholders at request time. They’re available everywhere placeholders are interpolated - URL, query_params values, and body_template. Useful for idempotency keys, correlation IDs in your logs, and Tavus-side tracing.
PlaceholderFilled with
{tavus_conversation_id}The Tavus conversation ID (c…).
{tavus_tool_call_id}Unique ID for this specific tool invocation. Stable across retries.
{tavus_inference_id}The LLM turn (inference) that emitted the tool call.
{tavus_turn_idx}Integer index of the conversational turn.
{tavus_tool_name}The tool’s name as registered at /v2/tools.
The tavus_ prefix is reserved. Tool parameters.properties cannot declare a property whose name starts with tavus_ - the create / update tool API rejects it with 400. Pick a different name (e.g. customer_conversation_id) if you need a similar field.

Request body and query string

Only arguments declared in your tool’s parameters.properties ride along. Body and query string are decided independently - you can combine them in any way.

Body

The HTTP method decides whether a body is sent. GET / HEAD / DELETE never carry a body - body_template on those methods is rejected at validation time.
Methodbody_templateBody
GET / HEAD / DELETEnot allowedempty
POST / PUT / PATCHunsetDeclared arguments not consumed by URL placeholders become the JSON body. Content-Type: application/json.
POST / PUT / PATCHsetRenders the template with arguments and uses that as the body. Use this when the API expects a nested or renamed shape.

Query string

query_params applies to every method - you can attach a query string to a POST as easily as to a GET.
Methodquery_paramsQuery string
GET / HEAD / DELETEunsetDeclared arguments not consumed by URL placeholders auto-route here.
GET / HEAD / DELETEsetOnly the entries you list reach the query string. Auto-route is skipped so your named entries aren’t duplicated.
POST / PUT / PATCHunsetempty (body holds the args).
POST / PUT / PATCHsetOnly the entries you list reach the query string. Body is decided separately by body_template or auto-route.

Example - reshape flat tool args into a nested API body

Suppose the tool’s parameters schema defines two flat fields:
Tool parameters (what the LLM emits)
{
  "type": "object",
  "properties": {
    "search_term": { "type": "string" },
    "region":      { "type": "string" }
  }
}
But the API expects a nested shape - query.text and filters.region. Use body_template to remap:
delivery.api with body_template
"delivery": {
  "api": {
    "url": "https://api.example.com/search",
    "method": "POST",
    "auth": { "type": "api_key", "location": "header", "name": "X-API-Key", "value": "..." },
    "body_template": {
      "query":   { "text":   "{search_term}" },
      "filters": { "region": "{region}" }
    }
  }
}
When the LLM calls the tool with { "search_term": "pizza", "region": "tokyo" }, Tavus sends:
POST https://api.example.com/search
X-API-Key: ...
Content-Type: application/json

{"query": {"text": "pizza"}, "filters": {"region": "tokyo"}}
body_template is a regular JSON object - write it as one, no escaping. Each {placeholder} inside a string value is replaced with the matching LLM argument. If a string is exactly one placeholder ("{count}"), the substituted value preserves its native type - so "{count}" with count: 10 produces 10 (number), not "10" (string). Non-string values in the template (numbers, bools, null) pass through unchanged.

Tavus callback shape

Used when auth.type: hmac is set. Tavus ignores body_template / query_params / URL templating in this mode and sends a fixed JSON envelope, signed with HMAC-SHA256.
Request body (POST/PUT/PATCH)
{
  "name": "get_current_weather",
  "arguments": "{\"city\":\"San Francisco\",\"unit\":\"celsius\"}",
  "tool_call_id": "call_abc123",
  "conversation_id": "c123456789",
  "inference_id": "inf_987654321",
  "turn_idx": 4
}
FieldTypeDescription
namestringThe tool’s name.
argumentsstringTool arguments as a JSON-encoded string (the LLM’s raw output). Parse this on your side.
tool_call_idstringUnique ID for this specific invocation.
conversation_idstringThe Tavus conversation ID.
inference_idstringThe inference (LLM turn) that produced the call.
turn_idxintegerIndex of the conversational turn (groups events from the same turn).
Request headers Tavus always sets in this mode:
HeaderValue
Content-Typeapplication/json
X-Tavus-SignatureHMAC-SHA256 hex digest of the request body. See Verifying API signatures.
Any extra delivery.api.headers you configured are merged in.

Response Tavus expects

The same response handling applies to both API modes:
StatusOutcome
2xxTreated as success. Response body is used as the tool result (see on_resolve for how it is consumed).
5xxOne retry after a short backoff. If the retry also fails, the call is marked error.
401 (oauth2_client_credentials only)One retry with a freshly fetched token.
Other non-2xxNo retry; marked error.
Timeout (no response within delivery.api.timeout seconds)Marked timeout.
Connection errorOne retry. If the retry also fails, marked error.
When the status is error or timeout and on_resolve is not fire_and_forget, the PAL acknowledges the failure to the user (no result content is fed back to the LLM).

Verifying API signatures

When you set auth.type: "hmac" with an auth.secret, Tavus signs the exact request body bytes with HMAC-SHA256 and sends the hex digest in X-Tavus-Signature. Verify it before trusting the payload.
The signing input is the raw body Tavus sends, which is canonical JSON with keys sorted alphabetically. Verify against the raw bytes you received, not a re-serialized version - any re-encoding can change byte order and break the signature.
Python (Flask)
import hmac
import hashlib
import json
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["TAVUS_TOOL_SECRET"].encode("utf-8")


@app.post("/tools/get_weather")
def get_weather():
    received_sig = request.headers.get("X-Tavus-Signature", "")
    expected_sig = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received_sig, expected_sig):
        abort(401)

    body = request.get_json()
    args = json.loads(body["arguments"])
    # ... your business logic ...
    return "It is 72 degrees and sunny in San Francisco.", 200
Node.js (Express)
import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.TAVUS_TOOL_SECRET;

// IMPORTANT: capture the raw bytes; do not let middleware re-serialize.
app.use(express.raw({ type: "application/json" }));

app.post("/tools/get_weather", (req, res) => {
  const received = req.header("X-Tavus-Signature") || "";
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(req.body)
    .digest("hex");

  const ok =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const body = JSON.parse(req.body.toString("utf8"));
  const args = JSON.parse(body.arguments);
  // ... your business logic ...
  res.type("text/plain").send("It is 72 degrees and sunny in San Francisco.");
});

app.listen(3000);
Replace <api-key> with your actual API key. You can generate one in the PAL Maker.