---
title: Route Inbound Calls to an AI Agent
description: >-
  Bind a published AI agent to a number, cover after-hours calls, hand back to a
  human via CallHandover, and add specialist transfers.
summary: >-
  Bind a published AI agent to answer inbound calls on a number (always or
  after-hours), respond to the CallHandover callback when the agent hands back
  to a human, and chain specialist agents.
date: 2026-07-21T00:00:00.000Z
type: guide
---


## Summary

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.

## Prerequisites

- A published AI agent in your workspace. Agent authoring (draft, tools, publish) is a separate flow — see the Agents section of the [API reference](/developers/api#agents). Only a **published** revision can be bound to a number.
- A claimed number. See [Claim and route a number](/developers/guides/claim-and-route-a-number).
- A `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](/developers/guides/forward-calls-and-voicemail#step-6-route-an-ai-agents-handover-to-forward-or-voicemail)), and defaults to the number's `voice_callback_url`; if it's unreachable or errors, the call hangs up.

---

## Steps

### Step 1: Bind an agent to a number (dashboard)

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:

- **Always** — the agent answers every inbound call to this number.
- **After hours only** — the agent answers outside the calling-hours windows you define below; calls arriving inside a window fall through to the number's normal routing (`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](#step-4-handle-the-callhandover-callback)). It defaults to the number's `voice_callback_url`. Save.

### Step 2: Bind an agent to a number (API)

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`:

- **omitted** from the request body — leave the current binding untouched.
- **`null`** — clear the binding (no AI agent answers this number).
- **an object** — validate and set. `agent_id` must reference a **published** agent in your workspace.

```bash
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).

```js
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.

### Step 3: After-hours example (Africa/Nairobi)

`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.

```bash
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.

### Step 4: Handle the CallHandover callback

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:

1. **Synchronously to `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.
2. **Fire-and-forget to `events_url`** as a `call.handover` lifecycle event — unsigned, and a no-op if `events_url` is unset.

Request body (`handover_callback_url`):

```json
{
  "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:

```json
{
  "actions": [
    { "say": { "text": "Sure — connecting you to a specialist now." } },
    { "dial": { "number": "+254700000001" } }
  ]
}
```

```js
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.

### Step 5: Add specialist transfers

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:

```bash
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 .
```

```bash
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.

## Billing

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.

## Troubleshooting

### 400 `numbers.invalid_inbound_agent`

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).

### 400 `numbers.unknown_agent`

`agent_id` doesn't reference a published agent in this workspace. Publish the agent first, or double-check the ID.

### The agent never answers

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.

## Next steps

- [Bind an agent's routing](/developers/api/updateNumberRouting) — full `PUT /v1/numbers/{id}/routing` reference
- [List an agent's specialist links](/developers/api/listAgentLinks)
- [Replace an agent's specialist links](/developers/api/replaceAgentLinks)
- [Claim and route a number](/developers/guides/claim-and-route-a-number)
- [Voice Actions DSL](/developers/concepts/voice-actions)
