Loops AI Docs

Quickstart

A working chat container in one page — React Native, Flutter, Swift, Kotlin, or an in-app iframe.

Everything below is one screen's worth of code. Copy the tab for your stack, replace YOUR_AGENT_ID, and you have a working assistant.

Build the URL

https://chat.loopsai.com/YOUR_AGENT_ID?embedded=true&mode=sdk&platform=ios&isMobile=true

Four parameters, all four required for a mobile container. platform is ios or android — match the device so analytics land on the right channel. Full list: The chat URL.

Expose a bridge named loopsAIBridge

The runtime looks for one object and calls one method on it:

PlatformWhat the runtime looks forWhat it sends
iOS (WKWebView)window.webkit.messageHandlers.loopsAIBridgeA JS object
Everything elsewindow.loopsAIBridgeA JSON string

If neither exists, the runtime silently falls back to window.parent — which in a top-level WebView is itself, so your messages go nowhere. No bridge, no close button, no product links.

Handle closeChat and openExternalUrl

Those two need a native decision and nothing else does. Everything else is optional. Wait for ready before you send anything in.

Ask for a close button, or draw your own

In mode=sdk the runtime hides its own header ✕ — it assumes the container draws a native one, which is what our SDKs do. If your screen has no close control of its own, turn the web one on from your ready handler:

send("initConfig", { config: { closeButtonEnabled: true } });

Tapping it emits closeChat, which you already handle. Skip this only if you present your own ✕ or nav-bar button.

The container

import { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { WebView } from "react-native-webview";

const AGENT_ID = "YOUR_AGENT_ID";
const CHAT_ORIGIN = "https://chat.loopsai.com";

const CHAT_URL =
  `${CHAT_ORIGIN}/${AGENT_ID}` +
  `?embedded=true&mode=sdk&platform=${Platform.OS}&isMobile=true`;

// react-native-webview only exposes window.ReactNativeWebView, so map it to the
// object name the runtime expects. Injected before page scripts run.
const BRIDGE_SHIM = `
  window.loopsAIBridge = {
    postMessage: function (message) {
      window.ReactNativeWebView.postMessage(message);
    },
  };
  true;
`;

export function LoopsChat({ onClose, onOpenUrl }) {
  const ref = useRef(null);

  const send = useCallback((name, payload = {}) => {
    const envelope = JSON.stringify({
      protocolVersion: 1,
      type: "nativeAction",
      name,
      payload,
    });
    ref.current?.injectJavaScript(`window.postMessage(${envelope}, "*"); true;`);
  }, []);

  const onMessage = useCallback(
    (event) => {
      let msg;
      try {
        msg = JSON.parse(event.nativeEvent.data);
      } catch {
        return;
      }
      if (msg?.type !== "nativeAction") return;
      const payload = msg.payload || {};

      switch (msg.name) {
        case "ready":
          send("initContext", {
            productContext: { productCode: "SKU123", pageType: "PDP" },
          });
          break;
        case "closeChat":
          onClose();
          break;
        case "openExternalUrl":
          onOpenUrl(payload.url);
          break;
      }
    },
    [send, onClose, onOpenUrl],
  );

  return (
    <WebView
      ref={ref}
      source={{ uri: CHAT_URL }}
      onMessage={onMessage}
      injectedJavaScriptBeforeContentLoaded={BRIDGE_SHIM}
      originWhitelist={[CHAT_ORIGIN]}
      javaScriptEnabled
      domStorageEnabled
      allowsInlineMediaPlayback
      mediaPlaybackRequiresUserAction={false}
      mediaCapturePermissionGrantType="grantIfSameHostElseDeny"
      allowsBackForwardNavigationGestures={false}
    />
  );
}

The trailing true; in injected JavaScript is required — without it iOS logs a warning for the non-undefined return value.

addJavaScriptChannel creates exactly window.loopsAIBridge.postMessage(String), so Flutter needs no shim at all.

import 'dart:convert';
import 'package:webview_flutter/webview_flutter.dart';

const agentId = 'YOUR_AGENT_ID';
const chatOrigin = 'https://chat.loopsai.com';

class LoopsChat extends StatefulWidget { /* ... */ }

class _LoopsChatState extends State<LoopsChat> {
  late final WebViewController controller;

  @override
  void initState() {
    super.initState();
    final platform = Platform.isIOS ? 'ios' : 'android';
    final url = Uri.parse(
      '$chatOrigin/$agentId'
      '?embedded=true&mode=sdk&platform=$platform&isMobile=true',
    );

    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel(
        'loopsAIBridge',
        onMessageReceived: (JavaScriptMessage m) => _handle(m.message),
      )
      ..loadRequest(url);
  }

  void _send(String name, [Map<String, dynamic> payload = const {}]) {
    final envelope = jsonEncode({
      'protocolVersion': 1,
      'type': 'nativeAction',
      'name': name,
      'payload': payload,
    });
    controller.runJavaScript('window.postMessage($envelope, "*");');
  }

  void _handle(String raw) {
    final msg = jsonDecode(raw) as Map<String, dynamic>;
    if (msg['type'] != 'nativeAction') return;
    final payload = (msg['payload'] as Map<String, dynamic>?) ?? const {};

    switch (msg['name'] as String?) {
      case 'ready':
        _send('initContext', {
          'productContext': {'productCode': 'SKU123', 'pageType': 'PDP'},
        });
        break;
      case 'closeChat':
        Navigator.of(context).pop();
        break;
      case 'openExternalUrl':
        openProduct(payload['url'] as String);
        break;
    }
  }

  @override
  Widget build(BuildContext context) =>
      WebViewWidget(controller: controller);
}

For try-on and voice mode, also grant media capture and allow inline playback through the platform-specific controller (AndroidWebViewController / WebKitWebViewControllerCreationParams) — see the requirements table below.

This is what the iOS SDK does internally, minus the session store and the warm-WebView pool.

import UIKit
import WebKit

final class LoopsChatViewController: UIViewController,
                                     WKScriptMessageHandler,
                                     WKUIDelegate {
    private static let chatHost = "chat.loopsai.com"
    private let agentId = "YOUR_AGENT_ID"
    private var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let configuration = WKWebViewConfiguration()
        configuration.websiteDataStore = .default()      // session survives relaunch
        configuration.allowsInlineMediaPlayback = true   // try-on / voice
        configuration.userContentController.add(self, name: "loopsAIBridge")

        webView = WKWebView(frame: view.bounds, configuration: configuration)
        webView.uiDelegate = self
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(webView)

        var components = URLComponents(
            string: "https://\(Self.chatHost)/\(agentId)"
        )!
        components.queryItems = [
            URLQueryItem(name: "embedded", value: "true"),
            URLQueryItem(name: "mode", value: "sdk"),
            URLQueryItem(name: "platform", value: "ios"),
            URLQueryItem(name: "isMobile", value: "true"),
        ]
        webView.load(URLRequest(url: components.url!))
    }

    // MARK: web → native

    func userContentController(_ controller: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        guard message.frameInfo.isMainFrame,
              message.frameInfo.request.url?.host == Self.chatHost,
              let body = message.body as? [String: Any],
              body["type"] as? String == "nativeAction",
              let name = body["name"] as? String
        else { return }

        let payload = body["payload"] as? [String: Any] ?? [:]

        switch name {
        case "ready":
            send("initContext", ["productContext": ["productCode": "SKU123"]])
        case "closeChat":
            dismiss(animated: true)
        case "openExternalUrl":
            if let raw = payload["url"] as? String, let url = URL(string: raw) {
                route(to: url)
            }
        default:
            break
        }
    }

    // MARK: native → web

    private func send(_ name: String, _ payload: [String: Any] = [:]) {
        let envelope: [String: Any] = [
            "protocolVersion": 1,
            "type": "nativeAction",
            "name": name,
            "payload": payload,
        ]
        guard let data = try? JSONSerialization.data(withJSONObject: envelope),
              let json = String(data: data, encoding: .utf8) else { return }
        webView.evaluateJavaScript("window.postMessage(\(json), '*');")
    }

    // MARK: camera / microphone

    func webView(_ webView: WKWebView,
                 requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo,
                 type: WKMediaCaptureType,
                 decisionHandler: @escaping (WKPermissionDecision) -> Void) {
        decisionHandler(origin.host == Self.chatHost ? .grant : .deny)
    }

    // Product links open as `_blank`; never spawn a second WebView.
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let url = navigationAction.request.url { route(to: url) }
        return nil
    }
}
import android.webkit.JavascriptInterface
import android.webkit.WebView
import org.json.JSONObject

private const val CHAT_HOST = "chat.loopsai.com"
private const val AGENT_ID = "YOUR_AGENT_ID"

class LoopsBridge(private val onAction: (String, JSONObject) -> Unit) {
    @JavascriptInterface
    fun postMessage(message: String) {
        val envelope = runCatching { JSONObject(message) }.getOrNull() ?: return
        if (envelope.optString("type") != "nativeAction") return
        onAction(
            envelope.optString("name"),
            envelope.optJSONObject("payload") ?: JSONObject(),
        )
    }
}

class LoopsChatActivity : AppCompatActivity() {
    private lateinit var webView: WebView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        webView = WebView(this)
        setContentView(webView)

        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true                  // session + conversation cache
            mediaPlaybackRequiresUserGesture = false  // try-on / voice
        }

        webView.addJavascriptInterface(LoopsBridge(::handleAction), "loopsAIBridge")

        webView.webChromeClient = object : WebChromeClient() {
            override fun onPermissionRequest(request: PermissionRequest?) {
                request ?: return
                if (request.origin?.host == CHAT_HOST) request.grant(request.resources)
                else request.deny()
            }
        }

        webView.loadUrl(
            "https://$CHAT_HOST/$AGENT_ID" +
                "?embedded=true&mode=sdk&platform=android&isMobile=true"
        )
    }

    private fun send(name: String, payload: JSONObject = JSONObject()) {
        val envelope = JSONObject()
            .put("protocolVersion", 1)
            .put("type", "nativeAction")
            .put("name", name)
            .put("payload", payload)
        webView.post {
            webView.evaluateJavascript("window.postMessage($envelope, '*');", null)
        }
    }

    // @JavascriptInterface runs on a background thread — hop to the UI thread.
    private fun handleAction(name: String, payload: JSONObject) = runOnUiThread {
        when (name) {
            "ready" -> send(
                "initContext",
                JSONObject().put(
                    "productContext",
                    JSONObject().put("productCode", "SKU123"),
                ),
            )
            "closeChat" -> finish()
            "openExternalUrl" -> route(payload.optString("url"))
        }
    }
}

Keep the WebView pinned to one host

addJavascriptInterface exposes your bridge to every page the WebView loads. Implement shouldOverrideUrlLoading so any navigation away from chat.loopsai.com is routed out of the WebView instead of loaded inside it — see Links & navigation.

Use this when the screen is already a web page inside your app (Capacitor, Cordova, Ionic, or any in-app HTML view) and you cannot register a native message handler. Add parentOrigin — URL-encoded — or the runtime will ignore everything you send it.

<iframe
  id="loops-chat"
  src="https://chat.loopsai.com/YOUR_AGENT_ID?embedded=true&mode=sdk&platform=ios&isMobile=true&parentOrigin=https%3A%2F%2Fapp.example.com"
  allow="camera; microphone"
  style="border: 0; width: 100%; height: 100%"
></iframe>

<script>
  const CHAT_ORIGIN = "https://chat.loopsai.com";
  const iframe = document.getElementById("loops-chat");

  function send(name, payload = {}) {
    iframe.contentWindow.postMessage(
      { protocolVersion: 1, type: "nativeAction", name, payload },
      CHAT_ORIGIN,
    );
  }

  window.addEventListener("message", (event) => {
    if (event.origin !== CHAT_ORIGIN) return;
    const data = event.data;
    if (!data || typeof data !== "object") return;

    // The iframe transport uses the flat shape: { action, ...payload }
    switch (data.action) {
      case "ready":
        send("initContext", { productContext: { productCode: "SKU123" } });
        break;
      case "closeChat":
        hideChatScreen();
        break;
      case "openExternalUrl":
        router.push(data.url);
        break;
    }
  });
</script>

allow="camera; microphone" is what delegates those permissions to the iframe. Without it, virtual try-on and voice mode cannot access the camera or mic no matter what the app-level permissions say.

WebView requirements

Wire what you need. The first two rows are mandatory; the rest gate specific features, and skipping one makes exactly that feature fail silently.

You wantiOS (WKWebView)Android (WebView)
The chat to load— (on by default)settings.javaScriptEnabled = true
Session + history to persistconfiguration.websiteDataStore = .default()settings.domStorageEnabled = true
Voice mode (microphone)WKUIDelegate.requestMediaCapturePermissionFor → .grant, plus NSMicrophoneUsageDescriptionWebChromeClient.onPermissionRequest → grant(RESOURCE_AUDIO_CAPTURE), plus RECORD_AUDIO
Try-on from the cameraSame grant, plus NSCameraUsageDescription and allowsInlineMediaPlayback = trueSame grant for RESOURCE_VIDEO_CAPTURE, plus CAMERA and settings.mediaPlaybackRequiresUserGesture = false
Try-on from the photo libraryHandled by the systemWebChromeClient.onShowFileChooser — without it the picker never opens
Links leaving the chat cleanlyWKNavigationDelegate.decidePolicyFor + WKUIDelegate.createWebViewWithshouldOverrideUrlLoading + WebChromeClient.onCreateWindow

Request OS permissions before the flow starts

Granting the WebView's request is not enough — the app itself still needs the OS-level permission. Ask for camera / microphone access before the user opens try-on or voice mode, otherwise the WebView's request is denied by the system and the flow stalls with no error.

Verify it works

  1. The chat renders in a mobile layout. That layout comes from isMobile=true.
  2. A close control exists and works. Either your own native one, or the web ✕ after closeButtonEnabled: true. If the ✕ is there but does nothing, your bridge object is missing or wrongly named — the runtime emitted closeChat into the void.
  3. Tapping a product opens your product screen (or the in-app browser) instead of navigating inside the chat WebView.
  4. Reopen the chat — the conversation is still there. If it restarts every time, DOM storage is off, or you are recreating the WebView with a non-persistent data store. See Session continuity.

Next: The chat URL.

On this page