# Customer Support Agent with Handoff

> An inbound support agent that answers, resolves with tools, and escalates to a human. The call sid stays the same across the handoff.

You want an inbound line where an AI agent picks up. The agent looks up data to
resolve the caller's problem. When the agent cannot resolve the problem, or
when the caller asks for a person, it hands off to a human **with context**.
One `call_sid` addresses the call the whole time. Recordings, reports, and QoS
keyed on the sid therefore stay intact across the handoff.

This recipe wires the full path:

  1. **Route the number to an AI agent**
Inbound calls on your DID land on a server-side agent. No code in your
    process answers the phone.
  2. **Give the agent a tool to resolve the query**
A custom HTTP tool (`lookup_order`) lets the model read your systems. The
    model can then answer with real data.
  3. **Escalate to a human with context**
The built-in `route_to_skill` tool drops the live caller into an ACD skill
    queue with a one-line summary screen-pop. The runtime stamps the `call_sid`
    onto the handoff.
  4. **Accept and bridge — same sid throughout**
A human accepts. The engine bridges the caller. The `call_sid` never
    changes.

> **NOTE:**
> The agent runs **server-side**, inside FirstByte Voice. You configure the
> agent and its tools once. You do not hold an audio bridge in your process for
> this pattern. Your code shows up in two places only: the backend that your
> `lookup_order` tool calls, and (optionally) the control-plane calls that
> observe or steer the call.

## 1. Route the number to an AI agent

Inbound routing is control-plane configuration, not SDK code. A DID (or a
whole trunk) binds to an `agent_id`. Calls that land on it inherit that agent.
Set this binding on the number in the console, or provision it when you buy the
number. See [Inbound calls](/modalities/voice/transport-telephony/inbound) and
[Number provisioning](/modalities/voice/transport-telephony/number-provisioning).

After the DID points at your agent, the engine answers each inbound call
(early-media SDP, then a 200). The engine wires the audio bridge end-to-end.
The agent starts to talk. Nothing in your process has to be online for the
engine to answer the call.

## 2. Define the agent and its resolve-the-query tool

An agent is a small orchestration graph (its DAG). It has a system prompt, a
speech pipeline, and a set of tools that the model may call mid-conversation.
Author it in the console, or push it over the control-plane API. There is no
SDK wrapper for the DAG itself (see [Agent DAGs](/admin/agent-dags) and
[Tool calling](/modalities/voice/runtime/tool-calling)).

Here the agent is a single streaming (speech-to-speech) node with one custom
tool, `lookup_order`, plus the implicit call-control toolset every voice agent
gets for free:

```json Agent config (support-bot)
{
  "id": "support-bot",
  "version": 3,
  "entry": "converse",
  "nodes": {
    "converse": {
      "type": "REALTIME",
      "provider": "openai",
      "model": "gpt-4o-realtime-preview",
      "voice": "alloy",
      "system_prompt": "You are the support line for Acme. Greet the caller and ask how you can help. When they mention an order, call lookup_order with the order id and read back the status. If you cannot resolve the issue, or the caller asks for a person, call route_to_skill with skill_code \"SUPPORT\" and a one-line summary of what they need. Never invent an order status.",
      "tools": ["lookup_order"]
    }
  }
}
```

The `lookup_order` tool is an **HTTP tool**. You declare its name, its
description, and an OpenAPI-style parameter schema. You point it at an endpoint
that you host. The model fills the parameters. The runtime makes the request
and feeds the JSON response back into the turn.

```json Tool: lookup_order (http)
{
  "kind": "http",
  "name": "lookup_order",
  "description": "Look up an order by its id and return status and ETA.",
  "silent": false,
  "spec": {
    "method": "GET",
    "url": "https://orders.example.com/v1/orders/{{order_id}}",
    "headers": { "authorization": "Bearer ${ORDERS_API_TOKEN}" },
    "parameters": {
      "type": "object",
      "properties": {
        "order_id": { "type": "string", "description": "The caller's order id, digits only." }
      },
      "required": ["order_id"]
    }
  }
}
```

The endpoint behind it is ordinary code that you already know how to write.
Return a compact JSON object. The model reads it back to the caller:

  
```ts
// orders.example.com — the backend your lookup_order tool calls.
import { Hono } from "hono";

const app = new Hono();

app.get("/v1/orders/:id", async (c) => {
  const id = c.req.param("id");
  const order = await db.orders.findById(id);        // your system of record
  if (!order) return c.json({ found: false }, 404);
  return c.json({
    found: true,
    order_id: id,
    status: order.status,                            // "shipped" | "processing" | …
    eta: order.etaIso ?? null,
    carrier: order.carrier ?? null,
  });
});

export default app;
```
  
  
```python
# orders.example.com — the backend your lookup_order tool calls.
from fastapi import FastAPI, HTTPException

app = FastAPI()

@app.get("/v1/orders/{order_id}")
async def get_order(order_id: str):
    order = await db.orders.find_by_id(order_id)      # your system of record
    if order is None:
        raise HTTPException(status_code=404, detail={"found": False})
    return {
        "found": True,
        "order_id": order_id,
        "status": order.status,                       # "shipped" | "processing" | …
        "eta": order.eta_iso,
        "carrier": order.carrier,
    }
```
  

> **TIP:**
> Keep tool responses small and literal. The model narrates them out loud. A
> tight `{status, eta, carrier}` reads back far better than a full order
> document. Use MCP tools instead of raw HTTP when you already expose a Model
> Context Protocol server. See
> [Tool calling](/modalities/voice/runtime/tool-calling).

## 3. Escalate to a human — with context

You do **not** author the escalation tool. `route_to_skill` is part of the
implicit call-control toolset that every voice agent carries. A blind SIP
transfer hands the caller away. `route_to_skill` does not. It keeps the caller
on our leg. It pauses the AI's audio. It plays hold music. It drops the caller
into the ACD ring for a skill. When a human answers, the engine bridges the
caller.

The model calls it with two fields:

- **`skill_code`** (`string`, required) — The department to queue to, e.g. `SUPPORT`, `BILLING`, `HC_TRIAGE`. Match the   department the caller asked for.
- **`summary`** (`string`) — A one-line reason the caller needs a human. This is the **screen-pop** the   receiving agent sees before they take the call.

The runtime stamps on everything else that the human needs. You never pass the
sid or the caller's number by hand. When the model invokes the tool, the
runtime posts to the control-plane ACD picker:

```json POST https://engine.firstbyte.fast/api/acd/queue  (emitted by the runtime)
{
  "tenant_id":       "org_abc",
  "agent_config_id": "support-bot",
  "skill_code":      "SUPPORT",
  "summary":         "Order 88231 shows delivered but caller never received it",
  "caller_ani":      "+15551234567",
  "source_call_id":  "call_9f2c…"        // ← the preserved call_sid, injected for you
}
```

The picker selects the most-idle human with the `SUPPORT` skill. If no such
human is online, the picker queues the request. The picker returns the chosen
agent, so the model can say "connecting you to Ashwin now." The
`source_call_id` is the same `call_sid` that the AI leg used. The `summary`
becomes the human's screen-pop.

### Accepting the offer

When the offer rings on a human agent's desktop, the desktop confirms it with a
call to the accept endpoint. That call clears the offer marker, stops the ring,
and bridges the caller, on the **same** `call_sid`:

  
```ts
// Agent-desktop "Accept" button → clears the offer, bridges the caller.
async function acceptCall(callSid: string, agentJwt: string) {
  const res = await fetch("https://engine.firstbyte.fast/api/acd/accept", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "authorization": `Bearer ${agentJwt}`,   // the signed-in agent
    },
    body: JSON.stringify({ call_id: callSid }),
  });
  if (!res.ok) throw new Error(`accept failed: ${res.status}`);
  return res.json() as Promise<{ status: "accepted"; call_id: string }>;
}
```
  
  
```python
# Agent-desktop "Accept" button → clears the offer, bridges the caller.
import httpx

async def accept_call(call_sid: str, agent_jwt: str) -> dict:
    async with httpx.AsyncClient() as client:
        res = await client.post(
            "https://engine.firstbyte.fast/api/acd/accept",
            headers={"authorization": f"Bearer {agent_jwt}"},
            json={"call_id": call_sid},
        )
        res.raise_for_status()
        return res.json()   # {"status": "accepted", "call_id": "call_9f2c…"}
```
  

> **NOTE:**
> The context that survives the handoff today is the **one-line `summary`
> screen-pop**, plus the skill, the caller's number, and the preserved
> `call_sid`. A richer structured payload (full transcript, verified identity,
> collected fields rendered as a screen-pop card) is a product-level capability
> on the roadmap. The SDK does not ship it wired yet. Build the human-facing
> card off the `summary` + `source_call_id` for now.

Human agents can be browser softphones **or** real SIP desk/softphones. The
accept and bridge are the same either way. For the routing model (skills,
VDNs, picker algorithms), see
[PBX & ACD](/modalities/voice/transport-telephony/pbx-acd). For the mechanics
of the live audio handoff, see
[AI ↔ human handoff](/modalities/voice/transport-telephony/ai-human-handoff).

## 4. The sid is the throughline

`route_to_skill` keeps the caller on our leg. The `call_sid` is therefore
stable from the first ring through the human conversation. Your control-plane
code can observe or steer the same call at any point with the FirstByte
Voice SDK:

  
```ts
import { Voice } from "clutchcall_connect/voice";

const v = new Voice({
  baseUrl: "https://engine.firstbyte.fast",
  apiKey:  process.env.CLUTCHCALL_CREDENTIALS!,
  orgId:   "org_abc",
});

// Read the live call by the same sid the agent has been using.
const call = await v.calls.get({ sid: callSid });
console.log(call.status);            // "in_progress" through the handoff

// Alternative escalation: instead of letting the model queue to a skill,
// your app can transfer the live call itself — the sid is preserved either way.
await call.transfer({ agent: "senior-support" });   // re-attach to another agent
// …or forward to an on-call phone via SIP REFER:
await call.transfer({ to: "+15557654321" });
```
  
  
```python
import os
from clutchcall.voice import Voice

v = Voice(
    base_url="https://engine.firstbyte.fast",
    api_key=os.environ["CLUTCHCALL_CREDENTIALS"],
    org_id="org_abc",
)

# Read the live call by the same sid the agent has been using.
call = v.calls.get(sid=call_sid)
print(call.status)                    # "in_progress" through the handoff

# Alternative escalation: your app transfers the live call itself.
call.transfer(agent="senior-support")   # re-attach to another agent
# …or forward to an on-call phone via SIP REFER:
call.transfer(to="+15557654321")
```
  

There are two escalation styles here, and they compose:

- **Model-driven** (`route_to_skill`) — the agent decides, mid-conversation,
  that it needs a human. It queues to a skill with a summary. This style is
  best when the AI owns the judgment call.
- **App-driven** (`call.transfer`) — your backend decides. It re-attaches the
  call to a different agent, or it REFERs the call to a phone number. This
  style is best when an external signal (a supervisor, a business rule) forces
  the handoff.

Either way, the `call_sid` is unchanged. The recording (both legs), the
segmented call report (AI segment vs human segment), and the per-call QoS all
stay keyed to one call.

> **WARNING:**
> On a PSTN leg there is no browser echo-cancellation. Tune barge-in for the AI
> segment. See [Turn detection](/concepts/turn-detection). This matters only
> while the AI agent holds the line. After a human is bridged, turn detection is
> out of the path.

## Related

  - **[AI ↔ human handoff](/modalities/voice/transport-telephony/ai-human-handoff)** — How the live audio moves from the AI leg to the human.
  - **[Tool calling](/modalities/voice/runtime/tool-calling)** — HTTP, MCP, and client tools, plus the built-in telephony tools, in full.
  - **[PBX & ACD](/modalities/voice/transport-telephony/pbx-acd)** — Skills, VDNs, and the picker that selects the human.
  - **[Handoffs](/modalities/voice/concepts/handoffs)** — The concept model behind AI→human, transfer, and re-attach.
  - **[Transfer to a human](/modalities/voice/cookbooks/transfer-human)** — The one-call transfer snippet, on its own.
  - **[Outbound sales agent](/modalities/voice/recipes/outbound-sales)** — The outbound sibling of this recipe.
