> Fetch clean Markdown by appending `.md` to any page URL under https://signalwire.com/docs or requesting it with the HTTP header `Accept: text/markdown`. The root index at https://signalwire.com/docs/llms.txt lists the available documentation indexes.

# HandoffRouter

> The three routes a browser chat widget calls to move a conversation between text and a phone call.

[chatgateway]: /docs/server-sdks/reference/python/agents/chat-gateway

[mount]: /docs/server-sdks/reference/python/agents/agent-base/mount

[capabilities]: /docs/server-sdks/reference/python/core/capabilities

[register]: /docs/server-sdks/reference/python/agents/handoff-router/register

[redeem]: /docs/server-sdks/reference/python/agents/handoff-router/redeem

[escalate]: /docs/server-sdks/reference/python/agents/handoff-router/escalate

[say]: /docs/server-sdks/reference/python/agents/handoff-router/say

[router]: /docs/server-sdks/reference/python/agents/handoff-router/router

[read-handle]: /docs/server-sdks/reference/python/agents/chat-gateway/read-handle

[`ChatGateway`][chatgateway] lets a browser hold a text conversation. `HandoffRouter` is the other
half the SignalWire address widget expects: three routes, served at the same URL prefix as the
gateway, that move that conversation to a phone call and back, and let a visitor type into a live
call.

| Route                    | Body                | Effect                                                                   |
| ------------------------ | ------------------- | ------------------------------------------------------------------------ |
| `POST {prefix}/handoff`  | `{"nonce"}`         | End the call, wait for its record, return a chat handle for the next leg |
| `POST {prefix}/escalate` | `{"handle"}`        | End the chat leg and wait for its record before a call is placed         |
| `POST {prefix}/say`      | `{"nonce", "text"}` | Deliver typed text into the live call                                    |

Every route checks the request origin against the gateway's allowlist. Unknown, expired, and
already-redeemed nonces all answer `404`, so a caller can't probe whether a given call is live.

```python
from signalwire.ai_chat import ChatGateway, HandoffRouter

gateway = ChatGateway(config_url="https://bayview-taxi.example.com/dispatch")
handoff = HandoffRouter(gateway=gateway, capture_leg=save_leg, end_call=hang_up)

agent.mount(gateway.router(), prefix="/chat")
agent.mount(handoff.router(), prefix="/chat")
```

## How the nonce works

A browser can't be trusted to name a call, since a page-supplied call ID would let anyone inject
speech into a stranger's call. Instead the browser proves which call it is on. Your application puts
a random `handoff_nonce` in the user variables of one dial, [registers][register] it against that
call's IDs from the dynamic-config callback, and the browser presents it later. The same dial
carries the `chat_handle`, so the callback recovers the conversation ID with
[`read_handle()`][read-handle].
Redemption for a handle is single use. Typing is repeatable for the life of the call, bounded by
`max_messages_per_call`.

A new medium never starts until the one it replaces has finished and your `capture_leg` callback has
confirmed its record is durable. Without that wait the new leg's config fetch races a record that is
still being written and opens knowing nothing.

#### The nonce registry is per process

Like the gateway's rate-limit counters, the registry lives in the serving process. A redemption must
reach the replica that served the dial. Run one replica, use sticky routing, or pass a shared
`registry`.

## Parameters

Keyword-only.

**`gateway`** `ChatGateway` — required

The gateway that owns the conversations. Used to issue handles and to check origins, so both
halves of the URL enforce the same origin policy.

---

**`capture_leg`** `Callable[[str, str], bool | Awaitable[bool]] | None` — default: None

Called as `capture_leg(conversation_id, medium)` to end a leg and write its record. Return a truthy
value only once that record is durable. Sync or async. When omitted, no wait happens and the
ordering guarantee isn't provided.

---

**`end_call`** `Callable[[str], None | Awaitable[None]] | None` — default: None

Called as `end_call(call_id)` to hang the call up server-side so its teardown hooks fire
immediately.

---

**`send_message`** `Callable[[str, str], bool | Awaitable[bool]] | None` — default: None

Called as `send_message(call_id, text)` for `/say`. Omit to leave typing disabled; the route then
answers `404`.

---

**`next_conversation_id`** `Callable[[str], str] | None` — default: None

Called as `next_conversation_id(conversation_id)` to produce the ID for the new leg. An ended
conversation can't be reopened, so a fresh ID is required. Defaults to appending `.1`, or
incrementing an existing `.N` suffix.

---

**`nonce_ttl`** `int` — default: 3600

Seconds a nonce stays redeemable.

---

**`max_messages_per_call`** `int` — default: 200

Ceiling on typed messages for one call. Each is a billable turn, so this is a spend guard as much
as an abuse guard.

---

**`capture_timeout`** `float` — default: 8.0

Seconds to wait for `capture_leg`. A ceiling, not a budget; capture is normally sub-second. On
timeout the next medium starts without this leg's record and a warning is logged.

---

**`registry`** `dict[str, NonceEntry] | None` — default: None

Shared mapping for the nonce table. Supply one backed by shared storage to run more than one
replica.

---

## Properties

**`NonceEntry`** `dataclass`

What a nonce is a capability for. Importable from `signalwire.ai_chat`.

---

**`NonceEntry.conversation_id`** `str`

The conversation the nonce belongs to.

---

**`NonceEntry.call_id`** `str | None`

The call it was registered against.

---

**`NonceEntry.issued_at`** `float`

Monotonic timestamp used for expiry.

---

**`NonceEntry.messages`** `int`

Typed messages delivered so far.

---

## Methods

#### [register](/docs/server-sdks/reference/python/agents/handoff-router/register)

Record what a nonce is a capability for.

#### [redeem](/docs/server-sdks/reference/python/agents/handoff-router/redeem)

Exchange a nonce for a chat handle. Single use.

#### [escalate](/docs/server-sdks/reference/python/agents/handoff-router/escalate)

End a chat leg and wait for its record.

#### [say](/docs/server-sdks/reference/python/agents/handoff-router/say)

Deliver typed text into a live call.

#### [router](/docs/server-sdks/reference/python/agents/handoff-router/router)

The APIRouter to mount beside the gateway's.

## Example

Register the nonce from the dynamic-config callback of the dial that carried it, reading the call ID
from the platform's request rather than from anything the browser sent. The
[`capabilities`][capabilities] helpers read the same user variables.

```python {20-27,30-31}
from signalwire import AgentBase
from signalwire.ai_chat import ChatGateway, HandoffRouter
from signalwire.core.capabilities import user_variables

agent = AgentBase(name="dispatch", route="/dispatch")
agent.set_prompt_text("You are Ada, the dispatcher for Bayview Taxi.")

gateway = ChatGateway(
    config_url="https://bayview-taxi.example.com/dispatch",
    key="pk_your_publishable_key",
    allowed_origins=["https://bayviewtaxi.example.com"],
)
handoff = HandoffRouter(
    gateway=gateway,
    capture_leg=save_leg,      # your function: write the leg's transcript, return True when durable
    end_call=hang_up,          # your function: end the call server-side
    send_message=inject_text,  # your function: speak typed text into the call
)

def remember_nonce(query_params, body_params, headers, ephemeral_agent):
    variables = user_variables(body_params)
    nonce = variables.get("handoff_nonce")
    chat_handle = variables.get("chat_handle")
    if nonce and chat_handle:
        conversation_id = gateway.read_handle(chat_handle)
        call_id = body_params.get("call", {}).get("call_id")
        handoff.register(nonce, conversation_id=conversation_id, call_id=call_id)

agent.add_per_call_config(remember_nonce)
agent.mount(gateway.router(), prefix="/chat")
agent.mount(handoff.router(), prefix="/chat")
agent.serve()
```