Open in
Build a tool integration (API)
Let your agent call your APIs mid-conversation — search a database, create a ticket, look up an order.
A tool integration is a reusable HTTP endpoint that an agent can invoke during a call. You give ThunderPhone a JSON-schema description of the tool plus an endpoint URL; the agent decides when to call it based on the conversation, and ThunderPhone makes the outbound HTTP request from its servers and returns the response to the agent.
This guide walks through building a weather-lookup tool end-to-end.
Anatomy of a tool
Two pieces:
- The schema — an OpenAI-style function definition
(
{type: "function", function: {name, description, parameters}}) that tells the LLM what the tool does and what arguments it takes. - The endpoint — the URL ThunderPhone's servers call when the LLM decides to use the tool. The request is JSON POST with the LLM's chosen arguments as the body.
1. Choose an editor
Open Connections → APIs, create or edit the API connection, switch the parameter editor to JSON, and add the format there.
Create the spec with POST /v1/integrations, or update it with
PATCH /v1/integrations/{id}.
Both paths create a saved integration. Attach that integration to the
agent after saving it. The Agents API has no writable inline tools
field. This guide uses the integrations API path.
2. Create the integration
curl -X POST https://api.thunderphone.com/v1/integrations \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Weather API",
"spec": {
"type": "function",
"function": {
"name": "get_weather",
"description": "Return the current weather for a zip code.",
"parameters": {
"type": "object",
"properties": {
"zip": { "type": "string", "description": "5-digit US ZIP code" }
},
"required": ["zip"]
}
}
},
"endpoint_url": "https://api.example.com/weather",
"endpoint_method": "GET",
"headers": [
{ "key": "X-Api-Key", "value": "your-provider-key" }
]
}'Save the returned id (a UUID). Header values are write-only: responses
list header names only, and a later PATCH can send a header without a
value to keep the stored one.
Declare format: "email" on address parameters
A parameter that receives an email address should say so in its schema:
"email": { "type": "string", "format": "email", "description": "The caller's email address" }format is more than a hint. For a resolved email schema, before your
endpoint is called ThunderPhone trims the value, lower-cases the
domain, turns the standalone English words at, dot, underscore,
dash, and hyphen into their characters, and removes whitespace
directly around @, ., _, and -. The words have the same meaning
whether or not the transcript already contains a literal @:
"john dot smith at gmail dot com" becomes
john.smith@gmail.com.
Any other internal whitespace is rejected rather than silently joined.
Spoken separator words are English-only; non-English or unrecognized
spaced forms fail closed. Valid internationalized domains and SMTPUTF8
local parts are accepted. Punycode input remains punycode and Unicode
domain input remains Unicode after parser normalization, so your API
receives the caller-supplied conventional representation. If the final
value is invalid, the tool is not called. The agent receives
invalid_email_argument telling it
to confirm the spelling with the caller and resend the literal address.
An omitted optional email is untouched. null, an empty string, or a
whitespace-only string is also untouched when the property is optional
or nullable; the same values are rejected for a required, non-nullable
email.
Local schema references such as #/$defs/email and
#/definitions/email, plus anyOf, oneOf, and allOf, are inspected
with cycle and depth limits. A non-local or unresolvable $ref is a
known enforcement limit and is passed through unchanged, as is a call
whose tool snapshot has no usable schema. Keep email schemas local when
you need the gate to apply.
Parameters without an enforced email format are passed through exactly as the model produced them.
Supported formats are date-time, time, date, duration,
email, hostname, ipv4, ipv6, and uuid; only email is
normalized and enforced today.
3. Sandbox-test the endpoint
Before you link the integration to an agent, fire a signed request from ThunderPhone's servers to confirm connectivity:
curl -X POST https://api.thunderphone.com/v1/integrations/test-request \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/weather?zip=94110",
"method": "GET",
"headers": { "X-Api-Key": "your-provider-key" }
}'{
"ok": true,
"status": 200,
"elapsed_ms": 187,
"response_headers": { "content-type": "application/json" },
"response_preview": "{\"temperature_f\": 64, ...}"
}This test also hardens ThunderPhone's SSRF guards — requests to
localhost or private IP ranges return 400 code=url_not_allowed.
4. Link the integration to an agent
Attach via integration_ids when you create or update an agent:
curl -X PATCH https://api.thunderphone.com/v1/agents/12 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"integration_ids": ["f9b5a1a4-..."]
}'You can link many integrations to one agent. The agent's prompt can
reference them by name — "use get_weather when the caller asks
about conditions" — or it can discover them implicitly from the
schema descriptions.
5. Implement the endpoint
When the agent invokes the tool, ThunderPhone sends a signed POST to
your endpoint_url:
POST /weather HTTP/1.1
Host: api.example.com
X-Api-Key: your-provider-key
X-ThunderPhone-Signature-V2: t=<unix seconds>,v1=<HMAC-SHA256 hex>
X-ThunderPhone-Signature: <HMAC-SHA256 hex>
X-ThunderPhone-Call-ID: 987654321
Content-Type: application/json
{"zip": "94110"}
Your server responds with JSON that gets handed back to the LLM:
{"temperature_f": 64, "condition": "Partly cloudy", "wind_mph": 8}The LLM ingests that response and speaks a human summary to the caller.
6. Test the loop
Run a mic session against the agent and ask the question your tool handles ("What's the weather in 94110?"). The call's transcript shows the full round trip:
{
"call_id": 987654321,
"transcripts": [
{ "role": "user",
"content": "What's the weather in 94110?" },
{ "role": "tool_call",
"content": "{\"tool_call\": \"get_weather\", \"arguments\": {\"zip\": \"94110\"}}" },
{ "role": "tool_response",
"content": "{\"tool_name\": \"get_weather\", \"response\": {\"temperature_f\": 64, \"condition\": \"Partly cloudy\"}}" },
{ "role": "agent",
"content": "It's 64 degrees and partly cloudy." }
]
}You can pull this via
GET /v1/calls/{call_id}/transcript;
the raw event stream (with per-entry timing and audio offsets) is at
GET /v1/calls/{call_id}/history.
In call-listing and analytics surfaces, ThunderPhone now reports test calls separately from real traffic. Browser Talk tests and simulations can reuse a real phone number and may look inbound, but they do not count toward production call totals, grades, or issue metrics.
Common gotchas
Agent never calls the tool
The LLM decides based on the tool's description. If the caller's
question doesn't match the description, the model won't invoke
the tool. Tighten the description (add common synonyms and
phrasing) or mention it explicitly in the agent prompt ("When the
caller asks about weather, use get_weather.").
Tool returns too much data
Responses over 6 kB are truncated in the transcript preview. Return just the fields the LLM needs — not your whole row.
Timeouts
Tool endpoints have a 10-second default timeout. If you need longer,
handle it asynchronously: return {"status": "pending", "request_id": "..."}
and surface the result via a separate tool call.
Versioning
Every integration PATCH creates a new revision. Inspect
GET /v1/integrations/{id}/versions
to see who changed what. If you break a tool's schema, you can
roll back manually by PATCH-ing an older snapshot back in.