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

Start from the reference module — it's the canonical, known-good shape:

bash
# Clone the platform repo (or just the reference module directory)
git clone git@github.com:tangovision/tv-platform.git
cp -r tv-platform/modules/tv-module-example my-module
cd my-module

2. Write the manifest

module-manifest.json:

json
{
  "$schema": "https://npm.k8s.tangovision.dev/@tv/extension-sdk/1.0.0/manifest.schema.json",
  "id": "@acme/module-hello",
  "name": "Hello Module",
  "version": "1.0.0",
  "minCoreVersion": ">=2.0.0",
  "category": "operations",
  "permissions": [
    { "subject": "building.spaces", "actions": ["read"] }
  ],
  "events": {
    "publishes": [],
    "subscribes": []
  },
  "ui": {
    "routes": [{ "path": "/hello" }],
    "navigation": [
      { "label": "Hello", "path": "/hello", "section": "operations" }
    ]
  }
}

3. Validate it

bash
npx tv-sdk validate module-manifest.json
✓ Manifest is valid (version 1.0.0)

If it's wrong, the validator tells you the exact field:

✗ permissions[0].subject: must be one of [building.spaces, building.elements, ...]

Wire this into your CI so a broken manifest never leaves your machine:

yaml
# .github/workflows/manifest.yml
- run: npx @tv/extension-sdk validate module-manifest.json

4. Build the frontend

Your module exposes a federated Shell. Inside it, consume the platform context:

tsx
// frontend/src/App.tsx
import { usePlatformContext, useBuilding } from '@tv/extension-sdk/react';
import { useQuery } from '@tanstack/react-query';

export default function App() {
  const { api } = usePlatformContext();
  const building = useBuilding();

  const { data: spaces } = useQuery({
    queryKey: ['spaces', building.id],
    queryFn: () => api.get(`/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

ts
import { createMockPlatformContext } from '@tv/extension-sdk/testing';
import { PlatformProvider } from '@tv/extension-sdk/react';
import { render } from '@testing-library/react';
import App from './App';

const ctx = createMockPlatformContext({
  user: { ...defaultUser, roles: ['manager'] },
});

render(
  <PlatformProvider value={ctx}>
    <App />
  </PlatformProvider>,
);

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:

Spin up a sandbox

What you just learned

  • The manifest is your contract; validate it in CI.
  • The PlatformContext is your only door to platform state.
  • The mock context makes tests match production.
  • The reference module is the shape to copy.

Next: dig into the PlatformContext and events.

Built on the Tango Vision platform.