An AI receptionist answers every inbound call, greets the caller by voice, understands what they want in natural language, answers the common questions, and hands off to a human only when it needs to. No hold music, no voicemail, no missed calls after hours.
Sautikit now runs this for you. AI agents are a managed product: you describe the receptionist in a prompt, publish it, and bind it to your number. Sautikit hosts the model, the voice, and the media plumbing — there is no server to run and no audio socket to terminate. Calls are answered in real time, the caller can interrupt mid-sentence, and when the agent decides a human is needed it hands the call to your team with a written summary of the conversation so far.
If you would rather bring your own model, the original build-it-yourself recipe — the Stream voice action forking live audio to your WebSocket — still works and is documented in the second half of this page.
Three API calls stand between you and a working receptionist. All agent and routing endpoints take your API key as a Bearer token; the full request and response shapes live in the API reference.
The base_prompt is the receptionist's job description. Pick a model from the Sautikit catalog (GET /v1/models lists the ids, e.g. sautikit-flash), a voice, and a hard cap on call length.
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: "Front desk",
base_prompt:
"You are the receptionist for Acme Advocates in Nairobi. Greet callers, answer questions about office hours (Mon-Fri 8:00 to 17:30), location, and services, and collect a name and callback number for anything that needs a partner. Hand over to a human whenever the caller asks for one.",
model: "sautikit-flash",
voice: "Woman",
max_call_minutes: 10,
}),
});
const agent = await res.json();Only a published revision can answer calls, so drafts can be edited safely while a previous revision keeps working the phones.
await fetch(`https://api.sautikit.com/v1/agents/${agent.id}/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}` },
});mode: "always" puts the agent on every inbound call. handover_callback_url is where Sautikit sends the handover request when the agent passes a caller to your team.
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://example.com/voice",
},
}),
});Prefer the receptionist to cover only the hours your team does not? Use mode: "after_hours" with a schedule describing your business hours — the agent answers outside them, and calls inside them fall through to your normal routing:
{
"inbound_agent": {
"enabled": true,
"agent_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
"mode": "after_hours",
"schedule": {
"timezone": "Africa/Nairobi",
"hours": [{ "days": [1, 2, 3, 4, 5], "start": "08:00", "end": "17:30" }]
},
"handover_callback_url": "https://example.com/voice"
}
}The routing guide covers the schedule format, tri-state binding semantics, and every validation error.
The conversation is real-time and interruptible — a caller who talks over the agent is heard, and the agent stops and responds, the way a person would. The agent can call custom HTTP tools you attach to it (each request HMAC-signed, parameters defined by JSON Schema) to look things up in your systems mid-call, transfer to a specialist agent in the same session, or end the call itself when the caller is done. Every call is capped by max_call_minutes (default 15), so no conversation can run the meter indefinitely.
If no AI seat is free or the bound agent is unpublished, the call falls through to the number's normal routing. Callers never hear an error.
When the agent decides a human is needed — the caller asked, or the conversation went beyond its brief — Sautikit POSTs a CallHandover request to your handover_callback_url with the agent's reason and a summary of the conversation so far:
{
"sessionId": "HD_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"callerNumber": "+254700000001",
"status": "CallHandover",
"handover": {
"agent_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
"agent_name": "Front desk",
"reason": "caller asked to speak to a human",
"summary": "Caller wants to book a consultation about a land matter; available Thursday afternoon; callback number confirmed."
}
}You respond with ordinary JSON voice actions — typically a dial to your team — and the human picks up a call they have already been briefed on:
{
"actions": [
{ "say": { "text": "Connecting you to the front desk now." } },
{ "dial": { "number": "+254700000001" } }
]
}Calls are recorded by default, and every agent call produces a per-turn transcript — who said what, when, where the caller interrupted, and which tools the agent called:
const call = await fetch(
`https://api.sautikit.com/v1/calls/${CALL_ID}?transcript=1`,
{ headers: { Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}` } },
).then((r) => r.json());Read the transcripts of the first week's calls and tighten the base_prompt where the agent wandered — publishing a new revision takes effect on the next call.
The managed agent is the fast path, but it is not the only one. If you want your own model in control of the conversation — your prompt orchestration, your voice stack, your inference bill — Sautikit makes this a media problem, not a telephony problem. You attach a webhook to a number, return a <Stream> voice action, and Sautikit forks the live call audio to your WebSocket server as raw PCM. You relay that audio to any LLM voice model and send the synthesised reply back on the same socket. When the AI decides a human is needed, your flow returns a <Dial> to warm-transfer the caller.
routing_url points at your voice webhook.<Response> containing a <Stream> action.url in your <Stream>. Your server must advertise the audio.drachtio.org subprotocol on the handshake, or the connection is rejected.<Dial> to a human's number and the caller is warm-transferred.When the number is dialled, your webhook replies with an application/xml body opening the media stream:
<Response>
<Stream
name="receptionist"
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>track="both_tracks" forks both call legs so your model hears the caller and its own playback. 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 is the PCM rate Sautikit sends to your socket; bidirectionalSamplingRate is the rate you send PCM back at (Sautikit resamples it to the channel codec). Audio on the wire is 16-bit little-endian PCM.
Your server terminates the socket, relays PCM to your LLM, and pipes the model's PCM back. The one hard requirement: advertise the audio.drachtio.org subprotocol.
import { WebSocketServer } from "ws";
import { connectToLLM } from "./llm.js"; // Gemini Live / OpenAI Realtime / self-hosted
const wss = new WebSocketServer({
port: 8080,
handleProtocols: () => "audio.drachtio.org", // required by Sautikit
});
wss.on("connection", async (call) => {
const llm = await connectToLLM({ sampleRate: 16000 });
// Caller audio (binary PCM) -> LLM
call.on("message", (frame, isBinary) => {
if (isBinary) llm.sendAudio(frame);
});
// LLM audio (binary PCM) -> back into the call on the same socket
llm.on("audio", (pcm) => call.send(pcm, { binary: true }));
// When the model decides to escalate, close the stream so your
// voice webhook flow can return the <Dial> below.
llm.on("handoff", () => call.close());
});When the AI hands off, end the stream and let your flow return a <Dial> to the human's number, warm-transferring the caller:
<Response>
<Say>Connecting you to the front desk now.</Say>
<Dial>+254700000001</Dial>
</Response>The inbound call leg is billed per second for as long as the call is live on the Sautikit platform. There is no separate Sautikit charge for opening the media stream or for the WebSocket round-trips, and no AI-minute charge on this path — your LLM and voice-model costs are billed by that provider on their own metering. Sautikit only moves the audio.
CallHandover contract.<Stream> attribute table for the build-your-own path.