In-app assistant
Send messages, manage sessions, and handle live-agent handoffs — over WebSocket or HTTP.
travel.tech's in-app assistant is a chat surface where a traveler can search,
get trip help, and escalate to a live agent. It is reached two ways: a
WebSocket gateway at /v1/ws for real-time streaming, and an HTTP
surface under /v1/assistant/* as a fallback for clients that cannot hold
a WebSocket connection. Neither surface appears in the
OpenAPI reference — /v1/ws is not an HTTP
route at all, and the HTTP fallback routes are intentionally thin wrappers
around the same session/orchestrator logic the WebSocket gateway uses. This
guide is the only place both are documented.
Authentication
Both surfaces use the same traveler bearer JWT as
every other /v1 endpoint. The WebSocket gateway carries it differently,
since a WS handshake has no per-message header:
- The client opens the connection with no credential in the URL or
handshake. - The first frame the client sends must be an
authevent carrying
the JWT. The server waits up to 5 seconds for it. - The server verifies the token and closes the connection if it is
missing, malformed, or the first frame is anything other thanauth.
The HTTP fallback routes take the JWT the normal way, as
Authorization: Bearer <token>. Consumer HMAC signing
(Authentication § Consumer HMAC) applies to the
HTTP routes as usual; the WebSocket handshake itself is not HMAC-signed,
only the auth frame's JWT is checked.
WebSocket protocol (/v1/ws)
/v1/ws)Connecting and authenticating
const ws = new WebSocket("wss://api.travel.tech/v1/ws");
ws.addEventListener("open", () => {
ws.send(JSON.stringify({ event: "auth", data: { token: travelerJwt } }));
});
ws.addEventListener("message", (raw) => {
const { event, data } = JSON.parse(raw.data);
if (event === "auth.ok") {
ws.send(JSON.stringify({ event: "session.start", data: {} }));
}
// ... dispatch on `event` below
});A failed authentication closes the socket with one of two codes:
| Close code | Meaning |
|---|---|
4001 | Credential rejected — missing, malformed, expired, or the first frame was not auth. Re-authenticate; do not retry with the same token. |
1013 | Verification was temporarily impossible (identity provider unreachable). Safe to retry after a short backoff — the WS analog of an HTTP 503. |
Client → server events
| Event | Payload | Purpose |
|---|---|---|
session.start | {} | Get-or-create the traveler's open session. Repeated calls return the same session id; a new one is only minted once the previous session is closed. |
message.send | { sessionId, content, idempotencyKey?, handoffId?, timezone? } | Send a user message. The orchestrator replies with streamed deltas and a final message.new. |
message.history.load | { sessionId } | Load a page of message history for a session (for reconnect/resync). |
handoff.request | { sessionId } | Ask to escalate the session to a live human agent. |
handoff.callback | { sessionId, handoffId? } | Ask the live agent to phone the traveler back. Falls back to the session's active handoff when omitted. |
ping is also accepted at any time and answered with pong.
Server → client events
| Event | Payload | Purpose |
|---|---|---|
auth.ok | {} | Authentication succeeded; safe to send further events. |
session.created | { sessionId } | Reply to session.start. |
message.stream.* | { sessionId, ...delta } | Additive, best-effort incremental turn content (e.g. message.stream.token, message.stream.tool_call) while the assistant composes its reply. Not guaranteed delivery — treat as a progressive-rendering hint, and always reconcile against the final message.new. |
message.new | A full Message object (see below) | The finished, persisted message — either the echoed user message or the assistant's reply. Carries turnId, correlating it with the message.stream.* frames from the same turn. |
history.loaded | { messages[], stateVersion, nextCursor } | Reply to message.history.load. |
handoff.initiated | A Handoff object | A requestHandoff tool call escalated this turn to a live agent. |
handoff.callback.acknowledged | — | The callback request reached the live agent. |
handoff.message | A Message object | A live agent's reply, relayed from the support desk once a handoff is active. |
message.error | { code, message, idempotencyKey?, turnId? } | A message.send turn failed; see error codes below. |
error | { code, message } | A connection-level error (bad frame, unknown event, rate limit). |
Worked example: sending a message
// client -> server
{"event": "message.send", "data": {"sessionId": "sess_123", "content": "What's my next trip?"}}// server -> client (zero or more, while the reply is composed)
{"event": "message.stream.token", "data": {"sessionId": "sess_123", "delta": "Your next trip is..."}}// server -> client (exactly one, the finished turn)
{
"event": "message.new",
"data": {
"id": "msg_456",
"sessionId": "sess_123",
"role": "ASSISTANT",
"content": "Your next trip is Milan → London, departing May 25.",
"richCards": [
{
"type": "trip_ref",
"data": {
"tripId": "018f2c3e-1234-7abc-8def-000000000001",
"name": "Milan → London",
"status": "upcoming",
"temporalClassification": "upcoming",
"startsAt": "2026-05-25T08:00:00Z",
"endsAt": "2026-05-25T10:00:00Z",
"destinations": ["London"],
"stageCount": 1,
"bookingCount": 1
}
}
],
"toolCalls": [],
"searchContext": [],
"attachments": [],
"createdAt": "2026-05-25T10:30:00Z",
"turnId": "turn_789"
}
}richCards is a discriminated union — flight_option, car_option,
stay_option, stay_fare, train_option, alert, trip_ref,
booking_ref, travel_document, or booking_confirmation — each carrying
whichever domain payload matches its type. A stay_fare card is one rate
of one property: the rate fields of POST /v1/stays/fares plus the
property's propertyId, searchSolutionId, checkIn and checkOut. A
booking_confirmation card is a booking waiting on the traveler: it carries
taskId, the Confirm and Cancel options, a summary of what would be
booked, and expiresAt when a deadline is set. Render each card type
distinctly; do not assume every message carries the same card shape.
WebSocket error codes
Sent on error (connection-level) or message.error (turn-level) frames:
| Code | Meaning |
|---|---|
MISSING_SESSION_ID | The event needed a sessionId and none was supplied. |
SESSION_NOT_FOUND | The referenced session does not exist or is not owned by this traveler. |
SESSION_ERROR | The session cannot accept this action right now. |
SESSION_BUSY | Another request is already in flight for this session; retry shortly. |
HANDOFF_RESOLVED | The live-agent thread has already ended. |
SERVICE_UNAVAILABLE | The assistant backend or identity verification is temporarily down. Safe to retry. |
LLM_ERROR | The orchestrator's model call failed. |
TURN_PREEMPTED | A newer message on the same session superseded this turn; the newer turn carries the real reply. |
INTERNAL_ERROR | Unexpected server-side failure. |
UNKNOWN_EVENT | The event field did not match any known event type. |
PARSE_ERROR | The frame was not valid JSON. |
EVENT_ERROR | The event was recognised but its payload was invalid. |
RATE_LIMIT_EXCEEDED | Per-connection frame rate limit exceeded. |
HTTP fallback (/v1/assistant/*)
/v1/assistant/*)Use this surface only when a client genuinely cannot hold a WebSocket
connection. It reuses the same session and orchestrator state as /v1/ws,
so a session started over one transport is fully visible and continuable
over the other.
| Method & path | Purpose |
|---|---|
POST /v1/assistant/sessions | Get-or-create the traveler's open session (mirrors session.start). Returns 201 in both cases — id-stability is the contract, not the status code. |
GET /v1/assistant/sessions | List the traveler's open sessions, paginated. |
GET /v1/assistant/sessions/{sessionId}/messages | Paginated message history for one session, newest page first. |
PATCH /v1/assistant/sessions/close | Close a session. |
POST /v1/assistant/messages | Send a message and receive the assistant's reply synchronously (mirrors message.send, without the intermediate message.stream.* deltas). |
POST /v1/assistant/handoffs | Request, accept, resolve, or cancel-via-callback a live-agent handoff — action is one of request, accept, resolve, callback. |
GET /v1/assistant/sessions/{sessionId}/handoffs | List handoffs for a session. |
curl -X POST "$TRAVELTECH_BASE_URL/v1/assistant/messages" \
-H "Authorization: Bearer $TRAVELER_JWT" \
-H "TravelTech-Consumer: $CONSUMER" \
-H "TravelTech-Timestamp: $TIMESTAMP" \
-H "TravelTech-Signature: $SIGNATURE" \
-H "Content-Type: application/json" \
-d '{"sessionId": "sess_123", "content": "Show me my next trip"}'The response body is the same Message shape as the WebSocket message.new
frame's data.
| Status | Condition |
|---|---|
401 | Credential rejected, or an identity mismatch was detected mid-turn. |
404 | Session or handoff not found. |
409 | Conflict — superseded by a newer message, or the session closed mid-turn. |
422 | Request body failed validation (e.g. an invalid timezone). |
429 | Rate limit exceeded for this user. |
500 | An internal error occurred processing the message. |
503 | Orchestrator or identity verification unavailable. |
Requesting a live-agent handoff
Either surface can escalate a session to a human agent. Over WebSocket,
send handoff.request; over HTTP, POST /v1/assistant/handoffs with
action: "request". Once a handoff is active, the live agent's replies
arrive as handoff.message (WebSocket) or are visible through
GET /v1/assistant/sessions/{sessionId}/handoffs (HTTP). Use
action: "callback" to ask the agent to phone the traveler instead of
continuing in text.
Reporting issues
This page is maintained by the BizAway integration team. To request changes
or report an error, contact your BizAway integration contact. Edits made
directly in the ReadMe dashboard are overwritten on the next publish.
Updated 1 day ago