Skip to content

The PlatformContext ​

PlatformContext is the object the shell injects to describe the platform to the code rendering inside it: who is signed in, which organization, which building, locale and theme, and two typed clients.

Read this before you use it in a federated module

PlatformContext travels through React Context, and React Context does not cross a Module Federation boundary — it would only do so if @tv/extension-sdk were a shared singleton in the host and in every remote, which it is not today.

So inside a federated module, usePlatformContext(), useBuilding(), useCurrentUser() and useOptionalBuilding() throw, even though Building OS really did render <PlatformProvider> around your Shell. Nothing warns you at build time; you find out on mount.

Where your code runsWhat to use
A federated module (your ./Shell and everything under it)useSelectedBuildingId(), useBuildingSelection() from @tv/extension-sdk/react; readBuildingSelection(), subscribeBuildingSelection() from @tv/extension-sdk/context outside React; getAccessToken() / authorizationHeader() from @tv/extension-sdk/context for your own API calls (below); createBuildingGraphClient() from @tv/extension-sdk/graph for graph reads and writes, over createPlatformApiClient() from @tv/extension-sdk/context; usePlatformEvent() from @tv/extension-sdk/react for platform events (Events); the last two need SDK 1.18.0 or later
Code inside the shell bundlethe PlatformContext hooks below
Your teststhe hooks below — you render <PlatformProvider> yourself, so the boundary does not exist

The rest of this page describes the context object itself. It is accurate for in-shell code and for tests; treat the hook examples as belonging to those two places.

The shape ​

ts
interface PlatformContext {
  user: PlatformUser;                  // who's signed in + their realm roles
  organization: PlatformOrganization;  // the organization + its licensed modules
  building: PlatformBuilding | null;   // active building — null at org level
  locale: string;                      // "en", "ru", "hi"
  theme: 'light' | 'dark';
  api: PlatformApiClient;              // pre-authenticated, organization-scoped HTTP client
  eventBus: EventBusClient;            // publish / subscribe to platform events
}

Note building is nullable — the user can be at the org level with no building selected. Reach for useBuilding() when your route genuinely requires one and useOptionalBuilding() when it doesn't.

The surface is deliberately narrow. Identity, organization, building selection, locale/theme, and two typed clients — that's the whole contract. Anything you reach for beyond it is outside what the platform supports and can change without notice.

Hooks ​

tsx
import {
  usePlatformContext,
  useBuilding,
  useCurrentUser,
  useOptionalBuilding,
} from '@tv/extension-sdk/react';

function MyComponent() {
  const ctx = usePlatformContext();     // the whole context; throws outside a provider
  const building = useBuilding();        // throws if no building is selected
  const user = useCurrentUser();         // the signed-in user
  const maybe = useOptionalBuilding();   // null instead of throwing
}

Re-renders — what subscribing costs you ​

PlatformContext is a real React context, so the standard worry applies: every consumer re-renders when the provider's value changes. Here that worry is bounded by construction:

  • The value's identity is stable — the shell holds one context object and replaces it only when the signed-in user, the selected building, the locale, or the theme changes. All are rare, user-initiated moments; the first three legitimately invalidate everything a module renders anyway.
  • A theme flip is a shallow patch, not a rebuild. The shell patches theme on the context object and keeps everything else — so consumers re-render once with the new value while connections stay up. Modules that color via CSS variables follow the document theme without reading ctx.theme at all; read it only for JS-drawn surfaces (canvas, Three.js materials).
  • High-frequency data never flows through the context value. Telemetry and platform events arrive in the handler you pass to usePlatformEvent() from @tv/extension-sdk/react, or to subscribePlatformEvent() from @tv/extension-sdk/context outside React (Events) — a burst of events re-renders only the components whose own state you update in the handler, not every context consumer.
  • usePlatformEvent() takes no dependency list. It always calls the latest handler you passed and resubscribes only when the event type changes, so an inline handler doesn't churn.
  • api keeps its identity across theme patches and is replaced only on a full rebuild, so listing it in hook dependencies is correct and doesn't churn.

If you profile a re-render storm in your module, the cause is your own state management downstream of a handler — not the context.

The api client ​

ctx.api is an HTTP client that's already carrying the user's auth and scoped to the active organization. You never see a token.

In a federated module ctx.api is out of reach like the rest of the context. There, call the API yourself as shown in Calling the API from a module.

ts
// /spaces answers a page: { data, total, limit, offset }
const spaces = await ctx.api.get<{ data: Space[]; total: number }>(`/api/v1/buildings/${building.id}/spaces`);
await ctx.api.post(`/api/v1/buildings/${building.id}/faults`, dto);

It exposes get / post / put / patch / delete, each taking an optional { headers, query, signal }.

Why this matters: the same component works in your sandbox and in a customer's production environment, because the only thing that changes between them — the auth + base URL — is supplied by the platform, not your code.

Reading organization data ​

ctx.organization.activeModuleIds tells you which modules the organisation is licensed for. Use it to degrade gracefully rather than to gate security: a client-side check is a convenience, not a boundary. A boundary belongs on the server, and it is yours to build. @RequiresLicense() from @tv/extension-sdk/nestjs only records, as metadata, which licence a route needs; nothing checks it when a request arrives.

tsx
const { organization } = usePlatformContext();
const hasCafm = organization.activeModuleIds?.includes('@tv/module-cafm');

Following the building selection from a module ​

Building OS has exactly one building selector — the one in its header — and it reaches federated modules through a global channel rather than React Context, so these work on both sides of the boundary:

tsx
import { useSelectedBuildingId, useBuildingSelection } from '@tv/extension-sdk/react';

const buildingId = useSelectedBuildingId();
// null = nothing selected yet: the shell is still loading, or you're standalone.

const { buildingId, buildingIds, isAllBuildings, buildings } = useBuildingSelection();
// when the user has several buildings selected rather than one

Outside React, readBuildingSelection() and subscribeBuildingSelection() from @tv/extension-sdk/context do the same job with no framework dependency.

Two rules that are not stylistic:

  • Render an explicit empty state for null. Never substitute another building as a fallback — buildings share names across organizations, so that silently shows one organization's data under another organization's label.
  • Do not ship a second building selector. The shell's groups buildings by owning organization, which a module-local list of bare names cannot do, and two selectors visibly disagree the moment a user touches either.

Calling the API from a module ​

A module authenticates its own requests with the signed-in user's token, which the shell hands out through @tv/extension-sdk/context (SDK 1.15.0 and later):

ts
import { authorizationHeader } from '@tv/extension-sdk/context';

async function fetchSpaces(buildingId: string) {
  const res = await fetch(`/api/v1/buildings/${encodeURIComponent(buildingId)}/spaces`, {
    headers: authorizationHeader(), // { Authorization: 'Bearer …' }, or {} when signed out
  });
  if (!res.ok) throw new Error(`spaces: HTTP ${res.status}`);
  return (await res.json()) as { data: unknown[]; total: number; limit: number; offset: number };
}
  • Call it per request, never cache the token. The shell renews it silently, so a stored copy goes stale. getAccessToken() returns the token itself (or null) when a header is not what you need.
  • Use the relative /api/… path. It is the same origin as the shell everywhere: in production the shell's web server forwards /api/ to tv-api, and in a sandbox the sandbox host routes /api to that sandbox's own tv-api. Do not hardcode a tv-api host.
  • List endpoints return a page, not an array. /spaces and /elements answer { data, total, limit, offset } (/elements computes total only on the first page); /storeys answers a plain array. The typed client, createBuildingGraphClient() on @tv/extension-sdk/graph, models these as Page<T> and ElementsPage<T>.
  • Outside the shell there is no token. Under pnpm dev, getAccessToken() is null and authorizationHeader() is {}, so real calls get 401; mock them or run the module in your sandbox shell.
  • Debugging a 401: resolveAccessToken() returns { token, source }. source: 'platform' means the shell's own accessor supplied it; 'oidc-storage' is the fallback for shells that predate that accessor.
  • Never read localStorage['access_token'] or the OIDC library's storage directly. The first is a legacy copy that is going away and is already off in sandbox shells; the second is an internal format.

From SDK 1.18.0, createPlatformApiClient() does this for you, and the typed graph client runs on top of it. createPlatformApiClient() and PlatformApiError are @experimental: the options and the error's fields may change, while the PlatformApiClient you get back is the stable contract (Stability tiers):

ts
import { createPlatformApiClient } from '@tv/extension-sdk/context';
import { createBuildingGraphClient } from '@tv/extension-sdk/graph';

const api = createPlatformApiClient();        // the token per request, the relative /api path
const graph = createBuildingGraphClient(api);

const building = await graph.buildings.get(buildingId);            // storeys, counts, type
const spaces = await graph.spaces.list(buildingId, { limit: 50 });  // { data, total, limit, offset }

Any other endpoint is api.get<T>('/api/v1/…', { query }). A non-2xx answer throws PlatformApiError, which carries status and tv-api's error body. The response types follow tv-api's OpenAPI document, which the SDK ships as openapi.snapshot.json.

Don't reach around it ​

If you find yourself parsing the shell's localStorage keys by hand, building Keycloak URLs, or hardcoding https://tv-api…, stop. Those paths don't survive moving between organizations, and they're outside what the platform supports — when the shell changes a storage key, the SDK absorbs it and a hand-rolled parser breaks silently.

Every one of those has a supported call: the building-selection API above, getAccessToken() on @tv/extension-sdk/context for authentication, resolveLocale() on @tv/extension-sdk/i18n for language, and createBuildingGraphClient() on @tv/extension-sdk/graph for the building graph.

→ Next: Federation and the shell

Built on the Tango Vision platform. Questions? developers@tango.vision