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 first shipped in @tv/extension-sdk 1.14.0. Use 1.15.1 or later: earlier versions have bugs in the commands on this page, and a module scaffolded by 1.14.0 does not build. A module scaffolded by 1.13.0 or earlier has neither script — rescaffold, or add them by hand as shown in step 1. What each older version gets wrong: Known issues by SDK version.
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: the dev port answers /assets/remoteEntry.js with the app's HTML page, so a shell pointed at it shows Module failed to load, and the browser console reports a module script with the MIME type "text/html". Two terminals, two commands, started in this order:
# 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 --strictPortUntil the first build has finished, serve:remote stops with The directory "dist" does not exist. Did you build your project?; start it again once dev:remote has built. Your remote is then at:
http://localhost:6001/assets/remoteEntry.jsRegister it with localhost, not 127.0.0.1: on macOS the preview server answers on localhost only.
6001 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. The id is the sandbox id, sbx_…, which sandbox create prints and npx @tv/extension-sdk sandbox list shows for every sandbox you own — not the slug in the sandbox's URL.
With --sandbox, keep TV_API_TOKEN set to your portal key (or rely on login) and pass no --token. The lookup goes to the service that manages sandboxes with that credential, and the two calls above use the sandbox's own token from the record. Do not run the export lines sandbox connect prints in the same terminal: TV_API_URL then sends the lookup to your sandbox, which answers 404.
Without --sandbox, 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). A 401 or 403 from this command therefore means a wrong credential: check it using When something is wrong. SDK 1.15.0 and earlier print an older note after either (This is expected today: … ask for the module to be registered for you), written before that change; ignore it.
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. There are two ways in.
Invited developers. The Tango Vision team creates an account for you in that realm, with the tenant_id claim set to your sandbox's organizationId. Send your sandbox's slug (the subdomain of its apiUrl) to the contact named in your welcome message, by email or Telegram; developers@tango.vision reaches the same people, and we answer the same business day, Moscow time. The account starts with a temporary password, and you are asked to set your own at the first sign-in.
Forgot your sandbox password? The sandbox sign-in page has no reset link. Write to your contact, and we set a new temporary password for you.
Tango Vision staff. Employees sign in with their company account through the Tango Vision staff button on the sign-in page. That works only for a member of the /sandbox-developers group whose sandbox account an operator has linked to their company identity. Nothing is created or linked automatically, and a staff sandbox account has no password of its own.
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
getAccessToken()orauthorizationHeader()in@tv/extension-sdk/context, not fromlocalStorage['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's token functions are unaffected. See Calling the API from a module. - 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. Registerlocalhost: on macOSserve:remotedoes not answer on127.0.0.1. - 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. A colleague with a login to your sandbox can serve the module from their own machine instead (see Two developers, one sandbox). To show it to anyone 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, andPlatformContext.apiis out of reach because React context does not cross a federation boundary. CallauthorizationHeader()from@tv/extension-sdk/contexton every request to the relative/api/…path. - 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. See Add a backend for verifying the caller and setting CORS for this origin. - 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 |
|---|---|
Module failed to load, and the console shows the request for remoteEntry.js failing | pnpm serve:remote is not running, or the registry row points at 127.0.0.1 (macOS answers on localhost only) |
| Module failed to load, and the console reports a module script with the MIME type "text/html" | The registry row points at the pnpm dev port; the dev server emits no remote. Re-install with --remote-entry=http://localhost:6001/assets/remoteEntry.js |
| The remote loads from a different port | strictPort missing; vite moved the port and the registry row still points at 6001 |
| Module not in the registry | Not installed, the URL slug does not match the part of the manifest id after module-, or your sandbox login has no tenant_id yet (the sidebar then also says Module registry unreachable — showing cached list). Re-run sandbox install and reload; if the sidebar note stays, tell your contact |
| Signed in, but every API call is 401 | The module reads localStorage['access_token']; send authorizationHeader() from @tv/extension-sdk/context on each request instead |
| A second React crash on mount | The use-sync-external-store/shim alias is missing — see Federation and the shell |