Skip to navigation

ChatGateway

View as MarkdownOpen in Claude

A chat widget in a page can’t hold a SignalWire API token: the token carries the whole project, and every visitor could read it. ChatGateway mounts inside a web application you already run, holds the credentials on the server, and forwards on the widget’s behalf.

The browser learns two things, the gateway’s URL and a publishable key. It doesn’t learn the project, the Space, the token, or which agent runs, because the gateway sends config_url itself and a key only reaches the one agent it was issued for.

import { AgentBase, ChatGateway } 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', // never leaves the server
key: '<YOUR_PUBLISHABLE_KEY>', // safe in the page
allowedOrigins: ['https://bayviewtaxi.example.com'],
secret: process.env.SIGNALWIRE_CHAT_GATEWAY_SECRET,
conversationTimeout: 1800,
maxNewConversations: 60,
maxTurns: 200,
});
agent.mount(gateway.router(), { prefix: '/chat' });
await agent.serve();
npm install @signalwire/sdk

An agent already serves a Hono app, so agent.mount() hosts the gateway with no new infrastructure. router() returns a standard Hono app, so app.route() works on any other Hono server.

The SignalWire address widget also calls /handoff, /escalate, and /say at the same URL to move a conversation between chat and a phone call. HandoffRouter serves those three routes; mount it at the same prefix as the gateway.

Parameters

The constructor takes one options object, ChatGatewayOptions. It throws an Error when configUrl is empty, and, when client is omitted, when SIGNALWIRE_PROJECT_ID isn’t set or no service URL resolves from SIGNALWIRE_SPACE.

configUrl
stringRequired

The agent this key may talk to. Sent on every call and never taken from the request, so whoever holds a key can’t choose which agent runs.

key
string

The publishable key the widget carries. Falls back to SIGNALWIRE_CHAT_GATEWAY_KEY, then to a generated pk_ value, which is useful only to a process that also serves the page and can embed it.

allowedOrigins
readonly string[]

Origins allowed to use this key, stored without trailing slashes. Localhost is always allowed so local development works unconfigured; any other origin must be listed.

client
AIChatClient

The AIChatClient to forward with. Built from the environment when omitted (SIGNALWIRE_PROJECT_ID, SIGNALWIRE_API_TOKEN, and SIGNALWIRE_SPACE, which the examples on these pages assume are set), and then closed by close(). A client you pass in is yours to close.

secret
string | Uint8Array

HMAC key for signing handles. Falls back to SIGNALWIRE_CHAT_GATEWAY_SECRET, then to a random per-process value.

handleTtl
numberDefaults to 86400

Seconds a handle stays valid: long enough to outlive a page refresh, short enough that a session left open overnight expires.

conversationTimeout
number | null

Idle seconds before the service ends a conversation, sent on every create. Omit it for the service default of 3600. Set it here rather than in the page: the service’s responses don’t expose the deadline, so the gateway reports it to the widget on start and log.

maxNewConversations
numberDefaults to 60

New conversations allowed per windowSeconds. The cap that matters most: a leaked key is used to open many one-turn conversations, each billed for its opening turn.

maxTurns
numberDefaults to 200

Turns one conversation may run, in total.

windowSeconds
numberDefaults to 60

The window maxNewConversations counts over, in seconds.

Set secret explicitly if you run more than one replica or restart often. A random per-process secret invalidates outstanding handles on restart, and a handle signed by one replica is refused by another.

Properties

configUrl
string

The agent config sent on every call. Read-only.

key
string

The publishable key the browser presents, whether you passed one or the gateway generated it. Read-only.

allowedOrigins
ReadonlySet<string>

The listed origins, without trailing slashes. Read-only.

handleTtl
number

Seconds a handle stays valid.

conversationTimeout
number | null

Idle seconds before the service ends a conversation, or null for the service default.

maxNewConversations
number

New conversations allowed per window.

maxTurns
number

Turns allowed per conversation.

windowSeconds
number

Window for maxNewConversations, in seconds.

effectiveTimeout
number

The idle timeout reported to the widget. Never null: it falls back to the service default of 3600, so the widget is never asked to warn about a deadline it wasn’t told. Read-only.

Exports

All of these are importable from @signalwire/sdk.

GatewayRejection
class extends Error

A request the gateway refused. Carries status, the HTTP status to return (400, 401, 403, 413 or 429), and reason, a short explanation that is safe to show the browser. Reasons name a category and nothing finer, because anything finer would let a caller map the caps and the allowlist by probing.

ChatGatewayOptions
interface

The constructor’s options object, described under Parameters.

MAX_REQUEST_BODY_BYTES
number

65536 (64 KiB). The largest request body the router accepts. A larger body is answered with 413 before it is parsed.

MAX_MESSAGE_BYTES
number

8192 (8 KiB of UTF-8). The largest chat message, refused with 413 before a conversation is created or a turn is counted. HandoffRouter applies the same limit to the text of /say.

MAX_USER_METADATA_BYTES
number

8192 (8 KiB). The largest user_meta_data object, measured serialized, refused with 413.

The browser-facing wire

One endpoint: POST / on the router, with the key in Authorization: Bearer. Mounted at /chat, the path is /chat/. A preflight OPTIONS / is answered for allowed origins. The body is JSON with a method of start, chat, log, or end; method defaults to chat.

MethodBody fieldsReturns
starthandle?, user_meta_data?{ greeting, status, timeout }
chathandle?, message, user_meta_data?The service’s JSON-RPC response, streamed through
loghandle{ messages, timeout, last_activity }
endhandle{ status: 'ended' }

A start or chat with no handle creates a conversation and returns its handle in the X-Chat-Handle response header. start opens the conversation with no user message, so the agent speaks first. log and end require a handle. Any other method is a 400.

Rejections return { error: reason } with the status: 400 malformed request or handle, 401 bad key, 403 origin not allowed or a bad or expired handle, 413 a request, message, or metadata over its limit, 429 a cap was reached. A failure reaching the chat service before any reply is sent returns 502 with { error: 'chat service error' }.

Page context

user_meta_data is the one field forwarded rather than replaced: the page context a widget collects about itself, such as URL, title, referrer, locale, and viewport. It reaches the agent’s config request as params.user_meta_data, so a chat agent can tailor its greeting.

The service reads it when it creates a conversation, which is the start call or whichever chat auto-creates one. Metadata sent on later turns is accepted and unused. The browser writes it, so treat it as the visitor’s claim about themselves, never as authority; a page can put anything in it, including text aimed at your prompt.

What a leaked key can do

A publishable key is public by definition, so design for it being taken.

A key alone can’t read a conversation. The full chat_log isn’t exposed, and handles are signed by the gateway, so they can’t be forged, guessed, or enumerated. log returns only the user and assistant turns.

It can start and continue conversations, and every turn is billed. maxNewConversations and maxTurns bound that: the first limits conversations created per window, the second limits turns in each. The caps are counted in the serving process. Behind several replicas each keeps its own, so the effective cap multiplies by the replica count; set them with that in mind, or put a shared limiter in front.

The origin allowlist stops a key pasted into someone else’s page, because a browser sends that page’s origin and the gateway refuses it. It doesn’t stop a script that sends no origin or a false one. Treat it as leak containment, not access control.

To rotate a key, construct the gateway with a new key and redeploy. Change secret at the same time to invalidate outstanding handles too.

Methods

checkKey

Verifies the publishable key a request presented, comparing in constant time. router() and prepare() call it for you.

presented
string | null | undefinedRequired

The key from the request, or null or undefined when absent.

Returns void. Throws GatewayRejection with status 401 and reason bad key when the key is missing or wrong.

checkOrigin

Allows localhost and listed origins, and refuses any other. A missing Origin is allowed: browsers send one on the cross-origin requests this serves, so its absence means a caller that isn’t a browser, and refusing it would break server-side use without stopping an attacker.

origin
string | null | undefinedRequired

The request’s Origin header, or null or undefined when absent.

Returns void. Throws GatewayRejection with status 403 and reason origin not allowed for an origin that isn’t allowed.

readUserMetadata

Validates the user_meta_data field of a browser request. Absent, null, and empty all yield null.

body
Record<string, unknown>Required

The browser’s request body.

Returns Record<string, unknown> | null. Throws GatewayRejection with status 400 when the field isn’t a JSON object or can’t be serialized, and 413 when it is larger than MAX_USER_METADATA_BYTES serialized.

Example

Checking a request’s key and origin by hand, and reading its page context, in a handler you wrote yourself:

import { ChatGateway, GatewayRejection } from '@signalwire/sdk';
const gateway = new ChatGateway({
configUrl: 'https://bayview-taxi.example.com/dispatch',
key: '<YOUR_PUBLISHABLE_KEY>',
allowedOrigins: ['https://bayviewtaxi.example.com'],
});
export function guard(origin: string | undefined, authorization: string | undefined): void {
const key = authorization?.startsWith('Bearer ') ? authorization.slice(7) : null;
try {
gateway.checkKey(key);
gateway.checkOrigin(origin);
} catch (err) {
if (err instanceof GatewayRejection) {
console.warn(err.status, err.reason); // 401 bad key, 403 origin not allowed
}
throw err;
}
}
export function pageContext(body: Record<string, unknown>) {
return gateway.readUserMetadata(body); // Record<string, unknown> | null
}