WebXR Integration
The compiled @treedis/vps-webxr package localizes a browser WebXR AR session against a published Treedis tour, keeps it localized, and shows tags, AR objects, navigation routes and an occlusion mesh in the camera view. It uses the browser's WebXR Device API and raw camera access, so there is nothing to install on the phone. Your page supplies its UI, authenticated platform requests and route data. Localization is online only: every fix comes from the VPS server.
VPSController supplies the host command/event API, and VPSSession owns localization, content and navigation. They share the command names, events, coordinate frame and localization decisions of the iOS and Android SDKs, so a host written for one platform adapts to another with little change.
| Entry point | Contents |
|---|---|
@treedis/vps-webxr |
VPSController, VPSSession, VPSNavigation, the server client, installWebAPI, the WebXR helpers and all types. No dependencies. |
@treedis/vps-webxr/three |
VPSThreeView, which draws the content with three.js. Peer dependency: three 0.170 or newer. |
Install the SDK
Obtain TreedisVPS-WebXR-<version>.zip and its SHA-256 checksum from Treedis, the same way as the native SDKs. The archive holds the npm tarball treedis-vps-webxr-<version>.tgz: compiled, minified ES modules, TypeScript declarations, the README and the licence. Customers do not need the SDK implementation or a source repository. The package is not published to the public npm registry.
- Verify the archive checksum and extract it into your project.
- Install the tarball with npm, giving its path if you run the command elsewhere:
npm install ./treedis-vps-webxr-<version>.tgz
- For three.js rendering, install
three0.170 or newer in the same project:npm install three. The core entry point does not need it. - Import from
@treedis/vps-webxrand@treedis/vps-webxr/three. The package ships ES modules only; use your bundler or an import map.
Pin the SDK version and checksum in your build process, and replace the complete package when upgrading. Retain the supplied licence file when you redistribute the package with your product.
Requirements and browser support
- Chrome, or another Chromium browser, on an ARCore-supported Android phone, opened over HTTPS or
http://localhost. - The session needs WebXR Raw Camera Access (
camera-access), because the SDK reads the camera image to localize. A session without it cannot localize and ends in thefailedstate with anotSupportederror. The user must allow the camera when the browser asks. - iOS Safari and desktop browsers cannot localize. Check with
isWebXRSupported()and offer another experience when it returnsfalse. Headset browsers such as Quest Browser depend on their own camera access feature and are not tested. - A network connection to the VPS server (or your proxy) is required during the whole session. There are no offline packs and no on-device engine on the web.
- Start the AR session from a user gesture, such as a tap on a start button. Browsers refuse XR sessions outside one.
The SDK requests the required features local and camera-access, and the optional features dom-overlay, local-floor and hit-test. Content and navigation use the gravity-aligned local reference space.
Minimal three.js integration
This page starts AR for a tour whose published ID your host already knows, and shows one tag once the phone is localized. Tour IDs are scoped to your deployment: verify the environment and tour ID before starting.
<button id="start" disabled>Start AR</button>
<div id="overlay"></div>
import * as THREE from 'three';
import { VPSController, VPSSession, VPSTarget, isWebXRSupported } from '@treedis/vps-webxr';
import { VPSThreeView } from '@treedis/vps-webxr/three';
const tourId = 123456; // The published VPS tour ID in your environment.
// alpha: the camera image shows through; keep scene.background null.
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.xr.enabled = true;
document.body.append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.01, 100);
const session = new VPSSession({ serverURL: 'https://your-host.example/vps' });
const controller = new VPSController(session);
controller.loadTour = async (slug, matterId) => ({
target: VPSTarget.tour(tourId),
tags: [{ id: 'reception', title: 'Reception', position: { x: 2, y: 0.9, z: -4 } }],
});
controller.onEvent = (name, args) => console.log(name, args);
const view = new VPSThreeView({ renderer, scene, camera });
controller.start(view);
view.setAnimationLoop((time, frame) => { /* Your own per-frame work. */ });
const startButton = document.getElementById('start');
isWebXRSupported().then((supported) => { startButton.disabled = !supported; });
startButton.onclick = () => {
// startXR requests the XR session first: call it directly from the tap.
view.startXR({ domOverlay: document.getElementById('overlay') }).then(() => {
controller.setTourInfo('your-tour-slug');
controller.startLocalization();
}, (error) => console.error(error.message));
};
controller.start(view) attaches the renderer and accepts commands; onReady follows and does not mean the phone is localized. Commands sent earlier wait in an ordered queue and run before onReady. startXR requests the immersive-ar session before it does anything else, so call it straight from the tap with no await before it.
view.root is the tour frame in the AR scene: add your own three.js objects to it, in tour coordinates (Y up, metres). It stays hidden until the first fix. The scene's world is the WebXR reference space, so do not move a parent of the camera.
setAnimationLoop(callback?) runs view.update(frame), your callback, then the render. If you run your own loop, call view.update(frame) once per XR frame before rendering.
VPSThreeView option |
Purpose |
|---|---|
renderer |
A THREE.WebGLRenderer created with alpha: true. |
scene, camera |
Your scene and camera. |
modelLoader? |
Loads VS_OBJECT models. The default reads glTF and GLB with three's GLTFLoader. |
labelPainter? |
Draws label textures. The default paints on a canvas. |
VPSThreeView member |
Purpose |
|---|---|
startXR(init?) |
Starts the immersive-ar session and hands it to the VPS session. init is a VPSSessionInit: domOverlay (an element shown over the camera view), optionalFeatures (more WebXR features) and extra (merged into the session init). |
update(frame?), setAnimationLoop(callback?) |
Per-frame work and the render loop. |
root |
The tour frame group. |
detach(), dispose() |
Free the content; dispose() also removes root and ends the view's loop. |
session.stop() pauses localization while preserving the fix and content for the next start. session.reset() releases the experience and forces a fresh scan. session.dispose() stops everything and releases the XR session's GL objects. A new XR session always scans from scratch.
Other engines
For Babylon.js, PlayCanvas or raw WebGL, implement VPSRenderer and pass it to controller.start(renderer). The renderer draws everything in tour coordinates under one node whose transform is session.localFromTour, a column-major Float64Array that is null before the first fix.
VPSRenderer method |
Purpose |
|---|---|
attach(host), detach() |
attach receives the VPSRendererHost; detach() frees the content. |
setAnchor(localFromTour, animated) |
The tour frame in the WebXR reference space; null hides all content. |
setContent(items, hidden), setObjects(objects) |
Tags and the complete AR object list after each change. |
setNavigation(render, maneuvers) |
The route path points, end node and upcoming turns. |
setMesh(mesh, display) |
The occlusion mesh, with display occlusion, overlay or hidden. |
setPlacementPreview(position) |
The thread placement preview; null hides it. |
hitTest(x, y), raycastCentre() |
The object under a screen point (CSS pixels from the top-left), and the nearest surface along the view's centre ray. |
Your own session code feeds the XR session and its frames to the VPS session:
- Call
requestARSession(options)from a user gesture to get the XR session, or build the init withsessionInit(options). - Call
session.attachXR({ session, gl, binding })when the XR session starts.glis the WebGL2 context of its base layer;bindingis yourXRWebGLBindingif you have one, otherwise the SDK creates one. - Call
session.onXRFrame(frame, referenceSpace)in every XR animation frame, before rendering, with thelocalreference space the scene renders in. - Report content events, such as
objectTappedandobjectLoaded, withhost.arEvent(event)on theVPSRendererHostpassed toattach. Or callsession.click(x, y)and let yourhitTestfind the object.
API key and server access
The SDK sends the VPS key in the X-API-Key header, only to the serverURL origin, and never in a URL, event or log. AR object media and upload URLs never receive it. With the key in the page, the occlusion mesh is the one exception: the server redirects its download to a signed storage link, and browsers keep custom headers on redirects. A key in a web page is visible to its users. Choose one setup:
- Host proxy (recommended for public pages).
serverURLpoints to a route on your own server that adds the key and forwards the SDK's routes to the VPS server.headerssends your own session token to that proxy. The proxy forwards/health,/tours/<id>/{info,sweeps,mesh.bin,localize,localize/multi}and/mapsets/<id>/localize. If the server answers with a redirect to a signed download link, do not send the key to that other host. - Key in the page. Use it for internal tools only. The VPS server accepts browser requests from any origin.
// Host proxy: your own token goes to your route; the proxy adds the VPS key.
const session = new VPSSession({
serverURL: '/vps',
headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
});
// Key in the page: internal tools only.
const internal = new VPSSession({ serverURL: 'https://vps.treedis.com', apiKey });
VPSConfiguration field |
Purpose |
|---|---|
serverURL |
The VPS server, or your proxy route. |
apiKey? |
Sent as X-API-Key. Omit it for a proxy. |
headers? |
A function returning extra headers, or a promise of them. It runs before each request. |
Assigning session.configuration switches server: the content is dropped and a new scan starts.
Integrate with your platform
The SDK does not fetch Treedis account data or choose a SaaS or Connected Workers environment. Your page:
- Resolves the tour slug to the correct tour ID for its environment.
- Fetches AR settings, tags, rich content, and the navigation graph or route from its API.
- Converts that data to
VPSExperience,VPSContentItem,VPSARObjectandVPSRoutevalues. - Supplies the matching VPS server URL and authorization. IDs can overlap between deployments, so verify environment, tour ID and model before starting.
const sdk = new VPSController(session);
// Your loaders return SDK values, using your own API and authentication.
sdk.loadTour = async (slug, matterId) => platform.experience(slug, matterId);
sdk.hostCommand = async (command, data) => platform.handle(command, data);
session.navigation.routeProvider = async (request) => platform.route(request);
sdk.start(view);
sdk.setTourInfo('your-tour-slug');
sdk.startLocalization();
Here platform is your page's adapter, not another SDK dependency. loadTour returns a VPSExperience:
VPSExperience field |
Purpose |
|---|---|
target |
VPSTarget.tour(id), or VPSTarget.mapSet(primaryTourId). |
settings? |
A partial VPSLocalizationSettings; missing values use the defaults. |
tags? |
VPSContentItem values. |
objects? |
VPSARObject values. |
setEnvironment, loadUI, unloadUI, push, device integrity, session origin, sign-out and logging commands go to hostCommand. Update your API and CDN clients and session.configuration there for setEnvironment; that completes before the next loadTour call. A missing loader or host handler produces onError with the command and a message. A handler must perform the action or throw: accepting a command alone does not implement it.
controller.setTourInfo stops navigation, resets the session and emits onLoadingStarted, then onLoadingProgress and onLoadingComplete after loadTour resolves. A failed load emits onError.
Navigation from A to B
The host route provider receives a VPSRouteRequest: the live start position, the destination (id, title, position) and a type of shortest, noElevators or noStairs. Call your route endpoint and return a VPSRoute: ordered VPSPathNode values (position, optional sweepID and transition) in tour coordinates, and the destination. The SDK draws the path dots and turn markers, measures progress, detects deviation, reroutes through the provider and emits arrival and floor-change events.
// Call after localization.
await session.navigation.start(
{ id: '123', title: 'Conference room', position: { x: 2, y: 1, z: -4 } },
'shortest',
);
To provide a path directly, call session.navigation.setPath({ nodes, destination }); this disables automatic rerouting. The wire setPath command is a single-floor path without transitions. recompute() keeps the previous usable path if its request fails.
A floor-change node carries a VPSFloorTransition with kind (stairs or elevator), direction (up or down), name and exitPosition. On the waitingForFloorChange state, show your confirmation, call confirmTeleport() before the user moves between floors, then endTeleport() after they physically arrive. Content hides during the change, the SDK applies a landing hint, and a new localization is required before navigation continues. Do not send both commands back to back.
Read session.navigation.state, route, remainingMeters, instruction, bearingDegrees, isWrongDirection and nextManeuver, or subscribe with session.navigation.on(listener). Path appearance is set through session.navigation.style.
Localization
Localization follows the same decisions as iOS and Android, with the WebXR local reference space as the AR frame.
- Scan. Every 0.8 seconds, after the camera moved 5 cm or turned 5 degrees, the SDK reads the raw camera texture on the GPU and sends it to the server as a JPEG (long side 1280 pixels) with its intrinsics and the gravity direction. The server returns the camera pose in the map. Two fixes that agree within 0.25 m and 5 degrees give the anchor, and
onLocalizationStatusChanged(true)follows. - Background relocalization. Every 30 seconds or 10 metres, and when tracking comes back, a frame goes to the server with a position hint near the camera. Fixes below the confidence threshold (0.4) are not used. Agreeing fixes refine the anchor (an inlier-weighted mean of up to five), and two confident, agreeing fixes from separate viewpoints re-align it.
- Tracking resets. A reference-space reset, lost tracking or a hidden session re-establish the anchor from two agreeing fixes while the content stays in place. A new XR session scans from scratch.
Read session.state: idle, scanning (with progress.fixesFound, fixesNeeded and isTakingLong), localized, relocalizing, or failed (with a VPSError). isTakingLong turns on after 20 seconds without a fix: suggest pointing the phone at furniture and doors, not plain walls. session.cameraPose is the live camera in tour coordinates, and session.diagnostics has tracking, request and mesh details. Subscribe with session.on(listener) for scanStarted, localized, anchorMoved, relocalizing and requestFailed.
session.update(settings) changes the settings. Numbers apply at the next decision and the display settings at once. Values outside the supported ranges are clamped.
| Setting | Default | Meaning |
|---|---|---|
confidenceCheck |
true |
Background fixes below confidenceThreshold are not used. |
confidenceThreshold |
0.4 |
The engine's probability, 0–1, that a fix is within 0.25 m and 2 degrees. |
backgroundRelocalization |
true |
After the first fix, keep checking the position in the background. |
relocalizeEverySeconds |
30 |
Background check interval, 5–180 s. |
relocalizeEveryMeters |
10 |
Background check distance, 0.5–100 m. |
scanTimeoutSeconds |
25 |
Request timeout while scanning, 3–180 s. |
backgroundTimeoutSeconds |
12 |
Request timeout for background checks, 3–180 s. |
positionHint |
true |
Once localized, the server searches only near the camera. |
positionHintRadiusMeters |
10 |
Search radius around the camera, 1–100 m. |
floorBand |
null |
[min, max] tour Y in metres: only map views on one floor. |
searchDepth |
1 |
How widely the server searches, 1–5. |
queryMode |
'vps-1' |
'vps-1' is standard; 'vps-2' searches deeper and is about twice as slow. |
multiFrame |
false |
Send several frames with their WebXR poses in one request. Tours only; map sets use single frames. |
multiFrameCount |
4 |
Frames per request, 2–6. |
multiFrameIntervalSeconds |
0.5 |
Time between those frames, 0.3–0.8 s. |
gpsHint |
false |
Send the browser's GPS position as a search hint when the tour is georeferenced. The browser asks for location permission. |
geoPose |
true |
Ask the server for each fix's geographic pose when the tour is georeferenced. |
occlusion |
true |
The occlusion mesh hides content behind real walls and furniture. |
meshOverlay |
false |
Debug: show the mesh in translucent cyan. It wins over occlusion. |
session.setLocalizationHint(position, { radius, floorBand, expiresAfterSeconds }) limits the search in ordinary tour coordinates, for example after a QR code at the entrance. The radius defaults to 25 m and clamps to 1–100 m. It applies to scans and background fixes, and clears after the next accepted fix. rescan() discards the current fix and relocalize() sends the next frame at once.
A failed request is a VPSError with a kind and, for http, a status. session.problem holds the last failed request, and clears when one gets through, on relocalize() and on a new scan.
Error kind |
Meaning |
|---|---|
invalidServerURL |
serverURL is not a URL with a host. |
unreachable |
No connection, or the browser blocked the request (network, CORS or mixed content). |
http |
The server answered with an error status; 401 and 403 mean it refused the API key. |
unexpectedReply |
The server's answer cannot be read. |
timedOut |
The server took longer than the settings allow. |
notSupported |
This browser or device cannot run the session: no WebXR AR or no camera access. |
arSession |
The WebXR session ended or failed. |
failed |
A failure the SDK describes in a sentence. |
VPSTarget.mapSet(primaryTourId) localizes online in the primary tour's coordinate frame. session.server is the server client, with health(), info(tourId) and sweeps(tourId).
Spatial content, queries and threads
session.setContent(items) replaces the tags: each VPSContentItem has id, position, title and optional stem, showsStem and color. session.setObjects, upsertObject, removeObject and setObjectVisible manage VPSARObject values. Object types are TAG, THREAD, VS_IMAGE, VS_VIDEO, VS_OBJECT, VS_HTML and POLYGON. An object has an id, type, position (tour frame), optional rotation, scale, isVisible, renderRange ([min, max] camera distance in metres), completed, loops, playsAnimations, and a payload:
Payload kind |
Fields | Notes |
|---|---|---|
label |
title, color? |
A text label. |
image |
url, width, height |
Sizes are in metres. |
video |
url, width, height, autoplay? |
Plays only with autoplay: true, while AR runs and the object is in view. |
model |
url |
glTF or GLB, through three's GLTFLoader or your modelLoader. |
html |
html, width, height |
Shown as a static text card. A tap goes to the host, which can present the interactive page. |
polygon |
points, color?, lineWidth?, closed? |
A line or polygon in the object's local coordinates. |
Media URLs are fetched without the VPS key. The occlusion mesh loads from the server with the map and hides content behind real walls. If it fails to load, content still shows without occlusion. setMeshVisible(true) shows it in cyan; onMeshReady fires when it is ready.
Taps on the screen emit onARObjectClick(id, type). With a DOM overlay, call preventDefault() on beforexrselect over your own UI so its taps do not select content. Use click(x, y), in CSS pixels from the top-left of the XR view, only when an overlay intercepts the touch; setWindowSize(width, height) scales those coordinates, and 0 restores the view's own size.
raycastTag() finds the tag nearest the centre of the view, within a 0.75 m wide, 25 m long cone. captureCameraFrame(uploadUrl?) returns a camera-only JPEG with its dimensions and intrinsics, never a screen grab with content or UI. With an HTTPS signed PUT URL, the controller uploads the frame there; the VPS key is not attached.
Thread placement supports device and surface modes, confirm, cancel, completion and removal. The SDK returns the placement and the nearest sweep ID; your host persists threads and messages in its own backend. Thread placement needs a localized session: otherwise onThreadCreationError reports Localization required.
Commands
VPSController accepts typed method calls, controller.send(type, data) and controller.receive(json). Wire data is a JSON object, and an optional requestId belongs inside it. All 37 commands of the shared API contract have a method with the same name:
controller.send('getPosition', { requestId: 'p1' });
controller.receive('{"type":"setLocalizationHint","data":{"x":2,"y":1,"z":-4,"radius":2.5}}');
Commands queue until start(view) attaches the renderer (up to 256 before onError). controller.stop() clears queued commands and cancels work. controller.reset() clears the experience, content, route and hints, and cancels an unfinished tour load, while keeping the renderer attached.
| Commands | Data / JavaScript API |
|---|---|
setEnvironment |
environment, customApiUrl?, customCdnUrl?; setEnvironment(environment, customApiUrl?, customCdnUrl?), host adapter |
setTourInfo |
slug, matterId?; setTourInfo(slug, matterId?) |
startLocalization, relocalize, reset |
Methods with the same names |
setLocalizationHint |
x,y,z, radius?, floorMinY?, floorMaxY?; setLocalizationHint(x, y, z, radiusMeters?, floorMinY?, floorMaxY?) |
clearLocalizationHint |
clearLocalizationHint() |
showARObject, hideARObject |
String id, type; showARObject(id, type), hideARObject(id, type) |
setMeshVisible |
Boolean visible; setMeshVisible(visible) |
click |
x,y; click(x, y) |
setWindowSize |
Integer width,height; setWindowSize(width, height) |
startNavigation |
destinationName,x,y,z,navigationType?; startNavigation(destinationName, x, y, z, navigationType = 'shortest') |
stopNavigation, recomputePath |
Methods with the same names |
setPath |
destinationName, nodes:[{x,y,z}]; setPath(destinationName, nodes) with VPSPathNode values |
confirmTeleport, endTeleport |
confirmTeleport(), endTeleport() |
getPosition, raycastTag |
requestId?; getPosition(requestId?), raycastTag(requestId?) |
captureCameraFrame |
requestId?, uploadUrl?; captureCameraFrame(requestId?, uploadUrl?) |
startThreadCreation, setThreadPlacementMode |
Boolean useDevicePosition; typed methods with that argument |
confirmThreadPlacement, cancelThreadCreation |
Methods with the same names |
spawnThread, updateThread |
thread with id, title, positionX, positionY, positionZ, isCompleted; spawnThread(thread), updateThread(thread) |
removeThread, setThreadCompleted |
threadId, and threadId,isCompleted; removeThread(threadId), setThreadCompleted(threadId, isCompleted) |
loadUI, unloadUI |
url for load; host adapter; also openTreedisUI and closeTreedisUI |
requestPushToken, requestDeviceIntegrity |
Host adapter |
reportSessionOrigin, signOut, log, logWebView |
Host adapter for account and diagnostic actions |
Unknown commands and malformed fields emit onError. Position fields must be finite numbers, object IDs strings (integer JSON IDs are normalized to strings) and flags booleans. All public positions use the Treedis/Showcase Y-up metre frame; convert any external coordinate format at the host boundary. The hint radius defaults to 25 metres and clamps to 1–100. Supply both floor bounds, in ascending order, or neither. An updateThread without positionX only changes the completion state. In a position query, rotation is the camera forward vector.
Events
Set controller.onEvent = (name, args) => { ... }, or subscribe with controller.addEventListener(listener), which returns a function that removes the listener. Every listener receives every event. A listener that throws is logged, and the others keep running. Event arguments are structured values that match the native {event,args} envelope.
| Event | Arguments |
|---|---|
onReady |
[]; attached and accepting commands, not a successful localization |
onLoadingStarted, onLoadingComplete |
[] |
onLoadingProgress |
[step, progress], progress 0–1 |
onLocalizationStatusChanged |
[localized]; scanning or re-establishing false, accepted localization true |
onLocalizationResult |
[success, confidence] for each attempt |
onARObjectClick |
[id, type] |
onARObjectReady |
[id, type], when an object's content has loaded |
onMeshReady |
[true] when the occlusion mesh is ready |
onNavigationDistance |
[metres] |
onNavigationTeleport |
["up" or "down", name, "stairs" or "elevator"] |
onNavigationComplete |
[] |
onPose |
[x, y, z, headingDegrees], while navigating |
onPosition |
[{requestId, localized, pose?, rotation?}]; pose has x,y,z,confidence, rotation has forward x,y,z |
onRaycast |
[{requestId, tagId, distance, confidence}]; tagId is null when absent |
onCameraFrame |
[{requestId, ok, mime?, width?, height?, image?, uploadUrl?, error?}]; image is JPEG base64 |
onThreadPlacementStarted |
["device" or "raycast"] |
onThreadPlaced |
[x, y, z, sweepID] |
onThreadPlacementCancelled |
[] |
onThreadPlacementFailed, onThreadCreationError |
[message] |
onMessage |
[{type, data}]; structured diagnostic and scanning message |
onError |
[{command?, objectId?, message}] |
onPushToken |
[token, platform], reported by the host |
onPushTap, onPushMessage |
[data], reported by the host |
onDeviceIntegrity |
[report], reported by the host |
onMessage types include log, localization_status (Searching or Success), localizationSuccess, localizationWarning, warning and info. After a first accepted localization, the soft warning can fire after ten seconds without 0.3 metres of movement or a failed background attempt, at most once every ten seconds. It does not mean the AR anchor has been lost. Your page supplies the scanning prompt and animation.
For finer detail, session.onAR(listener) reports localizationResult, objectTapped, objectLoaded, contentFailed and the thread placement events.
Hosted web UI and app services
installWebAPI(controller) adds window.TreedisVPS.send(type, data) and calls window[eventName](...args) for each event your page defines. A Treedis web UI written for the native WebViews, as described in Web Integration, runs unchanged, for example in the DOM overlay. It returns a function that removes the API again.
import { installWebAPI } from '@treedis/vps-webxr';
window.onLocalizationStatusChanged = (localized) => { /* Show or hide your scanning UI. */ };
window.onPose = (x, y, z, headingDegrees) => {};
window.onMessage = ({ type, data }) => {};
const remove = installWebAPI(controller);
TreedisVPS.send('getPosition', { requestId: 'position-1' });
Handle loadUI, unloadUI, environment changes, push registration, device integrity, sign-out and session diagnostics in controller.hostCommand. For loadUI, present the url in your own UI, for example in the DOM overlay. Return real service results through the controller:
controller.reportHostEvent({ type: 'pushToken', token, platform: 'web' });
controller.reportHostEvent({ type: 'pushTap', data: notificationData });
controller.reportHostEvent({ type: 'pushMessage', data: notificationData });
controller.reportHostEvent({ type: 'deviceIntegrity', report });
Notification data is a string-to-string object. The integrity report needs platform, issues and flags, plus any device or app metadata your host provides. The SDK does not manufacture a push token or assess your security policy.
Differences from the native SDKs
| Area | WebXR | iOS and Android |
|---|---|---|
| Localization engine | Server only. Needs a connection throughout. | Server, plus an optional on-device engine with offline packs. |
| Offline mode | Not available. | Explicit offline downloads. |
| App Clip and app links | None. Open the page from your own link or QR code. | iOS App Clip; Android app links. |
| Hosted UI | installWebAPI in the same page. |
VPSWebBridge over a WebView, with allowed origins. |
| HTML objects | A static text card; taps go to the host. | A static snapshot with JavaScript disabled. |
| 3D models | glTF and GLB. | glTF and GLB; iOS also USDZ and Reality. |
The command names, event names and arguments, coordinate frame and default localization settings are the same on every platform.

