Agents

HandoffRouter

View as MarkdownOpen in Claude

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.

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

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

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]] | NoneDefaults to 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]] | NoneDefaults to 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]] | NoneDefaults to 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] | NoneDefaults to 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
intDefaults to 3600

Seconds a nonce stays redeemable.

max_messages_per_call
intDefaults to 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
floatDefaults to 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] | NoneDefaults to 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

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 helpers read the same user variables.

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