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 runs | What 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 bundle | the PlatformContext hooks below |
| Your tests | the 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
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
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
themeon 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 readingctx.themeat 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 tosubscribePlatformEvent()from@tv/extension-sdk/contextoutside 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.apikeeps 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.
// /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.
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:
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 oneOutside 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):
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 (ornull) 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/apito that sandbox's own tv-api. Do not hardcode a tv-api host. - List endpoints return a page, not an array.
/spacesand/elementsanswer{ data, total, limit, offset }(/elementscomputestotalonly on the first page);/storeysanswers a plain array. The typed client,createBuildingGraphClient()on@tv/extension-sdk/graph, models these asPage<T>andElementsPage<T>. - Outside the shell there is no token. Under
pnpm dev,getAccessToken()isnullandauthorizationHeader()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):
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