AI Agent Custom Integrations (Mid-Call Tool Webhooks)
A custom integration is a tool an AI agent can invoke during a live conversation. The agent collects a defined set of fields from the caller, the platform POSTs them as JSON to an HTTPS endpoint you host, and your endpoint’s JSON reply is handed straight back to the AI as the tool result: the agent reads it and speaks from it. One mechanism covers both directions:
- Submit (push): the agent sends data to your system: a lead, a booking request, a work order. Your endpoint stores it and replies with a confirmation the agent speaks.
- Lookup (pull): the agent fetches data from your system: an order status, an account balance, an appointment slot. Your endpoint replies with the data, and the agent answers the caller with it.
Custom integrations are configured on an agent under PBX > AI > AI Agents > edit agent > Tools card. With the AI API Access add-on (offered on the Trial and Pro AI plans) a tenant administrator sets them up directly; without the add-on a custom integration cannot run at all, so it is an add-on to add to your plan rather than something support can configure on your behalf. This page is the contract your developers build the receiving endpoint against.
How It Works
- Each integration is defined by a tool name, a description (the AI decides when to call the tool from this text), a field list (what the AI collects from the caller before calling), and your endpoint URL.
- The tool is offered to the AI as a callable function for the whole conversation. When the conversation reaches the point the description covers (“use this to submit a service request once you have the caller’s name, address, and issue”) the AI gathers any missing fields conversationally, then invokes the tool.
- The platform sends the collected fields to your endpoint, by default as a
POSTof the body shown below, or on the method and address shape you configure if you are calling an API you already have. While the request runs, the agent can speak a configured filler line (“One moment while I get that submitted…”). - Your endpoint replies with JSON. That reply becomes the tool result the AI reads next, so whatever you return is what the agent knows and can say. On failure (timeout, non-2xx, unreachable) the AI instead receives a speakable failure and recovers the conversation (“I’m having trouble reaching that system right now”).
Request Contract
This is the default shape: one HTTPS POST per tool invocation, Content-Type: application/json. To send a different method, put collected values in the address, or send the fields without this wrapper, see Calling an API You Already Have.
{
"name": "submit_booking",
"args": {
"caller_name": "Jane Smith",
"party_size": 4,
"requested_time": "2026-08-01 19:00"
},
"call": {
"call_id": "sip1-atl-1721857200.482",
"agent_id": 42,
"from_number": "+14045551234",
"to_number": "+14045550100"
}
}
| Field | Description |
|---|---|
name | The tool name as configured on the agent. One endpoint can serve several tools by switching on this value. |
args | The fields the AI collected, keyed exactly as the integration’s field list defines them. Types follow the field definitions (string / integer / number / boolean). Optional fields the caller did not provide may be absent. |
call.call_id | The stable per-conversation identifier, sent on every channel. The same value identifies the conversation in Result Webhook and campaign result deliveries (as call_id on a voice call and as chat_id on a web chat or SMS conversation) so you can join a mid-conversation submission to its post-conversation summary. On voice calls it is the switch’s own call identifier, which also appears in your call records; on web chat and SMS it identifies the conversation. Treat it as an opaque string: its format differs by channel and may change, so compare it for equality rather than parsing it. |
call.agent_id | Numeric ID of the AI agent that made the tool call. |
call.from_number / call.to_number | The call’s origin and destination (E.164), following the call’s direction: on an inbound call, from_number is the caller and to_number is the business number they dialed; on an outbound (campaign) call, from_number is the business number the agent is calling from and to_number is the person being called. Result Webhook bodies use the same semantics, so the parties always agree for one call_id. For web chat and SMS conversations these may be empty or carry the texting number. |
This body is a stable, versioned contract; existing endpoints keep working unchanged as the platform evolves.
Response Contract
Reply 200 (any 2xx) with a JSON body. The body is given to the AI verbatim as the tool result: design it as the information you want the agent to have next:
Submit example: confirm what happened so the agent can close the loop:
{ "result": "Booked. Confirmation number A-1042. A text confirmation is on its way." }
Lookup example: return the data; the agent phrases it naturally:
{ "order_status": "shipped", "carrier": "UPS", "eta": "Friday, August 1" }
- Keys and structure are yours to choose; the AI reads whatever JSON you return. Short, flat, human-meaningful values work best; the agent will paraphrase them.
- A non-JSON body is wrapped as
{"response": "<raw text>"}and still reaches the agent. - To steer the agent, return an instruction as data:
{ "error": "no appointment slots that day", "suggest": "offer the next morning instead" }. - Lookup freshness: duplicate suppression applies to writes only (
POST,PUT,PATCH): an identical write (same conversation, tool, and arguments) within the ~15-minute window returns the first recorded result rather than sending again, so an ambiguous timeout cannot submit twice. AGETis a lookup and is never suppressed; asking the same question twice in a conversation queries your system twice and gets two live answers, which is what a question like “is that slot still free?” requires.
Delivery Rules
| Rule | Detail |
|---|---|
| HTTPS only, public host | The endpoint must be https:// on a publicly-routable host. Private/internal addresses are rejected before any request is sent. |
| Success is 2xx only | Redirects are not followed; a 301/302 counts as failure (the POST body would not reach the destination). Configure the final URL. |
| Timeout | 20 seconds by default, settable from 1 to 60 per integration (5 s to connect). A caller is waiting on the line for the whole of it; answer fast, and do slow work asynchronously after replying. |
| Response size | Up to 256 KB. Larger replies abort and the tool fails. |
| Duplicate suppression (best-effort, writes only) | Applies to POST, PUT and PATCH. A GET is a lookup and is never suppressed: it always reaches your system. The platform never re-sends a request on its own, and an identical write by the AI (same conversation, tool, and arguments) within a ~15-minute window is suppressed on a best-effort basis: it receives the recorded result, or a refusal when the first attempt’s outcome is unproven (a timeout after your side may have committed). This suppression is not a distributed lock; a narrow race can let two identical requests through. Endpoints that perform irreversible actions must enforce their own idempotency (call_id plus a key derived from the submitted fields); treat platform-side suppression as an optimization, not a guarantee. |
| Failure is graceful | On timeout / non-2xx / unreachable, the agent receives a speakable failure result; the call continues; nothing crashes. |
Authenticating Requests
Set the request headers your API expects, one per line, in the integration’s Authentication headers box. The values are fixed, so this covers any scheme that authenticates with a constant header: bearer tokens, API keys, and multi-header combinations of them:
Authorization: Bearer 8f4c...e2a1
X-API-Key: your-key
X-Account-Id: 40219
Values are stored securely and are never displayed again after you save. Editing an integration leaves the saved values in place unless you type replacements or tick Remove all headers, so an unrelated change can never quietly clear your credentials.
These are fixed header values. That covers bearer tokens, API keys and any static header scheme, including several headers together. It does not cover schemes that must be computed per request (HMAC request signing, AWS SigV4, or OAuth flows that refresh a token) because the platform sends exactly what you stored and has no signer, token exchange, nonce or body-digest step. If your API requires one of those, put a small endpoint of your own in front of it that accepts a static key from us and performs the signing.
A few headers are reserved because the platform sets them or they would change where the request lands: Host, all Content-* headers, the connection and framing headers, the proxy hints Forwarded, Via, X-Forwarded-*, X-Original-* and X-Rewrite-*, and method-override headers such as X-HTTP-Method-Override. The editor refuses these with an explanation.
When you choose the bare-fields body style, the request also carries X-Cloudspire-Call-Id and X-Cloudspire-Tool, so you can still identify the conversation and the tool. With the default Cloudspire envelope those values are already in the body, so the headers are not sent and an endpoint written before this feature existed sees exactly the request it always did.
A secret in the URL still works if that is what your API expects, but headers are preferred: a URL is easier to leak through access and proxy logs you do not control.
Endpoints that perform irreversible actions should still add their own idempotency key (call_id plus your own dedup) and sanity limits. The platform suppresses an identical write retry for about 15 minutes as a best-effort guard, but that is a safety net, not a guarantee. For cryptographically signed deliveries, the post-call Result Webhooks surface signs every payload with a per-URL secret.
Calling an API You Already Have
An integration does not have to be an endpoint built for Cloudspire. It can call your existing REST API directly.
Choose the method
GET for lookups, POST, PUT or PATCH for writes. On GET no body is sent.
A GET is treated as a read: it is never retry-suppressed, so if the agent asks the same question twice in a conversation it gets a fresh answer both times rather than a replayed one. Writes keep the duplicate-submission guard described above.
Put collected values in the address
Wrap a field name in curly braces to build a REST path or query:
https://api.example.com/v1/reservations/{confirmation_code}
https://api.example.com/v1/availability?date={requested_date}&covers={party_size}
Rules that keep this safe and predictable:
- Placeholders work in the path and query only. The host is always fixed, so an integration can never be redirected to another server by something a caller said.
- Every placeholder must name a field the integration collects, and that field must be required: an optional field would leave a hole in the address. The editor refuses the configuration otherwise, rather than letting it fail on live calls.
- Values are URL-encoded, and values containing
/,\,%, or a bare./..path segment are rejected. Identifiers, dates and reference codes all pass; anything that could reshape the address does not. - The address is sent exactly as you wrote it, with the placeholders filled. Collected fields you do not place in the address are not appended to it; if a field belongs in the query, put it there yourself:
?code={confirmation_code}. - A
#fragment is rejected. Browsers keep everything after#and never send it, so a value placed there would never reach your server.
Choose the body style
Cloudspire envelope (the default) sends the {name, args, call} body documented above. Just the collected fields sends the fields as the top-level JSON object instead, which is usually what an existing API expects:
{"caller_name": "Jane Smith", "party_size": 4, "requested_time": "2026-08-01 19:00"}
You lose nothing by choosing it; the call context is still available in the X-Cloudspire-Call-Id and X-Cloudspire-Tool headers.
Return only what the agent needs
By default the whole JSON reply is given to the AI. If your API returns a large or deeply nested object, list the fields that matter in Read these fields from the reply, one per line, using dots for nested values:
booking.reference
booking.confirmed_time
slots.0.start
The agent then sees only {"reference": ..., "confirmed_time": ..., "start": ...}: keyed by the last segment of each path. This measurably improves how reliably the agent speaks the right value.
Because the last segment becomes the key, two paths ending in the same word (customer.id and booking.id) would both arrive as id and one would replace the other. That selection is rejected when you save, naming both paths, rather than silently dropping a value at call time.
If none of the listed fields exist in the reply, the call is reported as a configuration error rather than as an empty success. That is deliberate: an empty result would read to the agent as “your system has no record of this caller”, so a mistyped path would have it confidently tell callers their booking does not exist.
Set a timeout that matches your system
The default is 20 seconds and can be set between 1 and 60. The caller is on the line waiting for the whole of it, so keep it as low as your system allows, and turn on the spoken filler line for anything slow.
Test it before you rely on it
Save the integration, then press Test this integration. The platform sends sample values through exactly the same path a live call uses and shows you the status, the round-trip time, which headers were sent (names only), and your reply. A 401 tells you the credentials are wrong; a 404 with sample values usually means everything is working and the sample record simply does not exist. Recent integration calls, below the integration list, shows what real conversations have been doing.
Two-Way Data Access: the Full Picture
Custom integrations are the mid-call lane. The complete set of ways data moves between your systems and an AI agent:
| Direction | When | Mechanism |
|---|---|---|
| Into the agent | Call start | Dynamic variables: caller number and CNAM are injected automatically; outbound campaign calls carry the per-call variables you queued (see the AI Outbound Calling API). |
| Into the agent | Mid-call | Custom integration (lookup): this page. The agent asks your system a question and speaks the answer. |
| Into the agent | Any time | Knowledge base articles: curated content the agent searches during the conversation. |
| Out of the agent | Mid-call | Custom integration (submit): this page. The agent pushes collected data to your system while the caller is still on the line. |
| Out of the agent | After the conversation | Result Webhooks: outcome, transcript, sentiment, and summary POSTed to your URLs, signed. Reference. |
| Out of the agent | After an outbound campaign call | Campaign result callback: the per-call result POSTed to the webhook_response_url you supplied when queueing. Reference. |
Worked Example: a Booking Tool
Configuration on the agent:
| Tool name | submit_booking: letters, numbers, underscores, dashes. This is the function name the AI calls and the name your endpoint receives. |
| What it does | “Submit a table reservation once you have the caller’s name, party size, and requested time. Use only after confirming all three back to the caller.”; the AI decides when to invoke the tool from this text, so write it as an instruction about when and with what. |
| Fields | caller_name | string | required | Name for the bookingparty_size | integer | required | Number of guestsrequested_time | string | required | Requested date and time: these become the JSON Schema the AI fills; each description tells the AI what to ask for. |
| Endpoint URL | https://example.com/hooks/bookings: your system. |
| Authentication headers | Authorization: Bearer …: sent with every request. |
| While it runs | “Let me get that booked for you.”, spoken during the request so the caller never hears dead air. |
The caller says “table for four Friday at seven, name’s Jane”; the AI confirms the details, invokes submit_booking, your endpoint books it and replies {"result":"Booked, confirmation A-1042"}, and the agent says “You’re all set; your confirmation number is A-1042.”