An AI customer-support voice agent answers inbound support calls, resolves the routine ones, and escalates the rest. It handles FAQs, looks up account or order context from your backend mid-call, walks the caller through simple fixes, and connects a human agent on the same call when it hits something it cannot solve. The goal is deflection: most callers get an answer without ever waiting in a queue.
Sautikit's managed AI agents run this end to end. You write the agent's brief as a prompt, attach HTTP tools that call your API — each request HMAC-signed so your endpoint can verify it came from your agent — publish, and bind the agent to your support number. When the agent escalates, your team does not get a cold transfer: the handover request carries the agent's reason and a summary of everything the caller already said.
Want your own model in the loop instead? The build-it-yourself Stream recipe is in the second half of this page.
The base_prompt sets the agent's job and its boundaries. Models come from the Sautikit catalog (GET /v1/models, ids like sautikit-flash).
const res = await fetch("https://api.sautikit.com/v1/agents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Support line",
base_prompt:
"You are the support agent for Wema Stores. Help callers with order status, delivery windows, and returns. Use the order lookup tool before answering any order question. If the caller disputes a charge or asks for a human, hand over.",
model: "sautikit-flash",
voice: "Woman",
max_call_minutes: 10,
}),
});
const agent = await res.json();
// agent.tool_signing_secret is returned once, here — store it.The create response includes the agent's tool_signing_secret exactly once. Your API uses it to verify tool-call signatures below.
Tools are plain HTTP endpoints on your side. You describe the parameters with JSON Schema; the model decides when to call the tool and fills the arguments from the conversation. Every request is HMAC-signed with the agent's signing secret, so your endpoint can reject anything that did not come from your agent.
const toolId = crypto.randomUUID();
await fetch(`https://api.sautikit.com/v1/agents/${agent.id}/tools/${toolId}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "lookup_order",
description:
"Fetch the status, items, and delivery window for a customer's order.",
url: "https://api.wemastores.example.com/agent/orders/lookup",
method: "POST",
parameters: {
type: "object",
properties: {
order_number: { type: "string", description: "e.g. WS-4821" },
phone: { type: "string", description: "Caller's number in E.164" },
},
required: ["order_number"],
},
timeout_ms: 5000,
}),
});Whatever JSON your endpoint returns goes back into the conversation — the agent reads it and answers the caller in plain language. The same mechanism handles booking changes, ticket creation, or CRM writes: any endpoint you are willing to expose to your own agent.
await fetch(`https://api.sautikit.com/v1/agents/${agent.id}/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}` },
});
await fetch(`https://api.sautikit.com/v1/numbers/${NUMBER_ID}/routing`, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inbound_agent: {
enabled: true,
agent_id: agent.id,
mode: "always",
handover_callback_url: "https://api.wemastores.example.com/voice",
},
}),
});mode: "always" puts the agent on every call; mode: "after_hours" with a schedule keeps humans on the line during business hours and the agent covering nights and weekends — the routing guide shows both. If no AI seat is free or the agent is unpublished, calls fall through to your normal routing; callers never hear an error.
The conversation is real-time and interruptible — a caller who cuts in mid-sentence is heard, and the agent adjusts. Tool calls happen inside the conversation, so "where is my order WS-4821" becomes a lookup against your API and a spoken answer in the same breath. The agent can transfer the caller to a specialist agent in the same session (billing questions to a billing agent, for example), end the call itself when the caller is done, and is hard-capped by max_call_minutes (default 15).
When the agent hands over — the caller asked for a person, or the issue went beyond its brief — Sautikit POSTs a CallHandover request to your handover_callback_url with the reason and a summary:
{
"sessionId": "HD_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"callerNumber": "+254700000001",
"status": "CallHandover",
"handover": {
"agent_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
"agent_name": "Support line",
"reason": "caller disputes a delivery charge",
"summary": "Caller wants a refund for order WS-4821; order exists and shows delivered Tuesday; caller says it never arrived."
}
}You reply with normal JSON voice actions — a dial to your support desk — and the human joins a call where identity is confirmed, the order is found, and the complaint is already written down:
{
"actions": [
{ "say": { "text": "Let me get a specialist on the line." } },
{ "dial": { "number": "+254720000010" } }
]
}Calls are recorded by default, and GET /v1/calls/{id}?transcript=1 returns the per-turn transcript — roles, timestamps, interruptions, and every tool call with its arguments — so you can audit exactly what the agent looked up and said before refining the prompt.
AI time bills at KES 4.00 per minute, metered per second, on top of the standard per-second call rates; a deflected 50-second FAQ costs 50 seconds, not a rounded-up minute. The free tier includes 1 concurrent AI call (paid tiers: 5, 8, or 10), a dedicated number is KES 116 per month claimed instantly, and the wallet is prepaid via M-Pesa. Every call the agent resolves end to end is a human-agent minute you did not pay a person for.
If you want your own LLM driving the conversation — your prompts, your tools, your inference bill — Sautikit forks the live call audio to your server and you own everything above the media layer.
Your number's routing_url returns a <Stream> voice action; Sautikit forks live caller audio to your WebSocket server; your bridge relays that audio to the LLM of your choice and streams synthesized speech back into the call. Because your server owns the conversation, the same LLM turn that decides "I can't resolve this" can end the stream and return a <Dial> to a human.
routing_url points at your voice webhook.<Response> containing a <Stream> action with your wss:// URL.audio.drachtio.org subprotocol on the handshake, or Sautikit rejects the connection.outputSamplingRate you requested (use 16000 for AI models).<Dial> to a human agent, connecting them on the same call.Your server owns the conversation state. Key the LLM session and any tool results by the call SID from the stream handshake, so a mid-call escalation to <Dial> can pass everything the human agent needs (verified identity, account ID, what the caller already tried).
| Attribute | Required | Notes |
|---|---|---|
url | yes | wss:// endpoint Sautikit connects to. |
track | yes | inbound_track, outbound_track, or both_tracks. |
connect | yes | connect="true" answers and holds the call leg for the fork's lifetime. Without it the document ends immediately and the call hangs up. |
outputSamplingRate | yes | 8000 or 16000. Use 16000 for AI models. This is the rate delivered to your socket. |
bidirectionalSamplingRate | no | Rate your server sends PCM back at; Sautikit resamples it to the channel codec. Without it, bidirectional playback behavior is undefined. |
name | no | Friendly identifier echoed in stream status events. |
headerMetadata | no | Flat JSON object of string key/value pairs (auth tokens, tenant/correlation IDs), folded into the first WebSocket text frame alongside openMetadata — not HTTP handshake headers. |
openMetadata | no | Opaque UTF-8 payload sent as the first text frame. |
statusCallback | no | URL Sautikit POSTs stream status events to. |
statusEvents | no | Space-separated subset of stream-started, stream-stopped, stream-error. |
Audio on the socket is 16-bit little-endian PCM. Your server must accept the audio.drachtio.org subprotocol.
When the number is dialled, Sautikit POSTs to your routing_url. Reply with application/xml:
<Response>
<Stream
name="support-agent"
url="wss://your-app.example.com/audio"
track="both_tracks"
connect="true"
outputSamplingRate="16000"
bidirectionalSamplingRate="16000"
statusCallback="https://your-app.example.com/stream-status"
statusEvents="stream-started stream-stopped stream-error" />
</Response>This sketch shows where the LLM plugs in, where you'd invoke an internal lookup, and how you'd signal an escalation. Wire the LLM client and PCM plumbing to your provider.
import { WebSocketServer } from "ws";
// Sautikit negotiates the `audio.drachtio.org` subprotocol on connect.
const wss = new WebSocketServer({
port: 8080,
handleProtocols: (protocols) =>
protocols.has("audio.drachtio.org") ? "audio.drachtio.org" : false,
});
wss.on("connection", (ws) => {
const llm = startLLMSession({
// Your internal tools the model can call mid-conversation.
tools: {
async getAccount({ msisdn }) {
const res = await fetch(
`https://internal.example.com/accounts?phone=${msisdn}`,
);
return res.json(); // balance, plan, open tickets, outage status...
},
},
// The model calls this when it can't resolve the issue.
onEscalate: (reason) => escalateToHuman(ws, reason),
});
ws.on("message", (data, isBinary) => {
if (isBinary) {
// Live caller audio: 16-bit LE PCM at 16000 Hz. Feed it to the model.
llm.pushAudio(data);
}
});
// Model output: PCM back on the same socket. Sautikit plays it into the call.
llm.on("audio", (pcm) => ws.send(pcm, { binary: true }));
});
function escalateToHuman(ws, reason) {
// Close the stream, then return a <Dial> from your routing flow so the
// caller is connected to a human agent on the same call.
ws.close();
// e.g. redirect the call to a webhook that responds with:
// <Response><Dial><Number>+254720000010</Number></Dial></Response>
}<Dial> connects a human agent, the per-second rate continues across the connected legs.CallHandover contract.<Stream> reference for the build-your-own path.