Skip to content
TriedDocs
Functions and tools

Custom function

Call your own HTTP endpoint during a conversation, with the request format, signature verification, retries, response variables and the SSRF rules Tried enforces.

A Custom Function (type: "custom") calls your HTTP endpoint when the LLM decides to. Use it to look up orders, check availability, create bookings, or anything your own API can do. The voice worker makes the request directly, waits for the response and hands the response text to the LLM.

The Custom Function modal: parameters, retries, body encoding and response variables

Configure in the dashboard#

  1. Open the agent, expand Functions, click Add and choose Custom Function.
  2. Name and Description: when the agent should call it.
  3. Method (GET, POST, PUT, PATCH, DELETE) and Endpoint URL. The URL must start with http:// or https://.
  4. Timeout (ms): 1,000 to 600,000. Default 120,000.
  5. Headers and Query parameters: key and value pairs. Values can use {{variables}}, for example Authorization: Bearer {{crm_token}}.
  6. Parameters: the arguments the LLM fills in, in Form or JSON schema mode. Pin value sends a constant.
  7. Retries and Body encoding.
  8. Arguments at root, if your endpoint expects the arguments as the whole body.
  9. Response variables: values to save from the response.
  10. While it runs: Speak during execution, Typing sound, Speak after execution.
  11. Click Save.

Fields#

Field Type Default Description
type "custom"
name string ^[a-zA-Z0-9_-]{1,64}$. Sent in the body as name
description string "" When to call it
url string (URL) Endpoint. {{variables}} are rendered
method GET, POST, PUT, PATCH, DELETE POST
headers object of string to string Rendered with variables
query_params object of string to string Rendered with variables
parameters JSON schema object Arguments for the LLM
timeout_ms integer, 1,000 to 600,000 120,000 Per attempt
max_retry integer, 0 to 5 0 Retries on network errors, timeouts, 429 and 5xx
args_at_root boolean off Body is the arguments object alone
parameter_type "json" or "form" json Body encoding for POST, PUT and PATCH
response_variables object of variable name to JSON path Values saved as dynamic variables
speak_during_execution boolean false
speak_after_execution boolean true
execution_message_description, execution_message_type, typing_sound See execution options

Request format#

POST, PUT and PATCH#

The default body is a JSON object with the function name, a snapshot of the call and the arguments:

{
  "name": "check_availability",
  "call": {
    "call_id": "call_9f2c0b6d1e4a7c3b5d8e0f12",
    "call_type": "phone_call",
    "agent_id": "agent_3fa2c91e7b0d4e5f8a6b1c2d",
    "direction": "inbound",
    "from_number": "+61412345678",
    "to_number": "+61290001234",
    "metadata": {},
    "retell_llm_dynamic_variables": { "business_name": "Bondi Dental", "first_name": "Sam" },
    "collected_dynamic_variables": { "preferred_day": "Tuesday" },
    "transcript": "Agent: Hi, thanks for calling Bondi Dental.\nUser: Can I book a check-up on Tuesday?"
  },
  "args": { "date": "2026-10-20", "time": "14:30" }
}
Body field Description
name The tool's name
call.retell_llm_dynamic_variables Agent defaults merged with the request variables
call.collected_dynamic_variables Values collected during this call so far
call.transcript The conversation so far, one Agent: or User: line per turn
args The LLM's arguments plus any pinned values

The request sets content-type: application/json unless you set it yourself.

  • Arguments at root (args_at_root: true): the body is only the args object.
  • Body encoding set to Form (x-www-form-urlencoded) (parameter_type: "form"): the same fields are form-encoded. Object values (such as call) are sent as JSON strings.

GET and DELETE#

There is no body. Each argument is added as a query parameter (objects as JSON strings), merged over your query_params.

GET https://api.example.com/orders?order_id=A1234&source=phone

Verifying the signature#

When the workspace has an API key, every request carries two identical headers:

x-tried-signature: v=1760832000000,d=5c1b...e9
x-retell-signature: v=1760832000000,d=5c1b...e9
  • v is the timestamp in milliseconds.
  • d is the hex HMAC-SHA256 of the raw body followed by the timestamp, keyed with the workspace's webhook signing key (the API key marked for webhooks, otherwise the oldest API key in the workspace).
  • For GET and DELETE the body is the empty string.

This is the same scheme as Tried webhooks, so one verifier covers both.

import crypto from "node:crypto";

export function verify(rawBody: string, header: string, secret: string, maxAgeMs = 5 * 60_000) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const ts = Number(parts.v);
  if (!ts || Math.abs(Date.now() - ts) > maxAgeMs) return false;
  const expected = crypto.createHmac("sha256", secret).update(rawBody + parts.v).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.d ?? ""));
}
import hashlib, hmac, time

def verify(raw_body: str, header: str, secret: str, max_age_ms: int = 300_000) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts.get("v", "0"))
    if not ts or abs(time.time() * 1000 - ts) > max_age_ms:
        return False
    expected = hmac.new(secret.encode(), (raw_body + parts["v"]).encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("d", ""))

Verify against the raw request body, before any JSON parsing or re-serialisation.

Response handling#

Response Text given to the LLM
Status below 400 The response body, cut to 15,000 characters
Status 400 or above Error <status>: <body>, cut to 15,000 characters
Timeout after all retries Error: the function call timed out after <ms> ms.
Network error after all retries Error: the function call failed: ...
Blocked URL Error: the function URL is not allowed (<reason>).

Return short, plain JSON or text. The LLM reads it as context for its next turn, so a sentence like {"result": "Tuesday 2:30 pm is free"} works better than a large payload.

With Speak after execution on (the default), the agent replies once the result arrives. Turn it off for fire-and-forget calls such as logging; the result is still recorded in the transcript.

Response variables#

response_variables maps a variable name to a JSON path in the response body. Paths start with optional $, use dots for keys and [n] for array items, and ['key with spaces'] for awkward keys.

{
  "response_variables": {
    "next_slot": "$.data.slots[0].start",
    "clinic_name": "$['clinic info'].name"
  }
}

Values are saved only when the response is valid JSON and the path exists. Strings are stored as is; numbers, booleans, objects and arrays as JSON text. They become {{next_slot}} for the rest of the call and appear in collected_dynamic_variables.

Retries#

Retries (max_retry, 0 to 5) repeats the request after a network error, a timeout, or a 429, 500, 502, 503 or 504 response. The waits between attempts double from 0.5 seconds: 0.5 s, 1 s, 2 s and so on. Each attempt gets the full Timeout, so the worst-case wait is about timeout_ms x (max_retry + 1) plus the backoff.

Warning

Only retry idempotent endpoints, or make your endpoint idempotent (for example with a booking reference in the arguments). A slow request that succeeds after the timeout is retried anyway.

Security: blocked destinations#

Custom functions call URLs on the public internet only. The worker checks the URL before the request and again when each connection opens, so a hostname that resolves to a private address at connect time (DNS rebinding) is also refused.

Refused:

  • Schemes other than http and https.
  • Hosts localhost, metadata, metadata.google.internal, metadata.goog, instance-data, and any host ending in .localhost or .internal.
  • Private, loopback, link-local, multicast, reserved and unspecified IP addresses (IPv4 and IPv6, including IPv4-mapped IPv6).
  • The carrier-grade NAT range 100.64.0.0/10.
  • Cloud metadata addresses 169.254.169.254, 100.100.100.200 and fd00:ec2::254.

Every resolved address is checked, so one private answer blocks the request. Outbound proxies in HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY are honoured. For local development, TRIED_ALLOW_PRIVATE_TOOL_URLS=1 on the worker turns the address check off. Leave it unset in production.

Pinned values#

A parameter with Pin value checked is stored as a JSON schema const. The worker hides it from the LLM and adds it to args on every call. Pinned values can use {{variables}}, so you can pin customer_id to {{customer_id}} and never let the LLM guess it.

Example#

{
  "type": "custom",
  "name": "check_availability",
  "description": "Check free appointment slots once the caller has said which day they want.",
  "url": "https://api.example.com/clinics/{{clinic_id}}/availability",
  "method": "POST",
  "headers": { "Authorization": "Bearer {{api_token}}" },
  "parameters": {
    "type": "object",
    "properties": {
      "date": { "type": "string", "description": "Requested date, YYYY-MM-DD" },
      "source": { "type": "string", "const": "phone" }
    },
    "required": ["date"]
  },
  "timeout_ms": 8000,
  "max_retry": 1,
  "speak_during_execution": true,
  "execution_message_type": "static_text",
  "execution_message_description": "Let me check the diary.",
  "response_variables": { "next_slot": "$.slots[0]" }
}

Gotchas#

  • {{variables}} that do not exist stay as literal text in the URL, headers and query values.
  • A slow endpoint is dead air on a phone call. Keep timeouts short and use Speak during execution or Typing sound for anything over a second.
  • Test LLM in the dashboard does not call your endpoint. Use Mocks to test the conversation and Test Audio or a phone call to test the endpoint.
  • In conversation flows, a custom function only runs from a function node or a subagent node.