Your first module
We'll build a minimal module that mounts a page in the Building OS shell and reads the selected building's data from the platform API. Budget: 30 minutes.
The code on this page was checked on a fresh --external scaffold of SDK 1.15.0: pasted in order, it passes pnpm typecheck, pnpm test and pnpm build, so you can copy it as it is.
1. Scaffold
The SDK scaffolds the whole module for you — manifest, Vite config with the federation expose already wired, Shell.tsx, and package.json:
npx @tv/extension-sdk init-module hello \
--name="Hello Module" \
--category=operations \
--external --scope=acme --author="Acme Ltd"
cd tv-module-hello
pnpm installPut your company's npm scope in --scope and its name in --author: the module id becomes @acme/module-hello and the manifest's author Acme Ltd. Without them the module is born as @tv/module-hello, authored by "Tango Vision", and @tv is our scope. SDK 1.15.0 accepts --author although its --help does not list it yet.
--external is the flag for you
Without it the scaffold is built for repositories inside the Tango Vision organisation: the generated ci.yml asks for runs-on: self-hosted and calls composite actions out of the private tangovision/infrastructure repository, so on your own repository the job either fails at start with "repository not found" or queues until GitHub cancels it at 24 h. --external generates CI on ubuntu-latest with pnpm/action-setup@v4 pinned to the platform's pnpm (10.18.2), your own TV_NPM_TOKEN repository secret written to a job-scoped npmrc, and an AGENTS.md without the internal platform-rules block. The code a shell runs is the same either way.
Since 1.14.0 — --external first shipped in @tv/extension-sdk 1.14.0, but scaffold with 1.15.1 or later: earlier versions scaffold projects with known faults, and 1.14.0's does not build at all ("useQuery" is not exported). On 1.13.0 the flag does not exist; upgrade rather than working around it. Known issues by SDK version lists each version's faults and how to fix a module scaffolded by one.
The files it writes (the CLI prints each one as a full path):
module-manifest.json vite.config.ts
src/Shell.tsx package.json
README.md pnpm-workspace.yaml
AGENTS.md CLAUDE.md
CHANGELOG.md src/i18n.ts
src/locales/en.json src/locales/ru.json
index.html src/main.tsx
tsconfig.json .npmrc
.gitignore src/shims/use-sync-external-store-shim.ts
src/locales/catalogues.test.ts
.github/workflows/ci.yml .github/dependabot.ymlThat is SDK 1.15.0's set. It changes as the SDK does, so trust your own run's output over this list. Several of these are load-bearing in ways that are not obvious:
src/shims/use-sync-external-store-shim.tsplus its alias invite.config.tsis what stops your remote bundling a second React and crashing inside the shell. Federation and the shell explains it; do not delete either half.pnpm-workspace.yamlcarries the build-script approvals a fresh install needs. Without it, pnpm 11 refuses to install the scaffold's own tree. It also makes a plainpnpm add <package>stop withERR_PNPM_ADDING_TO_ROOT: add dependencies withpnpm add -w <package>(-D -wfor dev dependencies).AGENTS.md/CLAUDE.mdcarry the platform rules an AI agent working in your repository would otherwise have no way to see..github/workflows/ci.ymlinstalls, validates the manifest, typechecks, tests, builds the real federation remote and checks its./Shellexpose, on GitHub-hosted runners. It cannot run the shell-integration smoke, which lives in a private repository; loading the module in your sandbox shell is that check (step 7).
The scaffold is frontend-only — no backend/, and the generated manifest declares no mcpTools. Add both when you need them; see the manifest reference.
--category is required and must be one of core, operations, engagement, infrastructure, analytics, ai. Use init-manifest if you only want the manifest and already have a frontend.
Prefer to read a real one first?
tv-module-example is the canonical reference implementation — it demonstrates the full contract including Copilot tool federation, HMAC-signed request verification, and heartbeat reporting. Its repository is private, so a copy is provided on request: developers@tango.vision. The module catalog lists the modules running on the platform today.
2. Understand the manifest
The scaffolder writes a valid starting manifest. Here's the shape, with the fields you'll actually edit filled in:
{
"$schema": "./node_modules/@tv/extension-sdk/manifest.schema.json",
"sdkVersion": "1.15.0",
"id": "@acme/module-hello",
"name": "Hello Module",
"description": "Greets the operator and lists the spaces in the active building.",
"version": "1.0.0",
"minCoreVersion": ">=2.0.0",
"category": "operations",
"buildingTypes": ["all"],
"capabilities": { "provides": [], "requires": [] },
"permissions": [
{
"subject": "building.spaces",
"actions": ["read"],
"reason": "Lists the spaces on the module's landing page."
}
],
"events": { "publishes": [], "subscribes": [] },
"mcpTools": [],
"ui": {
"remoteEntry": "./Shell",
"routes": [{ "path": "/hello" }],
"navigation": [
{ "label": "Hello", "icon": "Hand", "path": "/hello", "section": "operations" }
]
},
"lifecycle": {
"healthEndpoint": "/health",
"init": "on_demand",
"dependencies": []
},
"author": { "name": "Acme Ltd" }
}A few fields carry more weight than their size suggests:
id— your own npm scope, not@tv.--scopesets it; without the flag the scaffolder uses@tv/…, which is Tango Vision's own.permissions[].reason— shown verbatim to the admin who installs your module. Write it for them, not for yourself.ui.remoteEntry— must stay./Shell. The shell looks for exactly this name;tv-sdk check-exposesenforces it.lifecycle.init—on_demandlazy-loads your module when its route is hit. Only useon_bootif the shell genuinely can't start without you.
Every field is documented in the manifest reference.
3. Validate it
npx @tv/extension-sdk validate module-manifest.jsonFor the manifest above, SDK 1.15.0 prints:
✓ /…/tv-module-hello/module-manifest.json is a valid module manifest.
id: @acme/module-hello
version: 1.0.0
core: >=2.0.0
⚠ 1 item(s) need a reviewer's sign-off before a cross-organization install:
- permissions.0.subject: "building.spaces" is a kernel-domain subject (building.*) requested by a module outside @tv/ — allowed, but it needs a reviewer's sign-off before a cross-organization install. (reserved_subject_request)The ⚠ note is information, not a failure: a module outside @tv that asks for a building.* permission is reviewed before it is installed in another organisation. The command exits 0.
When a field is wrong, the validator names it and exits 1:
✗ /…/tv-module-hello/module-manifest.json failed validation:
- permissions.0.actions.0: Invalid option: expected one of "read"|"write"|"delete"|"subscribe"|"publish" (invalid_value)It checks the form of a permission subject (lowercase, dot-separated), not whether the subject exists: building.nonsense passes, with the same ⚠ note. Compare your subjects with the permission catalog yourself.
The generated ci.yml already runs this check. If you are adding CI to an existing repository, these are the steps init-module --external generates (SDK 1.15.0, with its comments shortened; 1.15.1 generates the same steps with node-version: '24'). They use no action from a Tango Vision repository, because those repositories are private and a job that references one fails at start:
# .github/workflows/ci.yml, job `checks`, runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '22'
# The platform's pnpm, written out: the action that holds it is private.
- uses: pnpm/action-setup@v4
with:
version: 10.18.2
# A job-scoped npmrc under $RUNNER_TEMP, never ~/.npmrc; the token is
# the repository secret TV_NPM_TOKEN, masked in the log.
- name: Configure @tv registry auth
env:
TV_NPM_TOKEN: ${{ secrets.TV_NPM_TOKEN }}
run: |
if [ -z "$TV_NPM_TOKEN" ]; then
echo "::error::TV_NPM_TOKEN is not set. Add it as a repository secret; @tv/* cannot be installed without it."
exit 1
fi
echo "::add-mask::$TV_NPM_TOKEN"
mkdir -p "$RUNNER_TEMP/tv-registry"
{
echo "registry=https://registry.npmjs.org/"
echo "@tv:registry=https://npm.k8s.tangovision.dev/"
echo "//npm.k8s.tangovision.dev/:_authToken=$TV_NPM_TOKEN"
} > "$RUNNER_TEMP/tv-registry/.npmrc"
echo "NPM_CONFIG_USERCONFIG=$RUNNER_TEMP/tv-registry/.npmrc" >> "$GITHUB_ENV"
# Commit pnpm-lock.yaml: --frozen-lockfile fails without one.
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate the manifest
run: pnpm exec tv-sdk validate module-manifest.json
- name: Typecheck
run: pnpm typecheck
- name: Tests
run: pnpm test
- name: Build federation remote
run: pnpm build
- name: Check the canonical expose
run: pnpm exec tv-sdk check-exposes module-manifest.json
- name: Remove the registry credential
if: always()
run: rm -rf "$RUNNER_TEMP/tv-registry"Add TV_NPM_TOKEN under Settings → Secrets and variables → Actions before the first run. There is no shell-integration smoke step: that action lives in a private repository. The failure class it catches — a module that builds, tests and publishes cleanly and then breaks on mount inside Building OS — is one you observe instead by loading your remote into a sandbox shell: see See your module in the sandbox shell and Federation and the shell.
Editor autocomplete
From SDK 1.1.0 the published manifest.schema.json is usable for editor validation — point $schema at your installed copy and your editor will autocomplete fields and flag mistakes as you type:
{
"$schema": "./node_modules/@tv/extension-sdk/manifest.schema.json",
"sdkVersion": "1.15.0",
...
}tv-sdk init-module writes this line for you. On 1.0.x the schema was generated in output mode and rejected manifests tv-sdk validate accepts, so omit $schema if you're pinned below 1.1.0.
tv-sdk validate remains the authoritative check — it's what CI and the registry run.
4. Build the frontend
Your module exposes a federated Shell. Inside it, follow the building the user picked in the Building OS header, and read the spaces of that building from the platform API. Replace src/Shell.tsx:
// src/Shell.tsx
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { authorizationHeader } from '@tv/extension-sdk/context';
import { useSelectedBuildingId } from '@tv/extension-sdk/react';
import './i18n';
// A list endpoint answers one page: { data, total, limit, offset }.
interface Page<T> {
data: T[];
total: number;
limit: number;
offset: number;
}
async function fetchSpaces(buildingId: string): Promise<Page<unknown>> {
const res = await fetch(`/api/v1/buildings/${encodeURIComponent(buildingId)}/spaces`, {
headers: authorizationHeader(),
});
if (!res.ok) throw new Error(`spaces: HTTP ${res.status}`);
return (await res.json()) as Page<unknown>;
}
export default function Shell() {
const { t } = useTranslation();
const buildingId = useSelectedBuildingId();
const spaces = useQuery({
queryKey: ['spaces', buildingId],
enabled: buildingId !== null,
queryFn: () => fetchSpaces(buildingId!),
});
let status: string;
if (buildingId === null) status = t('shell.selectBuilding');
else if (spaces.isPending) status = t('shell.loading');
else if (spaces.isError) status = t('shell.loadFailed');
else status = t('shell.spaces', { count: spaces.data.total });
return (
<div style={{ padding: '1rem' }}>
<h1>{t('shell.title')}</h1>
<p>{status}</p>
</div>
);
}authorizationHeader() carries the signed-in user's token; call it on every request and use the relative /api/… path. The rules are in Calling the API from a module.
The text comes from the module's catalogues, never from JSX: the scaffold's AGENTS.md asks for that, and its catalogue test fails when the two files do not have the same keys. Replace both catalogues:
{
"shell": {
"title": "Hello",
"selectBuilding": "Select a building to continue.",
"loading": "Loading…",
"loadFailed": "Could not load the spaces.",
"spaces_one": "{{count}} space",
"spaces_other": "{{count}} spaces"
}
}{
"shell": {
"title": "Hello",
"selectBuilding": "TODO: translate. Select a building to continue.",
"loading": "TODO: translate. Loading…",
"loadFailed": "TODO: translate. Could not load the spaces.",
"spaces_one": "TODO: translate. {{count}} space",
"spaces_few": "TODO: translate. {{count}} spaces",
"spaces_many": "TODO: translate. {{count}} spaces"
}
}The count uses i18next plurals: English needs _one and _other, Russian _one, _few and _many (1, 3 and 5 of something take three different forms). The catalogue test compares keys without those suffixes. The Russian version of this page shows the translated catalogue.
useQuery needs '@tanstack/react-query' in the shared list in vite.config.ts. Building OS provides the query client, and an unshared copy never sees it (the comment in the generated config explains why). Change the shared line to:
shared: ['react', 'react-dom', 'react-router', 'react-router-dom', '@tanstack/react-query'],The standalone preview needs a query client of its own. In SDK 1.15.0, src/main.tsx has none, so pnpm dev shows a blank page with No QueryClient set, use QueryClientProvider to set one in the console. Replace src/main.tsx (1.15.1 and later generate an equivalent file):
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PlatformProvider } from '@tv/extension-sdk/react';
import { createMockPlatformContext } from '@tv/extension-sdk/testing';
import Shell from './Shell';
const container = document.getElementById('root');
if (!container) throw new Error('#root is missing from index.html');
// Standalone `pnpm dev` only. Building OS provides the real query client,
// router and platform context, so never mount any of them inside Shell.
const queryClient = new QueryClient();
const platform = createMockPlatformContext();
createRoot(container).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<PlatformProvider value={platform}>
<Shell />
</PlatformProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);Run pnpm dev and open http://localhost:5001. It shows the title and the "select a building" message: standalone, nothing publishes a building selection and there is no signed-in user, so the spaces appear only in your tests (step 6) and in your sandbox shell (step 7). The preview starts in Russian, the platform's default language, which here means the TODO: translate. placeholders. To see English, run localStorage.setItem('tv-platform-locale', 'en') in the browser console and reload.
null means no building is selected — the shell is still loading, or you are running standalone. Render an explicit empty state for it. Never fall back to some other building: several buildings share a name across organizations, and that silently shows one organisation's data under another's label.
Building OS has exactly one building selector, the one in its header. Read it; do not ship a second dropdown of your own. Two selectors visibly disagree the moment a user touches either.
usePlatformContext() does not work inside a federated module
This is the mistake that costs new modules the most time. PlatformContext travels through React Context, and React Context does not cross a Module Federation boundary unless @tv/extension-sdk is a shared singleton in both the host and every remote — which it is not today. So inside your Shell, usePlatformContext() and useBuilding() throw, even though the shell really did render <PlatformProvider> around you.
useSelectedBuildingId() and useBuildingSelection() use global channels instead and work in both places. Use usePlatformContext() only in code that runs inside the shell bundle itself — and in your own tests, where you render the provider yourself.
5. (Optional) Declare a backend capability
If your module has a NestJS backend:
import { Controller, Get } from '@nestjs/common';
import { ModuleCapability, RequiresLicense } from '@tv/extension-sdk/nestjs';
@ModuleCapability({ id: 'hello.greeting', version: '1.0.0' })
@Controller('api/v1/buildings/:buildingId/hello')
export class HelloController {
@RequiresLicense('@acme/module-hello')
@Get()
greet() {
return { message: 'hello' };
}
}@RequiresLicense() does not enforce anything yet
It only records which module's licence the route needs, as metadata; nothing checks it when a request arrives, so the route answers any caller that reaches it. Authenticate and authorise your routes yourself, and do not rely on this decorator for access control.
For the rest of it — verifying the caller's token, CORS for your sandbox shell, and calling tv-api with that same token — see Add a backend.
6. Test it
The scaffold's tests run in Node with no DOM, and it has no testing library. Add both (-w is required in the scaffold, see step 1):
pnpm add -D -w @testing-library/react @testing-library/dom @testing-library/jest-dom jsdomThen add src/Shell.test.tsx:
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BUILDING_SELECTION_STORAGE_KEY } from '@tv/extension-sdk/context';
import i18n from './i18n';
import Shell from './Shell';
// What the Building OS header publishes when the user picks a building.
// useSelectedBuildingId() reads this; it does not read a mock PlatformContext.
function selectBuilding(id: string, name: string) {
localStorage.setItem(
BUILDING_SELECTION_STORAGE_KEY,
JSON.stringify({ state: { selectedBuildingId: id, buildings: [{ id, name }] } }),
);
}
// Building OS provides the query client; a test provides its own.
function renderShell() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<Shell />
</QueryClientProvider>,
);
}
beforeAll(async () => {
await i18n.changeLanguage('en');
});
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
test('asks for a building when none is selected', () => {
renderShell();
expect(screen.getByText('Select a building to continue.')).toBeInTheDocument();
});
test('shows how many spaces the selected building has', async () => {
selectBuilding('b1', 'Demo Mall');
const fetchMock = vi.fn(async () =>
Response.json({ data: [], total: 3, limit: 50, offset: 0 }),
);
vi.stubGlobal('fetch', fetchMock);
renderShell();
expect(await screen.findByText('3 spaces')).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledWith('/api/v1/buildings/b1/spaces', expect.anything());
});pnpm testFour things make this test match what happens in the shell:
// @vitest-environment jsdom, on the first line, gives this file a DOM.- The building selection is seeded the way the Building OS header publishes it.
useSelectedBuildingId()reads that, not aPlatformProvider. fetchis mocked. Nothing in a test serves the relative/api/...path: without the mock, Node'sfetchthrowsTypeError: Failed to parse URL from /api/v1/buildings/b1/spacesand the Shell shows its load error. (Outside a shellauthorizationHeader()returns{}, so the call would carry no token either.)- The test renders its own
QueryClientProvider, as Building OS does. Retries are off, so a failing call shows at once.
It also switches i18next to English: the platform's default language is Russian. More patterns, including following a change of building and a failing call, are in Testing.
7. Run it in a sandbox
Before anyone else sees it, run the module in your sandbox shell, against a real (isolated) building. If your team already shares a sandbox, use that one rather than creating another: Two developers, one sandbox. Otherwise:
npx @tv/extension-sdk sandbox create \
--name=hello-dev --type=office --storeys=2 --area-sqm=5000 --ttl-days=30Then serve the remote, install it into the sandbox and open it: steps 3 to 6 of From zero to your module in your sandbox. On-demand sandboxes covers the whole lifecycle.
What you just learned
tv-sdk init-module --external --scope --authorgives you the known-good shape; don't hand-roll it.- The manifest is your contract;
validate+check-exposesbelong in CI. - A module reaches platform state through SDK calls:
useSelectedBuildingId()for the building,authorizationHeader()on every request to a relative/api/…path.usePlatformContext()is for tests. - Tests seed the building selection, mock
fetchand provide a query client, as Building OS does.
Next: read the PlatformContext, which covers calling the API from a module, and Testing.