Route Inbound Calls to an AI Agent
Bind a published AI agent to a number, cover after-hours calls, hand back to a human via CallHandover, and add specialist transfers.
An AI agent can answer inbound calls on any number you own, either for every call or only outside your business hours. When the agent decides to hand the caller to a person — because the caller asked, or the agent judged it necessary — Sautikit POSTs a CallHandover request to the number's handover_callback_url describing why, and you respond with the voice actions that take over from there (typically a dial to a human line). An agent can also transfer a live call to a specialist agent instead of ending the session. This guide covers all three: binding, after-hours scheduling, and handling the handover.
handover_callback_url for the binding — the endpoint Sautikit POSTs the CallHandover request to when the agent hands back. It's required while the agent is enabled unless you point the handover at a handover_forward or handover_voicemail instead (see Forward calls and voicemail), and defaults to the number's voice_callback_url; if it's unreachable or errors, the call hangs up.In the dashboard, open Numbers → <your number> → AI Agent. Turn on Enable AI agent for inbound calls, pick a published agent from the list, and choose when it should answer:
voice_callback_url / legacy voice actions).Set Handover callback URL — the endpoint Sautikit POSTs the CallHandover request to when the agent hands back (see Step 4). It defaults to the number's voice_callback_url. Save.
The same binding is available as inbound_agent on PUT /v1/numbers/{id}/routing. It uses tri-state semantics distinct from voice_callback_url/events_url:
null — clear the binding (no AI agent answers this number).agent_id must reference a published agent in your workspace.curl -s -X PUT "https://api.sautikit.com/v1/numbers/$NUMBER_ID/routing" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbound_agent": {
"enabled": true,
"agent_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
"mode": "always",
"handover_callback_url": "https://example.com/voice"
}
}' | jq .handover_callback_url is required while enabled is true — unless the binding carries a handover_forward or handover_voicemail, which answer the handover themselves and take precedence over the callback. Omit it and Sautikit defaults it to the number's voice_callback_url (a 400 handover_callback_url_required if neither is set and no forward/voicemail handover target is configured).
const response = 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: "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
mode: "always",
handover_callback_url: "https://example.com/voice",
},
}),
});
const { number, routing } = await response.json();
console.log(routing.inbound_agent);numbers/inbound_agent binding isn't yet covered by @sautikit/node — use fetch (or any HTTP client) against the REST endpoint as shown above.
mode: "after_hours" requires a schedule: an IANA timezone plus one or more weekly windows. The windows describe your business hours — the agent answers outside them.
curl -s -X PUT "https://api.sautikit.com/v1/numbers/$NUMBER_ID/routing" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"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"
}
}' | jq .days is 0(Sunday)–6(Saturday); start/end are "HH:MM" on a 24-hour clock and must be anchored to a real IANA zone (time.LoadLocation — a made-up name fails with numbers.invalid_inbound_agent, detail invalid_timezone). A call arriving Monday–Friday 08:00–17:30 Nairobi time reaches your normal routing; a call arriving at 19:00 or on Saturday reaches the agent instead. The dashboard's timezone picker is a curated shortlist for convenience — the API accepts any valid IANA zone name.
When the agent decides to hand the call to a person (it called its built-in handover tool — for example, the caller asked for a human), Sautikit constructs a fresh request and delivers it two ways:
handover_callback_url — as an ordinary voice callback. The request carries X-Sautikit-Timestamp and X-Sautikit-Signature: t=<unix>,v1=<hex> headers, but signature verification for voice callbacks is not yet generally available — don't gate your handler on the signature for now. Your response is parsed through the same path as any other voice callback: return the standard voice-actions envelope and Sautikit renders it to the caller.events_url as a call.handover lifecycle event — unsigned, and a no-op if events_url is unset.Request body (handover_callback_url):
{
"sessionId": "HD_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"callerNumber": "+254700000001",
"destinationNumber": "+254712345678",
"direction": "inbound",
"status": "CallHandover",
"handover": {
"agent_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0",
"agent_name": "Sales Bot",
"reason": "caller asked to speak to a human",
"summary": "Caller wants a refund for order #4821; already confirmed the order exists."
}
}There is no kind field on this body — discriminate on status: "CallHandover". Respond with a normal voice-actions payload, for example transferring to a human line:
{
"actions": [
{ "say": { "text": "Sure — connecting you to a specialist now." } },
{ "dial": { "number": "+254700000001" } }
]
}app.post("/voice", express.json(), (req, res) => {
if (req.body.status === "CallHandover") {
const { reason, summary } = req.body.handover;
console.log(`Handover: ${reason} — ${summary}`);
return res.json({
actions: [
{ say: { text: "Sure — connecting you to a specialist now." } },
{ dial: { number: "+254700000001" } },
],
});
}
// ...handle the other callSessionState values here.
});If handover_callback_url is unreachable, times out, or returns a non-2xx, Sautikit hangs the call up. The callback is only consulted when the binding has no handover_forward or handover_voicemail — those take precedence and are what makes the URL optional.
A published agent can hold specialist links to other agents in the same workspace: at runtime, the model can call a built-in transfer_to_specialist tool to hand the live call to one of them instead of ending the session or handing back to a human. In the dashboard, open Agents → <agent> → Specialists to manage a target agent's links; each link carries a description telling the main agent when to use it.
Links are managed as a full-replacement set via the API:
curl -s -X PUT "https://api.sautikit.com/v1/agents/$AGENT_ID/links" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"links": [
{
"target_agent_id": "3f1a9c02-6b3e-4a1f-9d7c-2e8b5a0c4d11",
"description": "Transfer billing questions to the billing specialist."
}
]
}' | jq .curl -s "https://api.sautikit.com/v1/agents/$AGENT_ID/links" \
-H "Authorization: Bearer $SAUTIKIT_API_KEY" | jq .A draft's links freeze into the agent's next published revision exactly like its tools — editing links after publishing only affects the next publish, not calls already in flight. A single call can transfer at most 5 times; once that cap is hit, the model is told to keep helping the caller itself or end the call.
Inbound-agent calls bill the same way as any other AI agent usage: per-minute plus token cost, recorded as an ai_debit wallet ledger entry. There is no separate pricing surface for inbound versus outbound agent calls.
The inbound_agent object failed validation. Check details[0] for the specific reason: invalid_mode (must be always or after_hours), agent_required (missing agent_id), schedule_required (after_hours needs a schedule), empty_schedule (schedule.hours is empty), invalid_timezone, invalid_window (bad days/start/end), handover_callback_url_required (enabled with no handover_callback_url, no voice_callback_url to default from, and no handover_forward/handover_voicemail to answer the handover instead), or invalid_handover_callback_url (not a valid http/https URL — checked whenever a value is supplied, in any handover mode).
agent_id doesn't reference a published agent in this workspace. Publish the agent first, or double-check the ID.
Confirm inbound_agent.enabled is true — a configured-but-disabled binding is stored but inert, which lets you pause without discarding the configuration. Then confirm the mode: an after_hours binding only answers outside its configured windows.
PUT /v1/numbers/{id}/routing reference