Agents

ChatGateway

View as MarkdownOpen in Claude

A chat widget in a page cannot 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 server-side, and forwards on the widget’s behalf.

The browser learns two things, the gateway’s URL and a publishable key. Not the project, not the Space, not the token, and not which agent runs, because the gateway injects config_url itself and a key only ever reaches the one agent it was issued for.

1from signalwire.ai_chat import ChatGateway
2
3gateway = ChatGateway(
4 config_url="https://bayview-taxi.example.com/swml",
5 key="pk_your_publishable_key",
6 allowed_origins=["https://bayviewtaxi.example.com"],
7)
8
9app.include_router(gateway.router(), prefix="/chat")

It mounts on any FastAPI app. An agent already serves one, so this costs no new infrastructure: agent.get_app().include_router(gateway.router(), prefix="/chat").

Parameters

Keyword-only.

config_url
strRequired

The agent this key may talk to. Injected on every call and never accepted from the request, so whoever holds a key cannot choose which agent runs.

key
str | None

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

allowed_origins
list[str] | tuple[str, ...]Defaults to ()

Origins permitted to use this key. Localhost is always allowed so local development works unconfigured; anything else must be listed, so nothing ships open by accident.

client
AIChatClient | None

The AIChatClient to forward on. Built from the environment when omitted.

secret
bytes | str | None

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

handle_ttl
intDefaults to 86400

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

conversation_timeout
int | None

Idle seconds before the service ends a conversation, passed on every create. None leaves it to the service default of 3600. Set here rather than in the page, because the JSON-RPC result exposes neither the deadline nor the server’s clock, so a browser cannot discover it and two places holding the same number drift.

max_new_conversations
intDefaults to 60

New conversations allowed per window_seconds. The cap that bounds what a leaked key can cost you, since a leaked key opens many one-turn conversations rather than hammering one.

max_turns
intDefaults to 200

Turns a single conversation may run.

window_seconds
intDefaults to 60

The window for max_new_conversations.

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

effective_timeout
int

The idle timeout reported to clients. Never None, because it falls back to the service default, so a widget is never asked to warn about a deadline it was told nothing about.

key
str

The publishable key this gateway accepts, whether you passed one or it generated one.

Exceptions

GatewayRejection
Exception

A request the gateway refused, carrying .status and .reason. Reasons are deliberately coarse, because anything finer would let a caller map the caps and the allowlist by probing.

The browser-facing wire

One endpoint: POST {prefix}/ with the key in Authorization: Bearer. With prefix="/chat" the path is /chat/, since the router mounts POST /.

MethodBodyReturns
start{"method": "start", "handle"?}{greeting, status, timeout} plus an X-Chat-Handle header when a handle is minted
chat{"method": "chat", "handle", "message"}the service’s JSON-RPC envelope
log{"method": "log", "handle"}{messages, timeout, last_activity}
end{"method": "end", "handle"}{"status": "ended"}

A chat with no handle mints a conversation, so start is how you get a greeting before the visitor types rather than a required first step. Anything outside these four methods is a 400. If method is omitted, it defaults to chat. start can also reuse a valid existing handle.

Rejections carry an HTTP status and {"error": "<reason>"}: 400 malformed, 401 bad key, 403 bad or expired handle or a disallowed origin, 429 a cap was hit.

What a leaked key can do

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

A key alone cannot read a conversation. Without a handle there is no conversation to attach log to, and handles are HMAC-signed so they cannot be forged, guessed, or enumerated. A caller holding a valid handle can use log to read that conversation’s filtered user and assistant transcript, but not its system prompt or tool traffic.

It can start conversations and send messages. Use max_new_conversations and max_turns to bound that usage. The first limits newly created conversations per rolling window; the second limits turns in each conversation.

Counters live in the serving process. Behind several replicas each keeps its own, so the effective cap multiplies by 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 their origin and the gateway refuses it. It does not stop curl, which simply omits the header. Treat it as leak containment, not access control. The same list drives CORS, including Access-Control-Expose-Headers: X-Chat-Handle and a preflight response.

Rotating a key

There is no expiry on the key itself, because publishable keys live in static pages and a TTL means the widget dies silently at some point. Rotate on demand instead: construct the gateway with a new key and redeploy. Change secret at the same time to invalidate outstanding handles too.

Methods

Example

1from fastapi import FastAPI
2from signalwire.ai_chat import ChatGateway
3
4app = FastAPI()
5
6gateway = ChatGateway(
7 config_url="https://bayview-taxi.example.com/swml", # never leaves the server
8 key="pk_your_publishable_key", # safe in the page
9 allowed_origins=["https://bayviewtaxi.example.com"],
10 secret="a-stable-secret-across-replicas",
11 conversation_timeout=1800,
12 max_new_conversations=60,
13 max_turns=200,
14)
15
16app.include_router(gateway.router(), prefix="/chat")