See your module in the sandbox shell
Your module is a federation remote. It is only really working when a shell loads it, and the shell that loads yours is a sandbox shell: the same Building OS image, deployed separately and bound to your sandbox. Your module never loads into the production shell — module JavaScript runs with the full trust of whatever shell loads it, so third-party code gets a shell of its own.
Since 1.14.0
tv-sdk sandbox install and the dev:remote / serve:remote scripts require @tv/extension-sdk 1.14.0 or later. A module scaffolded by 1.13.0 or earlier has neither script — rescaffold, or add them by hand as shown in step 1.
1. Serve your remote from your machine
@originjs/vite-plugin-federation emits remoteEntry.js in a build, not in the dev server. pnpm dev therefore serves an app with no remote at all, and a shell pointed at it gets a 404 that the browser reports as a CORS failure. Two terminals, two commands:
# terminal 1 — rebuild on every change
pnpm dev:remote # vite build --watch
# terminal 2 — serve the build output
pnpm serve:remote # vite preview --port 6001 --strictPortYour remote is then at:
http://localhost:6001/assets/remoteEntry.js6001 is the scaffold's pinned preview port (the dev port plus 1000). strictPort is deliberate: without it vite hops to the next free port and moves the remote out from under the registry row that points at it.
If your scaffold has no dev:remote / serve:remote, the equivalents are vite build --watch and vite preview --port 6001 --strictPort, with preview: { port: 6001, strictPort: true, cors: true } in vite.config.ts.
2. Install the module into your sandbox
npx @tv/extension-sdk sandbox install module-manifest.json \
--sandbox=sbx_... \
--remote-entry=http://localhost:6001/assets/remoteEntry.jsTwo calls against your own sandbox's tv-api, in order:
POST /api/v1/registry/modules/ingestwith your manifest and the remote entry URL — this is the registry row the shell renders from.POST /api/v1/licenseson the seeded building — the shell renders from licence state, so a registered but unlicensed module is simply absent. An existing licence answers409, which the command treats as success, so it is safe to re-run.
--sandbox=<id> resolves the API URL, the token, the seeded building and the console URL in one lookup. Without it, the command uses the TV_API_URL / TV_API_TOKEN pair that tv-sdk sandbox connect prints.
Both endpoints need platform-administrator rights, which you have inside your own sandbox: the sandbox's tv-api accepts its own token as an administrator of that one API and that one database, and of nothing else (tv-platform#795). If you read an older SDK note saying this call is expected to fail with 401/403, that note predates the change.
Moving where you serve from is a re-run of this command, not a shell rebuild.
3. Open it
<your sandbox host>/sandbox/<your module slug>The sandbox host is the consoleUrl on your sandbox record (the same host as apiUrl: the shell is served at /, the API stays on /api and /socket.io, so the UI and its backend share an origin). consoleUrl is null when no sandbox shell is deployed for your sandbox.
The sandbox shell mounts one generic /sandbox/:slug route driven by your registry row's remoteEntryUrl, federationName and exposeName — the production shell's /<slug> routes are not involved.
4. Sign in
The sandbox shell authenticates against the sandboxes Keycloak realm. There is no self-service sign-up there: the Tango Vision team creates an account for you in that realm, with the tenant_id claim set to your sandbox's organizationId. Ask at developers@tango.vision and give them the organizationId from your sandbox record.
What to expect, and what not to
- Use Chrome or Firefox, not Safari. Safari's handling of a page on a public HTTPS origin loading a script from
http://localhostblocks the remote. Chrome may ask for local network access permission the first time; allow it. - Take your token from the platform context, not from
localStorage['access_token']. The sandbox shell runs with the legacy token bridge off: a shell that mirrored a live access token into a key any script can read would be handing it to a stranger's JavaScript. Modules that read that key directly will not authenticate in a sandbox shell; modules that use the SDK'sPlatformContextare unaffected. See The PlatformContext. - The remote loads only if two separate gates admit it — the browser's CSP and the shell's own origin allowlist. Both are configured to admit
localhostin a sandbox shell. A remote served from somewhere else needs both changed, which is a request to us, not a setting on your side. - No licence gate on this route, but login still is required: the route sits inside the shell's protected area.
- Your module's own backend is not deployed by any of this.
installregisters a frontend remote and licences it. A backend runs wherever you run it, and reaches the sandbox's tv-api over the network like any other client.
The rules for loading a module from localhost
Everything above, as a checklist. If the page is blank, one of these is not true.
- Only a sandbox shell loads from localhost. The production shell's Content-Security-Policy does not admit
http://localhost, and its generic module route is switched off. A sandbox shell admits loopback origins and mounts any module in your sandbox's registry at/sandbox/<slug>. - Loopback only.
http://localhost:<port>andhttp://127.0.0.1:<port>are admitted. A LAN address, a tunnel URL or any other host is not, however it is reached. - Serve the build, not the dev server.
vite devdoes not emitassets/remoteEntry.js; a federation remote exists only in a build. Runpnpm dev:remoteandpnpm serve:remoteside by side. - The URL is fixed:
http://localhost:6001/assets/remoteEntry.js. The preview runs withstrictPort, so a busy port is an error rather than a silent move to another one, and with CORS on. - The registry row must point at it.
tv-sdk sandbox installwith--remote-entry=http://localhost:6001/assets/remoteEntry.jsingests the manifest into your own sandbox and grants it a licence on the seeded building. The slug is lowercase kebab-case, at most 64 characters, and the remote exposes./Shell. - Chrome or Firefox, not Safari. The shell is an HTTPS page loading a script from HTTP loopback; Safari blocks that. Chrome may ask once for permission to reach devices on your local network: allow it.
- It loads in your browser only.
localhostis your machine. A colleague opening the same URL gets a load error, not your module. To show it to someone else, host the built bundle somewhere the sandbox shell admits and ask us to allow that host. - Authenticate through the SDK, never
localStorage['access_token']. The sandbox shell does not write the token there. React context does not cross a federation boundary either, so inside the remote use the hooks that work there, such asuseSelectedBuildingId(). - Your backend runs on your machine. The shell's policy lets your frontend call
http://localhost:<port>, so a local backend works from the browser. The platform cannot reach it: the sandbox has no outbound internet and no route to your machine, so Copilot cannot call an MCP endpoint on localhost. - Share
i18nextandreact-i18nextboth or neither, and keep theuse-sync-external-store/shimalias from the scaffold. Breaking either fails only inside a shell, where your own CI cannot see it.
When the page is blank
| What you see | Usual cause |
|---|---|
404 on remoteEntry.js, reported as CORS | pnpm dev:remote not running, or you started pnpm dev instead — the dev server emits no remote |
| The remote loads from a different port | strictPort missing; vite moved the port and the registry row still points at 6001 |
| Blank route, no network call for the remote | The module is registered but not licensed — re-run sandbox install, which does both |
| Signed in, but every API call is 401 | The module reads localStorage['access_token']; move it to the platform context |
| A second React crash on mount | The use-sync-external-store/shim alias is missing — see Federation and the shell |