Persistence
Keep one session alive across page navigation on a multi-page site
On a traditional multi-page site, clicking a link triggers a full page reload — which tears down the running session, so the agent would restart and reconnect on every page. Persistence keeps one session alive as the user moves around the site.
It works by loading your site into a full-window iframe — an iframe of your own site — while the avatar stays outside it, in the top-level page. This happens when a visitor starts a session with the agent, not when the SDK loads. From then on they browse inside that frame, so pages can change without the document holding the session ever reloading.
Before you enable it
Whether your site allows itself to be framed
Enable it
One flag on the init config
Options
The persistence config object
When the frame appears
At session start by default, or deferred to the first link click
What survives a navigation
Every way a page can change, and what it costs
Navigating from code
Redirect without ending the session
Break-out rules
Links that must leave the iframe
Allowing another domain
Keep a domain that isn't yours inside the frame
Known limitations
What framing costs you, and what to do about each
Before you enable it
Your site has to allow itself to be framed. The iframe is same-origin, but your own response headers still decide whether it renders: X-Frame-Options: SAMEORIGIN is fine, while X-Frame-Options: DENY, or a Content-Security-Policy with frame-ancestors 'none', blocks it — and those are common defaults on security-hardened sites.
Then check the known limitations at the end of this page.
Enable it
Turn persistence on with the persistence object when you initialize the SDK:
NapsterCompanionApiSdk.init(token, {
persistence: { enabled: true },
});Include the same snippet on every page of the site. When persistence loads a page into the frame, the copy of the snippet running inside that frame detects it and exits without doing anything — it never starts a second session — so it's safe everywhere.
By default a session persists only across pages on the same domain — the domain it started on, exactly as it appears in the address bar. Those are the pages the SDK loads in the iframe. Click a link to anywhere else — another site, or one of your own subdomains like auth.example.com — and the SDK doesn't put it in the iframe: it ends the session and navigates the whole tab there. You can allow specific domains inside the frame with persistence.include, at a cost. See Break-out rules.
Options
| Field | Type | Default | Description |
|---|---|---|---|
persistence.enabled | boolean | — | Turn persistence on. Required — there is no boolean shorthand. When on, mountContainer is ignored (the avatar must live in the top document). |
persistence.iframeOnNavigate | boolean | false | Wait for the visitor's first link click before loading the site into the frame, keeping the page they started on fully native. Off by default — the frame appears as soon as the session starts. See When the frame appears. |
persistence.exclude | { urls?: string[] } | — | Pages of your own site excluded from persistence — they open as a normal top-level navigation, which ends the session. See Break-out rules. |
persistence.include | { domains?: string[] } | — | Other domains allowed to load inside the frame, so a link to them keeps the session alive. Everything off your domain breaks out by default; this is the exception list. See Allowing another domain inside the frame. |
persistence.navigationProgress | boolean | { color?, height? } | false | Show a thin progress bar at the top of the page while a page loads into the site frame. Navigations inside the frame drive no browser chrome (no tab spinner), so without an indicator a click the engine took over looks dead for the whole load. true for default styling, or restyle with { color, height } (defaults #be369d, 3px — also overridable via the --np-persist-progress-color / --np-persist-progress-height CSS custom properties). Leave off if your site shows its own navigation indicator. |
persistence.iframeAllowAttribute | string | "autoplay; clipboard-write" | Value for the site iframe's allow attribute (Permissions Policy). |
persistence.syncHistory | boolean | true | Mirror the iframe's path into the address bar and follow back/forward. Same domain only. |
When the frame appears
Persistence works by putting your site inside an iframe. iframeOnNavigate controls when that happens — which gives a session two possible states, and they behave differently:
iframeOnNavigate: false — the default | iframeOnNavigate: true | |
|---|---|---|
| When the iframe appears | Straight away, as soon as the session starts | Only when the visitor first clicks a link |
| The page they started the session on | Inside the iframe from the start | Never inside the iframe — it renders exactly as it would without persistence |
| …and while they're still on it | Everything is safe. The iframe is already in place, so navigation happens inside it | Only link clicks are safe. Anything else that navigates — your code, a redirect — ends the session |
Creating the frame at session start is the default because it protects everything from the first moment: there is no window where a redirect or a script can end the session.
Set iframeOnNavigate: true to keep that page fully native until the visitor actually navigates — nothing about how it renders changes. The cost is that the page is unprotected until the first link click: anything else that navigates there must go through the persist-navigate event, or the session ends.
NapsterCompanionApiSdk.init(token, {
persistence: {
enabled: true,
iframeOnNavigate: true, // stay out of the frame until the first link click
},
});What survives a navigation
The rule behind this whole table in one line: the SDK sees what the visitor does — clicks and form submissions. It cannot see a navigation your code performs. Once the page is inside the iframe that stops mattering, because the frame absorbs the navigation and the top document (where the avatar lives) never reloads.
| What navigates | Before the frame exists | Inside the frame |
|---|---|---|
| Visitor clicks a link on your domain | ✅ Loads into the frame | ✅ Loads in the frame |
| Visitor clicks a link to another domain | ❌ Ends — by design, unless the domain is in include | ❌ Ends — by design, unless the domain is in include |
| Visitor submits a form to your domain | ✅ Routed into the frame | ✅ Submits inside the frame |
Your code: location.assign(), location.href = …, location.replace() | ❌ Ends — use the event | ✅ Only the frame navigates |
Your code: form.submit() | ❌ Ends — use requestSubmit() | ✅ Submits inside the frame |
A server redirect, or <meta http-equiv="refresh"> | ❌ Ends | ✅ The frame follows it |
SPA router (history.pushState) | ✅ No page load at all | ✅ No page load at all |
target="_blank" or window.open() | ✅ Opens a new tab; this one stays put | ✅ Opens a new tab; this one stays put |
Under the default (iframeOnNavigate: false), only the second column applies — the iframe exists from the moment the session starts, so there is no unprotected page. The first column describes the opt-in deferred mode (iframeOnNavigate: true), where the page the session started on stays outside the frame until the first link click.
Back and forward are handled inside the frame: the iframe follows them, and the address bar stays in step as long as syncHistory is on.
Navigating from code
This section fixes the ❌ rows in the table above — they apply while a page is still outside the frame, which under the default never happens, and they're all one problem. A click produces an event the SDK can intercept; location.assign(), location.href = … and location.replace() produce nothing. They're instructions straight to the browser, with no event to listen for, so on a page that is not inside the frame yet the tab simply leaves and the session goes with it.
The fix is to announce the navigation instead of performing it. Dispatch the exported PERSIST_NAVIGATE_EVENT on window:
import { PERSIST_NAVIGATE_EVENT } from "@touchcastllc/napster-companion-api";
function navigate(url) {
const e = new CustomEvent(PERSIST_NAVIGATE_EVENT, {
detail: { url },
cancelable: true,
});
window.dispatchEvent(e);
// If nothing handled it (no active persisted session, or an excluded URL),
// fall back to a normal navigation.
if (!e.defaultPrevented) location.assign(url);
}The SDK listens while the page is still native, creates the frame on the event, loads detail.url, and calls preventDefault() so you know it was handled. Excluded URLs (persistence.exclude) are ignored so they still navigate normally.
Forms submitted from code
The form.submit() row fails for a different reason than the rest. A form the visitor submits is safe — the browser fires a submit event first, and the SDK routes the submission into the frame. But form.submit() called from code fires no event at all, by design, so there is nothing for the SDK to hear.
Call form.requestSubmit() instead. It performs the same submission but fires the event first, exactly as if the visitor had pressed the button — which is all the SDK needs:
form.requestSubmit(); // not form.submit()requestSubmit() also runs the browser's own validation, which submit() skips.
Break-out rules
A link that leaves your domain breaks out by default, with no configuration: the SDK ends the session and navigates the whole tab to it. That covers any other site, and any subdomain of your own — auth.example.com is a different domain from www.example.com as far as the browser is concerned, so it breaks out on its own. To keep a specific domain inside the frame instead, see Allowing another domain inside the frame.
What you configure are your own pages that should end the session instead of running inside the frame — a checkout flow, a sign-out route. List them in persistence.exclude:
NapsterCompanionApiSdk.init(token, {
persistence: {
enabled: true,
exclude: {
urls: ["/checkout", "/signout"], // paths (and everything under them), or absolute https URLs
},
},
});When a link matches, the SDK ends the session and navigates the whole tab to that URL.
A urls entry matches its exact path and everything under it — at path boundaries, not by spelling. "/check" matches /check, /check/out, and /check?step=1, but not /checkout — a different page that merely shares the prefix. So excluding one route never accidentally excludes look-alike siblings. End an entry with / to say "everything under this directory" explicitly.
These rules are applied to the URL of the link the visitor clicks, and nothing after that. If a page on your domain redirects somewhere else, the SDK never saw it coming — a /login route that redirects to auth.example.com passes the check, because /login is on your domain. The other domain then loads inside the frame, where many login pages refuse to render at all. Exclude the starting path so the click leaves the frame up front: exclude: { urls: ["/login"] }.
Allowing another domain inside the frame
Sometimes a domain that isn't yours belongs in the session — a docs site you also run, a partner's help centre. List it in persistence.include and links to it load in the frame instead of ending the session:
NapsterCompanionApiSdk.init(token, {
persistence: {
enabled: true,
include: {
domains: ["docs.example.com", "partner.io"],
},
},
});An entry matches the exact host and any subdomain of it. "example.com" covers docs.example.com and shop.example.com, but not notexample.com — a different site that merely ends with the same letters.
The other site has to permit framing at all. A site that sends X-Frame-Options: DENY or a restrictive frame-ancestors renders nothing, and the SDK cannot detect that — the visitor sees a blank frame. Test every domain you add before shipping it, and only add domains you control or have agreed with.
Known limitations
Running your pages inside a frame costs some ordinary page behavior. None of these break a site, but check the ones that touch your flows.
Browser permissions narrow to what the frame allows. The framed page gets only what iframeAllowAttribute grants, and the default is "autoplay; clipboard-write". If your pages use the camera, microphone, geolocation or payment, add them — otherwise those features stop working once the frame exists:
persistence: {
enabled: true,
iframeAllowAttribute: "autoplay; clipboard-write; camera; microphone",
}Code that checks for framing behaves differently. Inside the frame, window.top !== window.self. Frame-busting scripts on your own site will fight the frame — remove them, or scope them to cross-origin parents. Third-party widgets that refuse to run framed (some SSO, payment and consent flows) need their pages in persistence.exclude, so they open as a normal top-level navigation instead.