Loops AI Docs

Links & navigation

Turn a product tap inside the chat into a native product screen — closing the assistant, routing the URL, and keeping attribution intact.

When a shopper taps a product card in the conversation, the assistant should get out of the way and your product screen should open. In mode=sdk the runtime never navigates on its own — it hands you the URL and waits.

// web → host
{ "name": "openExternalUrl",
  "payload": {
    "url": "https://shop.example.com/p/1010600-88244?utm_source=loopsai&utm_medium=chat&utm_campaign=product_recommendation&utm_content=carousel&utm_term=1010600-88244",
    "target": "_blank"
  } }

Every outbound link comes through this one action — product cards, links the assistant writes in a message, the privacy policy. The URL arrives verbatim, with its full query string.

This is the whole product-tap integration

There is no separate product event to subscribe to. Handle openExternalUrl and product taps work. openProduct / openModule exist in the protocol but the current runtime does not emit them.

Pick a destination

Three valid answers. You can mix them — decide per URL.

StrategyWhat the shopper getsTrade-off
In-app browser (recommended)The web product page, inside your app — SFSafariViewController, Chrome Custom Tabs, or your framework's equivalentAttribution keeps working, because the page loads in a real web context; you have a web cart alongside your native one
Native screenYour real product screen, with your cart and checkoutZero web context, so the attribution parameters on the URL are never read — see Attribution
System browserLeaves your appRarely what you want on mobile

Default to the in-app browser

This is what the iOS and Android SDKs do out of the box, and it is the recommended default for a WebView integration too: the destination loads with its full query string in a context that runs the merchant's analytics, so a chat session that ends in a purchase is still measurable. Route into a native screen where the experience matters more than the measurement — deliberately, per URL.

Close, then navigate

For a product link the expected behaviour is: dismiss the assistant, then push the product screen. Do it in that order so the user does not see your screen appear behind a chat sheet.

function onOpenExternalUrl(url) {
  // Opt in per URL shape; everything else keeps its web context.
  const route = nativeRouteOptIn(url); // null → in-app browser

  if (route) {
    send("closeOverlays");   // close any in-chat overlay first
    dismissChatContainer();  // your sheet / route pop
    navigate(route);
    return;
  }

  openInAppBrowser(url); // default — keeps the chat open behind it
}

Do not close the chat for every link

A link inside an assistant message — a size chart, a policy page — should open over the conversation and leave it intact, so the shopper can come back and keep talking. Close the container only when you are taking them somewhere that replaces the conversation, like a product screen.

Map the URL to a native route

Match on the path, and carry the identifiers through. Your product URLs already contain everything you need.

const SHOP_HOSTS = new Set(["shop.example.com", "www.example.com"]);

function toNativeRoute(raw) {
  let url;
  try {
    url = new URL(raw);
  } catch {
    return null;
  }
  if (!SHOP_HOSTS.has(url.hostname)) return null;

  const productMatch = url.pathname.match(/^\/p\/([^/]+)/);
  if (productMatch) {
    return { name: "Product", params: { code: productMatch[1] } };
  }

  const categoryMatch = url.pathname.match(/^\/c\/([^/]+)/);
  if (categoryMatch) {
    return { name: "Category", params: { slug: categoryMatch[1] } };
  }

  return null; // unknown shape → in-app browser
}
private let shopHosts: Set<String> = ["shop.example.com", "www.example.com"]

func nativeRoute(for url: URL) -> Route? {
    guard let host = url.host, shopHosts.contains(host) else { return nil }
    let parts = url.pathComponents.filter { $0 != "/" }

    if parts.first == "p", parts.count > 1 { return .product(code: parts[1]) }
    if parts.first == "c", parts.count > 1 { return .category(slug: parts[1]) }
    return nil
}
private val shopHosts = setOf("shop.example.com", "www.example.com")

fun nativeRoute(raw: String): Route? {
    val url = raw.toUri()
    if (url.host !in shopHosts) return null
    val parts = url.pathSegments

    return when {
        parts.size > 1 && parts[0] == "p" -> Route.Product(parts[1])
        parts.size > 1 && parts[0] == "c" -> Route.Category(parts[1])
        else -> null
    }
}

Always fall back to the in-app browser for a URL you do not recognise. New link types will appear as your catalog and content grow, and a dead tap is worse than a web page.

Do you need custom URL schemes?

No. You are already inside your own app when the message arrives, so you can call your router directly — no myapp:// scheme, no universal-link round trip. Custom schemes only matter if you route the URL out to the OS and want it to come back into your app.

Attribution

Product URLs carry the parameters your Loops AI configuration is set up to add:

ParameterValue
utm_source, utm_mediumFrom your agent's UTM settings
utm_campaignproduct_recommendation
utm_contentWhere in the chat it was tapped
utm_termThe product code
loops_refSent instead of the utm_* set when UTM tagging is off

They may also carry loopsai_open=sidebar, which is meant for your web storefront — it opens the web widget on the landing page. Harmless; ignore it in a native route.

The runtime appends them before it hands you the URL, and every transport passes the query string through untouched — so attribution survives as long as the URL ends up in a web context. An in-app browser is a web context; a native screen is not.

Native routing means the attribution parameters are never read

These parameters are read by the analytics running on the destination web page. Route into a native screen and there is no page load, so nothing reads them — the chat session that produced the sale becomes invisible to the merchant's web reports. Open commercially important links in the in-app browser instead. Event-based native attribution (your app stamping its own purchase event with a Loops source signal) is on the roadmap — talk to your Loops AI contact if you need it before then. Background: Conversion attribution.

Keep navigation out of the chat WebView

Product links open as target: "_blank". Belt and braces: even with openExternalUrl handled, make sure the WebView itself can never navigate away from the chat host — a WebView showing your storefront with your JavaScript bridge attached is both a broken experience and a security problem.

PlatformHookDo
iOSWKNavigationDelegate.decidePolicyFor.cancel anything whose host is not chat.loopsai.com, and route it
iOSWKUIDelegate.createWebViewWithReturn nil and route the URL — never spawn a second WebView
AndroidshouldOverrideUrlLoadingReturn true for foreign hosts, and route
AndroidWebChromeClient.onCreateWindowCapture the target URL and route it
React NativeonShouldStartLoadWithRequestReturn false for foreign hosts, and route
FlutterNavigationDelegate.onNavigationRequestNavigationDecision.prevent for foreign hosts

Same-host navigation inside chat.loopsai.com must stay allowed — that is the runtime doing its job.

Closing the chat

Three things can close it. Handle all three.

TriggerWhat happens
The in-chat header ✕ (only when you enabled it with closeButtonEnabled: true)The runtime emits closeChat. Nothing happens until you dismiss your container.
Your own native control or back gestureSend closeOverlays, then dismiss
A product tap you routed nativelyYou dismiss as part of the navigation
case "closeChat":
  dismissChatContainer();
  break;

A `closeChat` with no handler is a dead button

The runtime cannot close a container it does not own. If the ✕ appears to do nothing, you either have no closeChat handler or your bridge object is missing entirely — the message never left the page. And if there is no ✕ at all, that is the mode=sdk default: enable it with closeButtonEnabled or present your own.

Prefer hiding your container over destroying the WebView. The conversation, scroll position and connection survive, so reopening is instant instead of a fresh load.

Returning from the standalone size guide

The standalone size route can hand the user back to the conversation:

{ "name": "startSizeGuideInline",
  "payload": { "product": { "productCode": "1010600-88244", "gender": "women" } } }

Close the size sheet, open the chat, and replay it as a startSizeGuide:

case "startSizeGuideInline":
  dismissSizeSheet();
  presentChat();                       // wait for `ready`
  onReady(() => {
    send("initContext", {});
    send("startSizeGuide", { productContext: payload.product });
  });
  break;

The reverse direction is just a URL. Anything that can open a screen in your app — a push notification, a marketing link, a shortcut — can open the assistant in a specific state, because every entry point has a URL parameter:

You wantParameter
A fresh conversationfresh=true
Straight into size suggestionsuggestSize=true
The size guide for a productsizeGuide=true&productCode=…
A search already runningsearchQuery=…

Full list: The chat URL.

Next: Checklist & troubleshooting.

On this page