Skip to content

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:

bash
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:

json
{
  "$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-exposes enforces it.
  • lifecycle.initon_demand lazy-loads your module when its route is hit. Only use on_boot if the shell genuinely can't start without you.

Every field is documented in the manifest reference.

3. Validate it

bash
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.0

If 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:

yaml
# .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.ts

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:

json
{
  "$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:

tsx
// 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:

ts
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

tsx
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:

bash
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-module gives you the known-good shape; don't hand-roll it.
  • The manifest is your contract; validate + check-exposes belong in CI.
  • The PlatformContext is your only door to platform state.
  • The mock context makes tests match production.

Next: dig into the PlatformContext and events.

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