Your first module
We'll build a minimal module that mounts a page in the Building OS shell and reads data through the platform context. Budget: 30 minutes.
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✓ Scaffolded module at ./tv-module-hello
Files written:
module-manifest.json
vite.config.ts
src/Shell.tsx
package.json--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?
modules/tv-module-example is the canonical reference implementation — it demonstrates the full contract including Copilot tool federation, HMAC-signed request verification, and heartbeat reporting. The module catalog lists the 30 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.1.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": []
}
}A few fields carry more weight than their size suggests:
id— your own npm scope, not@tv. The scaffolder defaults to@tv/…; change it.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.json✓ module-manifest.json is a valid module manifest.
id: @acme/module-hello
version: 1.0.0
core: >=2.0.0If it's wrong, the validator names the exact field:
✗ permissions[0].subject: must be one of [building.spaces, building.elements, ...]Wire both checks into CI so a broken contract never leaves your machine:
# .github/workflows/manifest.yml
- run: npx @tv/extension-sdk validate module-manifest.json
- run: npx @tv/extension-sdk check-exposes module-manifest.json --config=./vite.config.tsEditor 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.1.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, consume the platform context:
// src/Shell.tsx
import { usePlatformContext, useBuilding } from '@tv/extension-sdk/react';
import { useQuery } from '@tanstack/react-query';
export default function Shell() {
const { api } = usePlatformContext();
const building = useBuilding();
const { data: spaces } = useQuery({
queryKey: ['spaces', building.id],
queryFn: () => api.get<Space[]>(`/api/v1/buildings/${building.id}/spaces`),
});
return (
<div>
<h1>Hello from {building.name}</h1>
<p>{spaces?.length ?? 0} spaces</p>
</div>
);
}No tokens, no URLs you have to assemble. The api client is pre-authenticated and scoped to the active tenant.
5. (Optional) Declare a backend capability
If your module has a NestJS backend:
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' };
}
}6. Test against the mock context
import { createMockPlatformContext } from '@tv/extension-sdk/testing';
import { PlatformProvider } from '@tv/extension-sdk/react';
import { render, screen } from '@testing-library/react';
import Shell from './Shell';
const ctx = createMockPlatformContext({
building: {
id: 'b1',
slug: 'demo-mall',
name: 'Demo Mall',
type: 'mall',
timezone: 'Europe/Moscow',
},
});
render(
<PlatformProvider value={ctx}>
<Shell />
</PlatformProvider>,
);
expect(screen.getByText(/Demo Mall/)).toBeInTheDocument();You test against the exact same context shape production uses — just filled with fake data. No "works on my machine, breaks in prod" surprises.
7. Run it in a sandbox
Before you publish, exercise it against a real (isolated) building:
npx @tv/extension-sdk sandbox create \
--name="hello-dev" --type=mall --storeys=3 --area-sqm=20000→ Spin up a sandbox for the full lifecycle.
What you just learned
tv-sdk init-modulegives you the known-good shape; don't hand-roll it.- The manifest is your contract;
validate+check-exposesbelong in CI. - The
PlatformContextis your only door to platform state. - The mock context makes tests match production.
Next: dig into the PlatformContext and events.