On-demand sandboxes
A sandbox is a Tango Vision tenant of your own: its own PostgreSQL database, a seeded building graph, and a dedicated tv-api stack — at its own subdomain. You develop against it instead of production.
What is isolated, and what is shared
"Isolated" is a word worth spelling out, because the interesting question is what a mistake in your sandbox can reach.
Isolated:
- The database. Each sandbox gets its own database with its own login role, on a dedicated Postgres server that holds no production data. That role cannot open another sandbox's database, nor (tv-platform#822) the server's
postgresdatabase or, where the provisioner keeps its state on the same server, that state database. - The pod. Your sandbox's tv-api is its own Deployment, in an isolated namespace (
dev) separate from the namespace production runs in, with its own Secret holding its own token. - The network path to production. NetworkPolicies stand between that namespace and production. The sandbox pod is also configured with the production MQTT broker off (
WIFI_SENSING_MQTT_ENABLED=false) and platform sign-up off, set as containerenvso it cannot inherit otherwise. - The shell that loads your module. Third-party module code runs only in a sandbox shell bound to your sandbox, never in the production shell — see See your module in the sandbox shell.
Shared:
- The Kubernetes cluster. Sandboxes are namespaces on the same cluster as everything else, not a separate cluster. The quotas below exist because of this.
- One Keycloak realm. Every sandbox authenticates against the single
sandboxesrealm (never the production realm). Separation inside it is meant to be by thetenant_idclaim matching your sandbox'sorganizationId, not by a realm per developer. - The provisioner and its own metadata database, which holds sandbox records and API keys for everybody.
Sign-in is not separated yet (as of 2026-09-26)
Today every sandbox accepts a valid login from the shared sandboxes realm, whichever sandbox the login was issued for: the tenant_id check described above is not enforced yet. A fix that makes each sandbox accept only its own organisation's logins is in progress, and this note goes when it ships. Until then, do not rely on sign-in to keep other sandbox users out of your sandbox, and keep only synthetic data in it.
So: your sandbox's database role cannot read another sandbox's database, and a mistake in it does not reach production. It is not a private cluster, and it is not a security boundary you should test on purpose — that is what the terms ask of you.
Synthetic data only. Put no real company data into a sandbox (tenant lists, floor plans, anything about real people) unless we have agreed it with you in writing. Sandboxes are shared infrastructure, they expire, and they come with no durability guarantee.
No general outbound internet. Sandbox pods cannot make arbitrary outbound calls. One consequence bites early: the sandbox's Copilot cannot reach an external LLM, so AI features that call out to a provider will not work in a sandbox. Develop them against a mock and see Custom LLM endpoint.
Why
- Realism. It's seeded with a parameterized building —
mall,officeoruniversity— with spaces, elements, and telemetry, so your module renders against real-shaped data. - Disposability. Reset it when you've made a mess; it expires automatically.
Create one — from the CLI
The SDK wraps the whole lifecycle, so you never have to hand-roll curl. Sign in once (CLI sign-in) and every sandbox command authenticates itself from your OS keyring:
npx @tv/extension-sdk login
npx @tv/extension-sdk sandbox create \
--name="acme-cafm-dev" \
--type=mall \
--storeys=3 \
--area-sqm=20000 \
--seed-telemetry-days=14 \
--ttl-days=30In CI, or anywhere non-interactive, use a portal-minted API key instead of logging in — an explicit credential always wins over the stored session:
export TV_API_TOKEN=$SANDBOX_API_KEY # tvk_... key from the portal
npx @tv/extension-sdk sandbox listThe CLI targets https://sandbox-api.k8s.tangovision.dev by default; --api=<url> (or TV_SANDBOX_API_URL) points it elsewhere.
| Subcommand | What it does |
|---|---|
create | Provision a sandbox. --dry-run generates the seed locally without calling the service. --type=university is valid since 1.14.0 (requires @tv/extension-sdk 1.14.0 or later): the service and the seed templates have always accepted it, but the CLI's own parser rejected it up to and including 1.13.0. On an older CLI, create a university sandbox through the API below. |
list | List the sandboxes you own |
connect <id> | Print the sandbox record, then its own tv-api URL and administrator token as TV_API_URL=… / TV_API_TOKEN=… settings for server-side code only (a script, a local backend; never browser code). Do not export them in the terminal you use for sandbox commands: list and create read them as the provisioner address and your key, and would talk to your sandbox instead. SDK 1.15.0 printed them as export lines. |
extend <id> --days=N | Extend the TTL. --days is required, an integer 1–7. |
reset <id> | Wipe and re-seed without deleting |
delete <id> | Delete immediately |
install <manifest> | Register your module in your own sandbox and licence it, then print the URL to open. Since 1.14.0 (requires @tv/extension-sdk 1.14.0 or later) — see See your module in the sandbox shell. |
<id> (and install --sandbox=<id>) is the sandbox id, sbx_…: create prints it, and sandbox list shows the id of every sandbox you own. It is not the slug in the sandbox's URL.
Shaping knobs beyond the basics: --spaces-min / --spaces-max (default 10/30), --equipment-density=sparse|realistic|dense, --occupancy-profile=24x7|office-hours|peak-evenings, --ttl-days=N (1–30, default 14), and --seed=<string> for a deterministic build. Add --format=json to any subcommand to script it. create, list and connect print each sandbox's slug and organizationId in their normal output too (SDK 1.15.1 and later; 1.15.0 showed them only with --format=json), and create ends with the line to send us for sandbox logins.
The subdomain is a slug the service derives as <your-org>-<name, first 16 chars>-<6 random chars>, so it is prefixed with your org and never collides with another org's identically-named sandbox:
https://dev-a1b2c3d4e5f6-acme-cafm-dev-k2x7p9.sandbox.k8s.tangovision.devYour org slug is dev-<12 hex characters>: each developer account is an organisation of its own. That is also the boundary for "your" sandboxes and the unit the quotas below are counted against. Two developers who work on one sandbox: see Two developers, one sandbox.
Create one — from the API
curl -X POST https://sandbox-api.k8s.tangovision.dev/api/v1/sandboxes \
-H "Authorization: Bearer $SANDBOX_API_KEY" \
-H "content-type: application/json" \
-d '{
"name": "acme-cafm-dev",
"buildingType": "mall",
"storeys": 3,
"areaSqm": 20000,
"ttlDays": 14
}'Response — 202 Accepted. Provisioning runs in the background (a database, a seeded building graph, and a dedicated tv-api stack take a few minutes at realistic parameters), so the record comes back in provisioning status:
{
"sandboxId": "sbx_...",
"name": "acme-cafm-dev",
"slug": "dev-a1b2c3d4e5f6-acme-cafm-dev-k2x7p9",
"status": "provisioning",
"apiUrl": "https://dev-a1b2c3d4e5f6-acme-cafm-dev-k2x7p9.sandbox.k8s.tangovision.dev",
"consoleUrl": null,
"apiToken": "...",
"organizationId": null,
"buildingId": null,
"createdAt": "2026-05-30T...",
"expiresAt": "2026-06-13T...",
"extensionsUsed": 0,
"extensionsRemaining": 2,
"failureReason": null
}apiToken is the sandbox's own credential — keep it; it authenticates as an administrator of this sandbox's API, and of nothing else. organizationId and buildingId stay empty until provisioning finishes.
consoleUrl is the UI bound to this sandbox, and it is null unless a sandbox shell is deployed. When one is, it is the sandbox host itself: the shell is served at / and the API stays on /api and /socket.io, so the UI and its backend share an origin. A sandbox shell is a separate deployment from the production one on purpose — module code has full trust in the shell that loads it, so your module runs only in a shell bound to your own sandbox.
organizationId is the sandbox's tenant id. Every sandbox shares one Keycloak realm, and a user is meant to sign in to your sandbox only if their tenant_id claim equals this value (not enforced yet: see the note under What is isolated, and what is shared). It is what an operator needs in order to wire a login up, and it is unique per sandbox rather than per sandbox name.
Poll the detail endpoint until status leaves provisioning:
curl -H "Authorization: Bearer $SANDBOX_API_KEY" \
https://sandbox-api.k8s.tangovision.dev/api/v1/sandboxes/sbx_...The five statuses are provisioning, ready, failed, expired and deleted. ready means the sandbox is usable (buildingId and the final apiUrl are filled in); failed carries a failureReason and is terminal. The CLI (sandbox create) polls for you every 5 seconds and exits when the sandbox is ready — or gives up after 15 minutes, at which point provisioning carries on server-side and sandbox list will show you the result.
Lifecycle
| Action | Endpoint | Returns |
|---|---|---|
| List | GET /api/v1/sandboxes | 200 { sandboxes: [...] } |
| Read one | GET /api/v1/sandboxes/:id | 200 — this is the one you poll |
| Create | POST /api/v1/sandboxes | 202, status provisioning |
| Extend TTL | POST /api/v1/sandboxes/:id/extend, body { "days": 7 } | 201 |
| Reset | POST /api/v1/sandboxes/:id/reset | 201, or 409 if not ready |
| Delete | DELETE /api/v1/sandboxes/:id | 204, idempotent |
days on extend is required and must be an integer 1–7; omitting it is a 400. Note that this is a different bound from --ttl-days at create (1–30), so --days=14 is rejected.
What reset does
It drops and recreates the sandbox's database, runs the same migrate-and-seed step as create with the parameters the sandbox was created with, and restarts the sandbox's API onto the new database — so you get a fresh building graph under the same id, URLs and token. The building id changes, because the graph is regenerated. Expect the API to be unavailable for a minute or so while it restarts.
If the re-seed fails, the sandbox moves to failed rather than staying ready with an empty database; delete it and create a new one.
Quotas
To keep the shared cluster healthy:
- 3 concurrently-active sandboxes per org (
provisioningandreadycount;expiredanddeleteddo not) - 2 TTL extensions per sandbox, of up to 7 days each
- 30 days maximum TTL at create — so 44 days is the effective ceiling for one sandbox
Nothing reminds you before a sandbox expires, and an expired sandbox cannot be extended: its database and host are deleted. Note the expires date at create, and extend in time.
modules is not accepted at create time: a non-empty array is a 400 with the code MODULES_NOT_SUPPORTED. It used to be accepted and quietly thrown away, so a sandbox came up without the modules you asked for and nothing said so. Install a module into a sandbox once it is ready, with sandbox install (SDK 1.14.0 or later). The error's own message still says that command is not released; that text is out of date.
Breaching a quota is a 403 carrying a machine-readable code — QUOTA_CONCURRENCY when you are at the concurrent limit, QUOTA_EXTENSIONS when a sandbox has used both extensions. Malformed request bodies are a 400; unknown fields are rejected rather than ignored.
What's inside a fresh sandbox
- An organization + site + building seeded by
@tv/sandbox-seed(a private package; itsBuildingTypeenum is exactlymall | office | university) - Storeys, spaces, elements, and points sized by your parameters
- Up to 30 days of backfilled telemetry (configurable via
seedTelemetryDays) - The standard tv-api schema, so every platform endpoint works
Your module calls the sandbox's tv-api the same way it calls production's: relative /api/… paths, with authorizationHeader() from @tv/extension-sdk/context on each request — see Calling the API from a module. Structurally, the sandbox is production.
For Tango Vision staff: the console
Staff can also create sandboxes from the staff console, which needs a staff sign-in; invited developers use the CLI above. Its Sandboxes page (under the admin nav) takes an API key, then New sandbox:
| Field | Meaning |
|---|---|
| Name | Display name; the subdomain is derived from it |
| Building type | mall, office or university |
| Storeys | 1–50 |
| Area (m²) | Total gross area |
| TTL (days) | 1–30; auto-cleanup after |
The row starts as provisioning and flips to ready once the database is seeded — from under a minute for a small building to a few minutes at realistic parameters.