Skip to navigation

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. Without these routes, a gateway answers the widget’s chat requests and returns 404 for the rest.

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.

This class implements the wire contract: the routes, the nonce, the ordering guarantee, and the spend limits. What a conversation is belongs to your application, and arrives as callbacks: where a leg’s transcript is written, and how a call is ended or spoken into.

npm install @signalwire/sdk

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 from a per-call config callback (see addPerCallConfig()), and the browser presents it later. The same dial carries the chat_handle, so the callback recovers the conversation id with readHandle().

Exchanging a nonce for a chat handle works once. Typing is repeatable, up to maxMessagesPerCall, until /handoff uses the nonce or nonceTtl seconds pass from its registration, even if the call is still live.

The ordering guarantee

A new medium doesn’t start until the one it replaces has ended and its record is written. /handoff ends the call and waits for your captureLeg callback to confirm the capture before it issues a handle. /escalate waits for the chat leg’s capture before it returns. Without the wait, the new medium’s config fetch can run before the record exists, and the new leg starts with no history.

The nonce registry is per process

The registry lives in the serving process, like the gateway’s caps. A redemption must reach the replica that served the dial: run one replica, use sticky routing, or pass a shared registry. Registering, redeeming, and taking a typing slot each read the table and write it back, so across replicas that share a registry a nonce can be redeemed once by each, and the typing cap can be passed. Use one nonce table, or route each call’s requests to one replica.

Parameters

The constructor takes one options object, HandoffRouterOptions.

gateway
ChatGatewayRequired

The gateway that owns the conversations. Used to issue and read handles and to check origins, so all routes on the URL apply the same origin policy.

captureLeg
CaptureLeg

Ends a leg and writes its record: captureLeg(conversationId, medium), where medium is voice or chat. Return true only once the record is written. Sync or async. When omitted, nothing waits and the ordering guarantee doesn’t hold.

endCall
EndCall

Hangs a call up server-side so its teardown hooks run now: endCall(callId).

sendMessage
SendMessage

Delivers typed text into a live call as if the caller said it: sendMessage(callId, text). When omitted, typing is off and /say returns 404.

nextConversationId
(conversationId: string) => string

Returns the id for the new leg, from the current one. An ended conversation can’t be reopened, so each leg needs a new id. Defaults to appending .1, or incrementing an existing .N suffix: root becomes root.1, and root.2 becomes root.3. Keep . as the separator.

nonceTtl
numberDefaults to 3600

Seconds a nonce stays usable for /handoff and /say, from registration.

maxMessagesPerCall
numberDefaults to 200

Typed messages allowed per call. Each is a billed turn, so this limits spending as well as abuse.

captureTimeout
numberDefaults to 8

Seconds to wait for captureLeg. An upper limit; a capture normally takes under a second. On timeout the next medium starts without this leg’s record, and a warning is logged.

registry
Map<string, NonceEntry>

The nonce table. Supply one backed by shared storage to run more than one replica. It may return copies of its entries, because the router writes every change back with set().

Properties

gateway, captureLeg, endCall, sendMessage, nextConversationId, nonceTtl, maxMessagesPerCall, and captureTimeout are readable and writable on the instance. captureLeg, endCall, and sendMessage are null when not set.

Exports

All of these are importable from @signalwire/sdk.

NonceEntry
class

What a registered nonce gives access to.

NonceEntry.conversationId
string

The conversation the call belongs to.

NonceEntry.callId
string | null

The platform call id, from the request the platform sent.

NonceEntry.issuedAt
number

When it was registered, in monotonic seconds, used for expiry.

NonceEntry.messages
number

Typed messages delivered so far.

NonceEntry.redeemed
boolean

true once /handoff has used the nonce. The entry stays in the table until it would have expired, so registering the nonce again can’t make it usable again.

CaptureLeg
(conversationId: string, medium: string) => boolean | Promise<boolean>

The type of captureLeg.

EndCall
(callId: string) => void | Promise<void>

The type of endCall.

SendMessage
(callId: string, text: string) => boolean | Promise<boolean>

The type of sendMessage.

HandoffRouterOptions
interface

The constructor’s options object, described under Parameters.

MAX_REQUEST_BODY_BYTES (64 KiB) and MAX_MESSAGE_BYTES (8 KiB of UTF-8) apply to these routes as they do to the gateway. A body over the first, or /say text over the second, is answered with 413. Both are checked before the nonce is looked up, so the answer says nothing about whether the nonce is live.

Methods

Examples

Register the nonce from the per-call config callback of the dial that carried it, reading the call id from the platform’s request and never from anything the browser sent.

import { AgentBase, ChatGateway, HandoffRouter, userVariables } from '@signalwire/sdk';
const agent = new AgentBase({ name: 'dispatch', route: '/dispatch' });
agent.setPromptText('You are Ada, the dispatcher for Bayview Taxi.');
const gateway = new ChatGateway({
configUrl: 'https://bayview-taxi.example.com/dispatch',
key: '<YOUR_PUBLISHABLE_KEY>',
allowedOrigins: ['https://bayviewtaxi.example.com'],
});
const handoff = new HandoffRouter({
gateway,
// Write the leg's transcript, then return true once it is saved.
captureLeg: async (conversationId, medium) => {
console.log(`saving ${medium} leg of ${conversationId}`);
return true;
},
// End the call server-side.
endCall: async (callId) => {
console.log(`hanging up ${callId}`);
},
// Speak typed text into the call.
sendMessage: async (callId, text) => {
console.log(`sending to ${callId}: ${text}`);
return true;
},
});
agent.addPerCallConfig((_query, body) => {
const variables = userVariables(body);
const nonce = variables['handoff_nonce'];
const chatHandle = variables['chat_handle'];
const call = body['call'] as { call_id?: string } | undefined;
if (typeof nonce === 'string' && typeof chatHandle === 'string') {
handoff.register(nonce, {
conversationId: gateway.readHandle(chatHandle),
callId: call?.call_id ?? null,
});
}
});
agent.mount(gateway.router(), { prefix: '/chat' });
agent.mount(handoff.router(), { prefix: '/chat' });
await agent.serve();

A captureLeg, endCall, or sendMessage that throws is logged and treated as a failure. A sendMessage that throws or returns false doesn’t count against maxMessagesPerCall.

The option types and a shared registry:

import { HandoffRouter, NonceEntry, ChatGateway } from '@signalwire/sdk';
import type { CaptureLeg, EndCall, SendMessage, HandoffRouterOptions } from '@signalwire/sdk';
const gateway = new ChatGateway({ configUrl: 'https://bayview-taxi.example.com/dispatch' });
const captureLeg: CaptureLeg = async (conversationId, medium) => {
console.log(conversationId, medium);
return true;
};
const endCall: EndCall = (callId) => {
console.log(callId);
};
const sendMessage: SendMessage = (callId, text) => {
console.log(callId, text);
return true;
};
// A shared nonce table: any Map<string, NonceEntry> works.
const registry = new Map<string, NonceEntry>();
const options: HandoffRouterOptions = {
gateway,
captureLeg,
endCall,
sendMessage,
nextConversationId: (id) => `${id}.next`,
nonceTtl: 1800,
maxMessagesPerCall: 100,
captureTimeout: 5,
registry,
};
const handoff = new HandoffRouter(options);
console.log(handoff.nonceTtl, handoff.maxMessagesPerCall, handoff.captureTimeout, new NonceEntry('chat-8f21', 'call-1'));