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.
| Strategy | What the shopper gets | Trade-off |
|---|---|---|
| In-app browser (recommended) | The web product page, inside your app — SFSafariViewController, Chrome Custom Tabs, or your framework's equivalent | Attribution keeps working, because the page loads in a real web context; you have a web cart alongside your native one |
| Native screen | Your real product screen, with your cart and checkout | Zero web context, so the attribution parameters on the URL are never read — see Attribution |
| System browser | Leaves your app | Rarely 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:
| Parameter | Value |
|---|---|
utm_source, utm_medium | From your agent's UTM settings |
utm_campaign | product_recommendation |
utm_content | Where in the chat it was tapped |
utm_term | The product code |
loops_ref | Sent 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.
| Platform | Hook | Do |
|---|---|---|
| iOS | WKNavigationDelegate.decidePolicyFor | .cancel anything whose host is not chat.loopsai.com, and route it |
| iOS | WKUIDelegate.createWebViewWith | Return nil and route the URL — never spawn a second WebView |
| Android | shouldOverrideUrlLoading | Return true for foreign hosts, and route |
| Android | WebChromeClient.onCreateWindow | Capture the target URL and route it |
| React Native | onShouldStartLoadWithRequest | Return false for foreign hosts, and route |
| Flutter | NavigationDelegate.onNavigationRequest | NavigationDecision.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.
| Trigger | What 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 gesture | Send closeOverlays, then dismiss |
| A product tap you routed natively | You 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;Deep links into the assistant
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 want | Parameter |
|---|---|
| A fresh conversation | fresh=true |
| Straight into size suggestion | suggestSize=true |
| The size guide for a product | sizeGuide=true&productCode=… |
| A search already running | searchQuery=… |
Full list: The chat URL.
Next: Checklist & troubleshooting.