Tools
A tool is a function the model may call mid-conversation: look up an order, transfer the call, send digits into an IVR, query a knowledge base. Give an assistant a tool when it needs a fact it cannot know or an action it cannot take by talking.
A tool is a versioned resource of its own, published like an assistant, and attached to one or more assistants.
The 25 types
| Group | type | What it does |
|---|---|---|
| Custom | api_request | Calls an HTTP endpoint you own. Templated URL, path parameters, auth, retry, response extraction. |
function | Posts the call context to a server URL and waits. Falls back to the assistant-level server if the tool has none. | |
custom_provider | A provider-specific custom shape. | |
client_side | Executed in the visitor's browser by the Web SDK. | |
| Call control | transfer_call | Transfers to a number, SIP URI, extension or queue. |
handoff | Hands the conversation to another assistant in the squad. | |
end_call | Ends the call. | |
sms | Sends an SMS. | |
dtmf | Sends DTMF digits. | |
voicemail | Leaves a voicemail. | |
sip_request | Sends an in-dialog SIP request. | |
| Knowledge | query, knowledge_base | Retrieves from a knowledge base. |
| MCP | mcp | A tool from an MCP server. Created by discovery, not by hand. |
| Sandboxed | bash, computer, text_editor | Run in a disposable container, never in the gateway process. |
| Integrations | google_calendar_create_event, google_calendar_availability, google_sheets_append_row, slack_send_message, gohighlevel_availability, gohighlevel_event_create, gohighlevel_contact_create, gohighlevel_contact_get | Native connectors. |
Five of these never reach a call
bash, computer, text_editor, client_side and custom_provider are accepted by the API and stored, and the converter that builds the gateway's tool list has no mapping for any of them. A tool of one of those types is silently dropped from every call. The model never sees it.
client_side tools do work through the Web SDK's own registerTool path, which is a different mechanism. See What is not built.
The gateway's own contract has 27 members: these 25 plus markDoNotCall and scheduleCallback, which are injected by the gateway on campaign calls and cannot be created through the API.
Declaring an HTTP tool
The tools API is snake_case, and everything transport-related lives under config.
curl -X POST "$CALLAGENT_API_URL/tools" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Look up order",
"key": "lookup_order",
"description": "Look up an order by its number.",
"type": "api_request",
"parameters_schema": {
"type": "object",
"properties": {
"orderNumber": { "type": "string", "description": "The order number." }
},
"required": ["orderNumber"]
},
"config": {
"url": "https://api.example.com/orders/{orderNumber}",
"method": "GET",
"path_parameters": [{ "name": "orderNumber" }],
"parameters": [{ "name": "orderNumber", "source": "model" }],
"timeout_seconds": 10,
"response_extraction": [{ "path": "order.status" }],
"retry": { "max_attempts": 2, "backoff": "exponential", "excluded_status_codes": [400, 404] }
},
"messages": [
{ "type": "request_start", "content": "One moment, let me look that up." },
{ "type": "request_failed", "content": "I could not reach the order system." }
],
"risk_level": "read",
"timeout_seconds": 20
}'Top-level fields: name (required), key (^[a-zA-Z_][a-zA-Z0-9_]*$ — this is the name the model sees), description (required, ≤2000), type (required), parameters_schema, config, messages, rejection_plan, server, metadata, is_async, timeout_seconds (1–120), requires_confirmation, risk_level.
Then publish it, the same as an assistant:
curl -X POST "$CALLAGENT_API_URL/tools/$TOOL_ID/publish" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"Four things that will 422 you
These are the mistakes worth knowing before you make them.
1. config.url is required. An api_request, function or custom_provider tool with no URL is rejected at write time. config.method is not required — it defaults to POST, silently. Set it explicitly.
2. The URL host must resolve, now. Every URL goes through the SSRF guard when you create the tool, and the guard resolves the hostname:
{"error":{"type":"validation_error","errors":{
"config":["Request URL rejected: The hostname \"api.example.com\" could not be resolved."]}}}A URL that does not resolve, or that resolves to a private or link-local address, is refused. The check runs again in the gateway before every request, and on every redirect hop. A URL containing {{ is skipped at write time, because it cannot be resolved until the call runs.
3. Path placeholders are single-brace, and must be declared. {orderNumber} in the URL requires a matching entry in config.path_parameters, and a declared path parameter that is not in the URL is also an error. Writing {{orderNumber}} produces The URL uses {orderNumber} but no path parameter of that name is defined, because the inner braces still match.
4. response_extraction is a list of rules with a path. Not a {mode, json_path} object. The path is a dot/bracket path ([A-Za-z0-9_.\[\]*$] only), deliberately not a JSONPath expression language — an expression engine over untrusted input is a security surface nobody needs here.
response_extraction matters more than it looks. Handing the model a 2 MB JSON body destroys time to first token. Extract the field you actually need.
config in full
| Key | Values |
|---|---|
url | Required. Templated with {{variable}} and {pathParam}. |
method | GET · POST · PUT · PATCH · DELETE. Defaults to POST. |
headers | Object or name/value list. Host cannot be overridden. |
query | Object or name/value list. |
path_parameters | [{ "name": "…" }], matching {name} in the URL. |
body_format | json · form · multipart · raw · none. Default json. |
body | Not allowed on GET or DELETE. Validated as JSON unless it contains {{. |
parameters | Argument bindings — see below. |
auth | { "type": …, "credential_id": … }. |
response_extraction | [{ "path": "order.status" }]. |
retry | max_attempts 0–5, backoff fixed·linear·exponential, excluded_status_codes. |
timeout_seconds | 1–120. |
follow_redirects | boolean. Redirects are re-checked by the SSRF guard. |
async | Cannot be combined with response_extraction. |
Argument bindings
config.parameters says where each argument comes from:
"parameters": [
{ "name": "orderNumber", "source": "model" },
{ "name": "tenant", "source": "static", "value": "acme" },
{ "name": "callId", "source": "variable", "variable": "call.id" },
{ "name": "apiKey", "source": "secret", "credential_id": "01JQ…" }
]Four sources: model (the LLM fills it), static (needs value), variable (needs a dotted variable path), secret (must reference a stored credential). A secret parameter with an inline value is rejected — that is not an encrypted argument, it is a plaintext secret in a JSON column.
Auth
bearer, basic, api_key and oauth2 all require credential_id or oauth_connection_id. Secrets are never kept in the tool body. api_key also needs the header name to send it in.
Timeouts
Four layers, and knowing which one bites saves an afternoon:
| Layer | Default | Ceiling |
|---|---|---|
config.timeout_seconds — the HTTP request itself | 20 | 120 |
timeout_seconds on the tool — the execution budget | 20 | 120 at write time, 600 in the contract |
MCP server timeout_seconds | 20 in the database, 30 in the contract | 120 |
| MCP tool at bind time | the tighter of the tool entry and the server |
Enforcement is in the gateway, as an abort signal combined with the call session's own signal — so barge-in cancels an in-flight tool too. A timeout returns a structured result for that one tool call. It never takes the call down.
A tool-level value out of range is dropped, not clamped
If the stored execution timeout is outside 1–600 the converter omits it, and the gateway's own default of 20 seconds applies. Silence, not an error.
Spoken messages
The caller hears silence while a tool runs unless you fill it.
"messages": [
{ "type": "request_start", "content": "One moment, checking that." },
{ "type": "request_delayed", "content": "This is taking longer than usual.", "timing_milliseconds": 4000 },
{ "type": "request_complete", "content": "Got it." },
{ "type": "request_failed", "content": "I could not reach that system." }
]Four types only. Both spellings are accepted on the way in (request_start and request-start), and message is accepted as an alias for content. Anything else is dropped rather than rejected — check what came back. timing_milliseconds is only meaningful for request_delayed. blocking defaults to false.
Async tools
is_async: true (or config.async: true) fires the request and returns immediately with "Request accepted and running in the background." Errors are logged and never reach the model.
This works only for api_request. No other type branches on it.
Risk levels and confirmation
Five levels, ascending: read, write, destructive, financial, privileged.
Each type has a default: query and knowledge_base and the read-only connectors are read; transfer_call and end_call are destructive; bash, computer and text_editor are privileged; everything else is write.
requires_confirmation makes the assistant ask before executing. For MCP tools the policy is per server — see MCP.
Rejection
rejection_plan decides what happens when a tool call is refused — by policy, by risk tier, or by the caller declining a confirmation. The assistant says the configured line and the model is told the call was rejected, so it can route around it rather than repeating itself.
A rejection is never an exception. A tool that throws returns an error result to the model; the turn continues.
function tools
A function tool posts the call context to a server URL. server is optional on the tool: with none, the assistant-level server.url handles it.
If neither is set the tool is still created and attached, and every call to it comes back as not_found with "this function tool has no server URL configured". Legal to build, useless at run time. Set one.
Client-side tools
A client_side tool runs in the visitor's browser, so the assistant can act on the page it is embedded in — read the cart, scroll to a section, prefill a form.
client.registerTool('getCartTotal', async () => ({ total: 42.5, currency: 'EUR' }));
// or one handler for several tools; return undefined to decline
client.on('client-tool', async ({ name, arguments: args }) => {
if (name !== 'scrollTo') return undefined;
document.querySelector(String(args.selector))?.scrollIntoView();
return { scrolled: true };
});A handler that throws sends the error back to the model rather than hanging the turn. An unregistered tool is reported, not silently dropped.
Note the distinction: this is the Web SDK's registration path, which works. A stored Tool row of type client_side is a different thing and is dropped from the gateway snapshot.
Attaching a tool to an assistant
There is no /assistants/{id}/tools endpoint. Attach through the assistant body:
curl -X PATCH "$CALLAGENT_API_URL/assistants/$ASSISTANT_ID" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"tools": [
{ "tool_id": "01JQ…", "pin_mode": "latest", "enabled": true },
{ "tool_id": "01JQ…", "pin_mode": "pinned", "tool_version_id": "01JQ…", "alias": "orders" }
]
}'pin_mode is latest (resolve at publish time) or pinned (frozen to a version). alias renames the tool for this assistant only. tool_ids is the shorthand for attaching by id with defaults.
GET /assistants/{id}/preview shows the resolved list, with exposed_name — the name the model will actually see.
API
GET/POST /api/v1/tools
GET/PATCH/DELETE /api/v1/tools/{id}
POST /api/v1/tools/{id}/duplicate
POST /api/v1/tools/{id}/publish
GET /api/v1/tools/{id}/versions
GET /api/v1/tools/{id}/versions/{version}
POST /api/v1/tools/{id}/versions/{version}/restore
GET /api/v1/tools/{id}/validateFull parameters and responses: tool reference.