TravelBot SDK

A lightweight, dependency-free JavaScript embed that gives any partner site a chat + checkout widget in one script tag.

Overview

sdk.js is a single vanilla JS file a partner drops into their page. It injects two isolated iframes — a persistent chat widget and an on-demand checkout modal, each built in React — and brokers all communication between them with window.postMessage. The partner never writes iframe markup, manages z-index, or touches DOM overlay logic; the SDK owns all of it.

Deployment model — what ships where

Two different audiences receive two completely different things. Mixing them up is the most common point of confusion in this repo.

What a partner (e.g. Upgrade) receives

Only the <script src="sdk.js"> tag and the four-line init() call from the Quick Start below. Nothing else — no source code, no React, no build step on their end.

What Odynn/the agency must host

The chat/ and checkout/ React apps, built and deployed to a real public domain (e.g. widget.odynn.com) — partners never see or receive this code. sdk.js itself also needs to be hosted somewhere (ideally a CDN) so partners can point a <script src> at it.

Source vs. build output — read this before editing

Each of chat/ and checkout/ contains two different index.html files — easy to confuse:

FileWhat it is
chat/index.htmlVite's source template — just a <div id="root"> and a script tag. Not the real app.
chat/src/*.jsxThe actual React app — this is what you edit.
chat/dist/index.htmlCompiled output of npm run build — the real, working, servable app. This is what sdk.js's CHAT_URL points to.
Editing chat/src/App.jsx does nothing visible until you run npm run build inside chat/ — the static server serves dist/, not src/.

Current state of this repo (local/POC only)

CHAT_URL/CHECKOUT_URL in sdk.js are currently just relative paths (chat/dist/index.html) that only resolve because everything is served from one local static server. Sharing sdk.js alone, as-is, will not work anywhere else — those two constants need to become real hosted URLs once chat/dist and checkout/dist are deployed somewhere public.

Quick start

This is the entire integration surface a partner ever needs to write.

<!-- 1. Include the SDK -->
<script src="sdk/sdk.js"></script>

<!-- 2. Initialize it -->
<script>
  TravelBot.init({
    partnerId: "partner_123",
    userJWT: "SIGNED_JWT_TOKEN",
    theme: { primaryColor: "#2563eb" }
  });
</script>

That's it — a chat bubble appears immediately. No iframe URLs to configure: the SDK owns where its own chat and checkout apps live.

Architecture

The two-iframe pattern: one persistent, one ephemeral, brokered by the SDK.

Partner page
embeds ↓
sdk.js — message broker
Iframe A — Chat
persistent, injected on load
⇄ postMessage ⇄
Iframe B — Checkout
ephemeral, spawned on demand

Chat (Iframe A)

Injected once, on load, and stays alive for the whole session. Built in React (Vite), served as a static build. Streams live replies from Odynn's agent backend — see Live backend integration below — and renders structured flight offers inline.

Checkout (Iframe B)

Only exists while the user is paying. Chat sends OPEN_CHECKOUT → the SDK builds a darkened overlay + centered modal + iframe → on success or cancel, the SDK completely removes it from the DOM and focus returns to chat. Also built in React (Vite). Renders one of two real card-collection paths depending on a feature flag (see Security model), plus a real Uplift Flex Pay (Buy Now Pay Later) integration — both tested end-to-end against live Duffel bookings, not mocked.

Live backend integration

The chat iframe streams real replies from Odynn's live agent API — not a mock.

EndpointPurpose
POST /chat/start/streamOpens a new conversation thread — { thread_id, user_id }
POST /chat/continue/streamSends a message on an existing thread — { thread_id, user_id, message }

Both return Server-Sent Events: {type: "message"|"tool_call"|"tool_data"|"end", content, ...}. message events stream the reply token-by-token; tool_call events surface a transient status (e.g. "Searching for flights…"); tool_data carries structured payloads — real Duffel flight offers (tool_name: "search_flights") or payment-session data (tool_name: "initiate_payment"); end closes the stream. thread_id is generated once per page load, so the agent keeps conversation memory across turns without the frontend re-sending history.

A separate POST /booking/direct call — { offer_id, card_id, passengers, user_id, thread_id } — actually creates the booking once payment succeeds. This is currently the live agent backend's own endpoint (Anuj's), not yet routed through EngiNeo's Governance Layer.

If a request's token has expired (401) or hits a rate limit (429), the SDK retries automatically once — refreshing the token or waiting out the limit — before surfacing an error. There is no mock-data fallback; a failed live call surfaces a real error message instead of a fabricated reply.

Config reference

TravelBot.init(config) accepts:

KeyTypeRequiredDescription
partnerIdstringYesIdentifies the embedding partner; passed through to both iframes as a query param.
userJWTstringYesSigned token identifying the end user. Never placed in a URL — passed only via postMessage.
tokenExpiresInnumberNoSeconds until userJWT expires. Lets the chat iframe schedule its own refresh ahead of expiry instead of waiting for a request to fail first.
onTokenRefreshNeededfunctionNoCalled when the current token is about to expire (or a request just hit a 401). The partner should fetch a new token from their own backend, then call TravelBot.updateToken(token, expiresIn).
threadIdstringNoResumes a specific prior conversation instead of starting a new one.
theme.primaryColorstringNoBrand color for the bubble, chat header, and buttons. Defaults to #2563eb.
theme.backgroundColorstringNoWidget background. Defaults to #ffffff.
debugbooleanNoEnables verbose [TravelBot] console logging. Off by default.
Chat and checkout iframe locations are not configurable by the partner — they're owned internally by sdk.js per the whitelabel security model.

Public methods

MethodDescription
TravelBot.init(config)Initializes the widget. Safe to call again — automatically tears down any previous instance first.
TravelBot.open()Expands the chat panel.
TravelBot.close()Collapses the chat panel.
TravelBot.destroy()Removes all DOM, listeners, and timers. Resets the widget to an uninitialized state.
TravelBot.updateToken(token, expiresIn)Call once the partner's backend has minted a fresh session token — typically from onTokenRefreshNeeded, but safe to call proactively at any time. Re-sends auth to whichever iframes are currently mounted.

Message protocol

Every message is { type, data? }, sent via postMessage, and origin-checked on both ends.

TypeDirectionTrigger
READYIframe → SDKSent the moment an iframe's script loads. Starts the auth handshake.
INIT_AUTHSDK → IframeReply to READY; delivers the JWT. Retried up to 3× if unacknowledged.
OPEN_CHECKOUTChat → SDKUser selects something to book. SDK spawns the checkout modal.
CLOSE_CHECKOUTCheckout → SDKUser cancels. SDK tears the modal down.
PAYMENT_SUCCESSCheckout → SDK → ChatPayment completes. SDK destroys checkout and forwards confirmation to chat.
CLOSE_CHATChat → SDKCollapses the chat panel programmatically.
REQUEST_TOKEN_REFRESHChat/Checkout → SDKSent when the current token is nearing expiry, or a request just hit a 401. SDK invokes the partner's onTokenRefreshNeeded callback; the SDK cannot mint a token itself.

Security model

Origin validation

The SDK only accepts messages whose event.origin matches its own chat or checkout URL. Anything else is silently dropped before its contents are even read. Each iframe independently checks event.source === window.parent on its side.

Stateless JWT, not cookies

Third-party cookies can't be relied on inside iframes. Instead, the partner's JWT travels only over postMessage — never in a URL, never in localStorage — and is expected to ride on every backend request as Authorization: Bearer <jwt>.

Card data handling — two phases, one feature flag

Which of these is active is controlled by a single setting (VITE_PAYMENT_PROVIDER, default duffel) — flipping it is the entire migration, nothing else needs to change.

Phase 1 — current

Card entry uses Duffel's own hosted card form (@duffel/components). The card never reaches our own code at all — Duffel's widget tokenizes it directly and hands back a card_id. Uplift's Flex Pay virtual card is likewise passed through in plain form at this phase (no client-side encryption yet), matching the same phasing decision.

Phase 2 — October, built and ready behind the flag

A full Evervault-based card form (@evervault/react) tokenizes card details client-side before anything leaves the browser; Uplift's Flex Pay VCC gets the same client-side encryption treatment. Both are already built and tested — switching VITE_PAYMENT_PROVIDER to evervault activates this path with no other code changes.

Build status

PieceStatus
SDK, two-iframe architecture, JWT handshakedone
Chat + Iframe A, in Reactdone
Live chat replies (Odynn agent API, streamed)done
Proactive + reactive JWT refreshdone
429 rate-limit handling (auto-retry)done
Flight offer cardsdone real Duffel offers, not mocked
Card payment — Phase 1 (Duffel-hosted form)done tested with confirmed live bookings
Uplift / Flex Pay (BNPL)done tested with a confirmed live booking
Card + Flex Pay payment — Phase 2 (Evervault)built, on hold tested working; intentionally not active until October per agreed phasing
Routing through EngiNeo's Governance Layernot started still calling the agent backend directly
Production iframe hosting domainnot assigned
Partner developer portalnot started