Loops AI Docs

The message bridge

The complete two-way message contract — transports, envelope, handshake, and every action in both directions.

One contract, two transports. The action names and payloads are identical in both — only the envelope and the delivery mechanism change.

Transport A: native WebView

This is the transport the iOS and Android SDKs use, and the one to pick whenever you control a native WebView.

Web → host. The runtime looks for one object and calls postMessage on it:

PlatformObject it looks forWhat it passes
iOSwindow.webkit.messageHandlers.loopsAIBridgeA JavaScript object
Android and everything elsewindow.loopsAIBridgeA JSON string

The name is exact and case-sensitive: loopsAIBridge.

FrameworkHow you create it
Swift / WKWebViewconfiguration.userContentController.add(handler, name: "loopsAIBridge")
Kotlin / WebViewwebView.addJavascriptInterface(bridge, "loopsAIBridge") with @JavascriptInterface fun postMessage(message: String)
Fluttercontroller.addJavaScriptChannel("loopsAIBridge", onMessageReceived: …)
React NativeInject window.loopsAIBridge = { postMessage: (m) => window.ReactNativeWebView.postMessage(m) } before content loads
Anything elseExpose any object at window.loopsAIBridge with a postMessage(string) method

Host → web. Evaluate one line of JavaScript in the page:

window.postMessage(<envelope JSON>, "*");
FrameworkCall
SwiftwebView.evaluateJavaScript(js)
KotlinwebView.evaluateJavascript(js, null)
Fluttercontroller.runJavaScript(js)
React NativewebViewRef.current.injectJavaScript(js + " true;")

There is no window.ReactNativeWebView support in the runtime

The runtime only probes the two names above. A React Native, Capacitor, or Unity WebView must map its own channel to window.loopsAIBridge — a three-line shim, injected before page content loads.

Transport B: web iframe

Use this when the chat lives in an <iframe> inside a page your app already renders (Capacitor, Cordova, Ionic, an in-app HTML screen).

  • Web → host: the runtime posts to window.parent with targetOrigin: "*". Always check event.origin === "https://chat.loopsai.com" before you trust a message.
  • Host → web: iframe.contentWindow.postMessage(envelope, "https://chat.loopsai.com").
  • You must pass parentOrigin (URL-encoded) on the iframe src. The runtime only accepts inbound messages from its own origin, the referrer origin, and parentOrigin. Miss it and every command you send is dropped in silence.
  • Add allow="camera; microphone" to the <iframe> or try-on and voice mode cannot reach the hardware.

Keep `mode=sdk` even in an iframe

It is what turns a product tap into an openExternalUrl message you can intercept. Without it the runtime navigates the top frame itself — replacing your whole app screen with the product page.

The envelope

What you send — use the typed envelope on both transports; the runtime accepts it either way:

{
  "protocolVersion": 1,
  "type": "nativeAction",
  "name": "updateContext",
  "requestId": "req_1",          // optional, for your own correlation
  "payload": { "productContext": { "productCode": "SKU123" } }
}

What you receive depends on the transport:

{
  "protocolVersion": 1,
  "type": "nativeAction",
  "name": "openExternalUrl",
  "requestId": "req_1753…_4",
  "payload": { "url": "https://shop.example.com/p/123?utm_source=loopsai", "target": "_blank" }
}

Android receives this as a JSON string — parse it. iOS receives an object.

Flat, with the action name under action and the payload spread onto the same object:

{ "action": "openExternalUrl", "url": "https://shop.example.com/p/123", "target": "_blank" }

Rules that hold in both directions:

  • Unknown action names are ignored, never an error. You can send an action a future runtime understands, and receive one your app has never heard of — check the name and drop what you don't handle.
  • protocolVersion is 1. It exists so a shipped app keeps working when the protocol grows.
  • Everything is fire-and-forget. There are no responses, no acknowledgements, and no error messages in either direction.

The handshake

ready fires once the runtime has attached its message listener. Anything you send before it is lost — the listener does not exist yet, and there is no queue on the web side. Send initConfig / initContext from your ready handler, and guard later calls with a simple isReady flag (queue them yourself if a user can tap a button during load).

`ready` can arrive while nothing is on screen

If you keep the WebView warm between opens, ready and persistSession may arrive with no chat presented. Handle them at the container/service level, not inside a screen that gets torn down — that is exactly what the SDKs do.

Web → host actions

Everything the runtime tells you. Only the first two require action on your side.

ActionPayloadWhat to do
closeChat—Dismiss your container. The user asked to close the chat from inside it. Nothing happens unless you act.
openExternalUrl{ url, target: "_blank" }Route the link — a product tap, or any link in the conversation. See Links & navigation.
ready—The runtime is live. Send initConfig / initContext now.
respondingStateChange{ isResponding: boolean }Optional: drive a native typing / busy indicator.
productQuoteChanged{ product: { code, name, image, vtoEnabled } | null }Optional: the active product card above the input changed. null (or an empty product) means cleared. Useful for a native "Try this on" button.
conversationActive—Optional: a conversation is now active (e.g. show an "unread"/resume affordance).
newChatStarted{ source? }Optional: the user started a new chat.
persistSession{ agentId, anonUserId, conversationId }Recommended: store both ids — Session continuity.
trackEvent{ event, kind? }Optional: a canonical analytics event. See Analytics.
startSizeGuideInline{ product: { productCode?, gender? } }Only from the standalone size route — the user chose to continue in chat.
overlayOpen / overlayClosed—Optional: an in-chat overlay (try-on, size, sheet) opened or closed. Handy for suppressing your own gestures while it is up.

`openProduct` and `openModule` exist in the protocol but are not emitted

The current runtime sends every navigation — products included — as openExternalUrl with a full URL. Handle that one and you have covered product taps.

Host → web actions

ActionPayloadEffect
initContext{ productContext?, userContext? }Sets the initial context. Send once, from ready.
updateContext{ productContext?, userContext? }Updates context later — the user swiped to another product, or logged in.
initConfig{ config: { … } }Optional presentation overrides — see initConfig.
sendUserMessage{ message }Sends a message as the user (a suggestion chip, a canned question).
searchEscalation{ query, isOnlySearchProducts? }Opens AI search with a prefilled query. true = product results only.
suggestSize—Opens the size-suggestion form (height + weight).
startSizeGuide{ productContext: { productCode?, gender? } }Opens the body-type size guide.
startVirtualTryOn{ product: { productCode } }Starts virtual try-on for that product. Requires try-on enabled for the agent.
quoteProduct{ product: { productCode } }Pins the product as a card above the input, without sending a message — an "Ask about this product" button.
startTryOnFromQuote—Try on whatever is currently quoted.
clearProductQuote—Clears the quote card.
startVoiceMode—Enters voice mode. Requires voice enabled for the agent and microphone permission.
syncCustomerDetails{ customerId }Links the session to a known customer — call after login.
setAnalyticsConsent{ granted: boolean }Bridges your consent manager to the runtime's analytics gate.
setWebsiteFont{ fontFamily }Matches chat typography to your app (a CSS font-family value).
mobileStateChange{ isMobile: boolean }Re-evaluates the layout after a rotation or size-class change.
closeOverlays—Closes in-chat overlays without unloading the runtime — call it before you dismiss your container.
widgetVisibility{ visible: boolean }Tells the runtime it is on or off screen. Optional in a WebView (it assumes visible).

Web-embed-only actions (setEmbedFooterHeight, forwardHeaderTap, viewportGeometry, trackAnalytics) belong to the website bubble / bottom-bar shell. Ignore them in a mobile container.

Flows are gated by the agent's configuration

If a flow is disabled for your agent, the matching action is silently ignored. Read the agent's published flags once at startup (see Availability check) and only render the native buttons the agent actually supports.

initConfig

Theme, copy, and which flows exist all come from your agent's published configuration, so this message is for the handful of things only the container knows:

{
  "protocolVersion": 1,
  "type": "nativeAction",
  "name": "initConfig",
  "payload": {
    "config": {
      "closeButtonEnabled": true,
      "theme": "dark",
      "chatHistoryEnabled": false
    }
  }
}
KeyUse
closeButtonEnabledtrue shows the in-chat header ✕ — send it unless you draw your own close control, because mode=sdk hides it by default. Tapping it emits closeChat.
headerActionSlotWidthPxReserve that many pixels on the right of the chat header so a close button you overlay yourself never covers the runtime's controls. The iOS SDK sends 56 for its native ✕. Use this instead of closeButtonEnabled, not with it.
themelight or dark — follow your app's appearance instead of the published default.
chatHistoryEnabledHide the in-chat conversation list if your app has its own.
virtualTryOnEnabled, searchEscalationEnabled, productSuggestionEnabled, voiceModeEnabled, speechToTextEnabledPer-launch overrides for the flows. Omit a flag to keep the published value; send false to hide a flow for this launch (e.g. a device with no camera).
productContext / userContextAccepted here too, if you would rather send config and context in one message.

Copy overrides are also accepted (displayName, welcomeTitle, welcomeDescription, initialMessage, messagePlaceholder, privacyText, privacyPolicyUrl). Prefer the dashboard for these so web and mobile stay in sync.

Two ways to give the user a way out

A mode=sdk container has no close control until you provide one. Either draw a native one (and reserve space with headerActionSlotWidthPx) or enable the runtime's own with closeButtonEnabled: true. Do neither and the only way out of the chat is your back gesture.

Analytics

In mode=sdk the runtime stops sending canonical events to its own analytics sink and hands each one to you instead:

{ "name": "trackEvent",
  "payload": { "event": { "event": "loops_ai_select_item", "channel": "ios", "…": "…" } } }

Forward payload.event to your provider (Firebase Analytics, GA4, Segment, a webhook — anything that accepts a flat JSON event). kind: "interaction" marks a customer-facing interaction event rather than a canonical ecommerce one.

Two things to arrange up front

trackEvent is only emitted when your agent's mobile channel is configured for native-bridge delivery — ask your Loops AI contact to enable it. And because the web sink is off in mode=sdk, forwarding these events is the only way they reach analytics: if you want them in Loops AI reporting too, agree on an ingest destination with your Loops AI contact. Conversations and messages themselves are unaffected — those always reach the backend.

Event names and fields: Canonical events.

Security

What the runtime enforces, and what you must enforce.

The runtime:

  • Accepts inbound messages only from its own origin, the referrer origin, and parentOrigin. A native window.postMessage is same-origin, so it always passes; an iframe host must pass parentOrigin.
  • Ignores unknown action names.

You:

  • Verify the sender. iOS: check message.frameInfo.isMainFrame and that the frame's host is chat.loopsai.com. Iframe: check event.origin. Android: prefer WebViewCompat.addWebMessageListener with an origin rule when the device supports it, otherwise pin navigation to the chat host (below).
  • Keep the WebView on one host. addJavascriptInterface exposes your bridge to every page the WebView loads, so intercept navigation (shouldOverrideUrlLoading, decidePolicyFor) and route anything that is not chat.loopsai.com out of the WebView.
  • Send the exact origin when posting into an iframe — never "*".
  • Keep secrets out of context. productContext and userContext are forwarded to the assistant and may appear in conversation logs and analytics. Send an opaque customer id, not tokens or full personal records.

Next: Flow modes.

On this page