Manifest reference
Every module declares a module-manifest.json at its root. tv-sdk validate checks it against the Zod schema in @tv/extension-sdk/manifest — that is the authoritative gate, the same one CI and the registry run.
Validate
npx @tv/extension-sdk validate module-manifest.json
# exit 0 valid, 1 invalid, 2 usage errormanifest.schema.json for editor validation
The package also ships manifest.schema.json. From 1.1.0 it is generated in input mode — describing what you are allowed to write — so it agrees with tv-sdk validate and is safe to wire into your editor:
"$schema": "./node_modules/@tv/extension-sdk/manifest.schema.json"On 1.0.x it was emitted in output mode, marking every defaulted field required; it rejected 30 of the 31 modules then shipping, so leave $schema out if you're pinned below 1.1.0.
Gate CI on tv-sdk validate, not on the JSON Schema — the validator is the contract the registry enforces, and the schema is generated from it.
Top-level fields
Exactly five fields are required: id, name, version, minCoreVersion and category. Everything else either has a default or is genuinely optional.
| Field | Required | Description |
|---|---|---|
id | yes | @vendor/module-name — globally unique. Use your own scope, not @tv. |
name | yes | Human-readable display name shown in the console |
version | yes | Semver of your module |
minCoreVersion | yes | Minimum tv-api version, e.g. >=2.0.0 |
category | yes | core / operations / engagement / infrastructure / analytics / ai |
sdkVersion | defaulted | SDK version you authored against. The scaffolder stamps the real one; the schema's own default is the stale 0.2.0, so set it. The platform rejects manifests targeting a newer SDK than it runs. |
buildingTypes | defaulted | ["all"] or specific slugs like ["mall","office"] — see below |
capabilities | defaulted | provides + requires — named capability contracts between modules |
permissions | defaulted | Data domains you read/write (see below) |
events | defaulted | publishes + subscribes arrays |
mcpTools | defaulted | Copilot/MCP tools your module contributes. [] if none. |
lifecycle | defaulted | healthEndpoint, init, dependencies |
nameLocales | no | name per language, e.g. { "pt-BR": "Manutenção" } (SDK 1.18.0+) — see UI |
description | no | One-liner shown in the catalog |
maxCoreVersion | no | Upper bound, if you know you break above one |
mcpEndpoint | no | URL the platform calls for your MCP tools — see below |
ui | when you have UI | routes, navigation, dashboard widgets |
author | no | name, email, url |
tv-sdk validate accepts a manifest that omits every defaulted field — but declare them explicitly anyway. It's the difference between "I have no events" and "I forgot to think about events", and a reviewer can't tell those apart.
Unknown keys are rejected, not ignored
Every object in the schema is strict. A typo'd or invented field fails validation rather than being quietly dropped — which is the behaviour you want, and the most common first-time surprise.
Permissions
"permissions": [
{
"subject": "building.spaces",
"actions": ["read"],
"reason": "Lists spaces on the module's landing page."
}
]reason is optional to the validator and mandatory in practice — it's shown verbatim to the admin approving your install.
The catalog
Subjects and actions come from tv-api's permission registry, shipped with the SDK as permissions.snapshot.json. The full list — every subject, every action, and the roles each action is granted to — is rendered from that same snapshot on the Permission catalog page, so it cannot drift from what the platform enforces. That page also lists the reserved first-party-only prefixes.
Read it from the package directly if you'd rather not leave the terminal:
cat node_modules/@tv/extension-sdk/permissions.snapshot.json | jq '.subjects[].subject'Building types
buildingTypes gates which buildings your module is offered for. The schema validates the shape of a slug; tv-api's building_types registry decides which slugs exist, and POST /api/v1/registry/modules/ingest rejects a manifest naming one that doesn't, listing the valid values back to you.
That check arrived in SDK 1.10.0 for a reason: before it, a typo ingested happily and gated the module to nothing at all — silently, and permanently.
UI
"ui": {
"remoteEntry": "./Shell",
"routes": [{ "path": "/cafm", "requiresLicense": true, "exact": false }],
"navigation": [
{ "label": "CAFM", "labelLocales": { "pt-BR": "Manutenção" }, "icon": "Wrench", "path": "/cafm", "section": "operations", "order": 100, "roles": [] }
],
"dashboardWidgets": [
{ "id": "open-work-orders", "title": "Open work orders", "remoteComponent": "./OpenWorkOrders", "defaultSize": "md", "defaultRoles": [] }
],
"defaultLandingByRole": { "technician": "/cafm/my-work" }
}routes are where your federated Shell mounts; exact (default false) controls whether the path matches as a prefix. navigation is what appears in the Building OS sidebar; section is one of operations, engagement, infrastructure, analytics, admin.
navigation[].roles is a role-slug whitelist — empty or absent means "visible to anyone whose org is licensed for the module". It is a visibility filter, not an access control; the server enforces access regardless.
navigation[].labelLocales translates the sidebar label, keyed by language tag (ru, pt-BR). Building OS shows the entry for the user's language, then for its base language (pt for pt-BR), then label. A module outside Building OS cannot add to the shell's own translations, so this is where its label's translations go. The top-level nameLocales does the same for name, where the shell names the module rather than one of its entries. Both need @tv/extension-sdk 1.18.0 or later: earlier versions reject them with Unrecognized key.
dashboardWidgets contribute tiles to the shell's dashboard. id matches ^[a-z][a-z0-9-]*$, remoteComponent must start ./ and be a federation expose of its own, and defaultSize is sm (one column), md (two, the default) or lg (full width).
defaultLandingByRole maps a role slug to a path: when a user with that role opens the shell, / redirects there instead of to the dashboard.
remoteEntry must be ./Shell — the shell resolves exactly that name. Enforce it in CI:
npx @tv/extension-sdk check-exposes module-manifest.json --config=./vite.config.tsLifecycle
"lifecycle": {
"healthEndpoint": "/health",
"init": "on_demand",
"dependencies": []
}init: "on_demand" lazy-loads your module when its route is first hit; on_boot starts it with the shell. dependencies lists module ids that must reach HEALTHY before yours starts — keep it empty unless you genuinely can't function without another module.
Copilot (MCP) tools
"mcpTools": [
{
"name": "cafm_list_work_orders",
"description": "List work orders for a building, optionally filtered by status.",
"permissions": ["cafm.workorders:read"],
"parameters": {
"type": "object",
"properties": { "status": { "type": "string" } }
}
}
],
"mcpEndpoint": "http://cafm-backend.prod.svc.cluster.local/api/mcp"name matches ^[a-z][a-z0-9_]*$; the convention is {moduleSlug}_{verb}_{noun}. Two tools may not share a name — Copilot's tool catalog is one flat namespace, and the validator rejects a duplicate.
description is what the model reads when deciding whether to call your tool, so write it for the model. parameters is free-form JSON Schema, passed to the LLM verbatim and not validated by the platform. permissions are "<subject>:<action>" strings and are the authorization layer on top of whatever authenticated the call.
mcpEndpoint — the field most often wrong
tv-api calls POST {mcpEndpoint}/tools/{toolName} with { arguments, context }, where context is { userId, organizationId, buildingId, roles }, and expects 200 { result } or 4xx/5xx { error: { code, message } }.
Once your module runs in the platform cluster, the host must be the deployed Service name in namespace prod, with no port — and it must not be derived from your module slug. A 2026-08 audit found the wrong shape in 23 of 30 manifests, advertising 63 tools whose calls died at DNS resolution, silently. Remember your global prefix too: with setGlobalPrefix('api'), a @Controller('mcp/tools') serves at /api/mcp/tools/…, so the endpoint ends /api/mcp.
That rule describes a module Tango Vision has deployed — a first-party module from day one, a partner module from promotion onward (see "Partner modules" below). Before promotion, mcpEndpoint can point wherever you're serving POST /tools/:toolName yourself; nothing calls it yet. Omitting mcpEndpoint entirely is also fine at any stage — the tools then resolve to a structured "not executable" stub rather than an error.
Authenticating your MCP endpoint
Every MCP endpoint must be guarded, and exactly two credential types are accepted. Unauthenticated requests get 401.
- An HMAC signature, for service-to-service calls from Copilot. Use
McpSignatureGuardfrom@tv/extension-sdk/nestjs. It checksX-TV-Platform-Timestamp(rejected beyond 5 minutes old) andX-TV-Platform-Signature, an HMAC-SHA256 over{timestamp}.{raw-body}, under a secret only Tango Vision and your module hold. - A realm JWT, and only where a user-initiated caller actually exists — for example, a module frontend calling a sibling module's MCP tool with the signed-in user's own session. Verify it against the Keycloak JWKS — RS256 only, checking signature and issuer — and rewrite the MCP
contextfrom the verified token. Client-supplied identity is never trusted.
McpSignatureGuard refuses every request in production when no secret is set
Starting with the @tv/extension-sdk release that contains this change (versioned as 1.17.0 in the SDK changelog and first published in 1.18.0 on 2026-09-28; every release since carries it), McpSignatureGuard no longer admits unsigned traffic in production. With NODE_ENV=production and neither TV_PLATFORM_SIGNING_SECRET nor the per-module TV_PLATFORM_SIGNING_SECRET_<SLUG> set, the guard answers every request 401 MCP_SIGNING_SECRET_MISSING and the process keeps running — /health stays green, only the MCP route refuses. The first refusal is logged once at error level and names the variable to set.
Any other NODE_ENV (including unset) still accepts the unsigned request with a warning, to match tv-api's own behaviour when its executor has no key either. A configured key is unchanged in either branch — what a correctly configured module accepts or refuses is identical, so this is not a wire change for callers that hold a key.
Before deploying a module on SDK 1.18.0 or later into a production cluster, declare a signing key by name: TV_PLATFORM_SIGNING_SECRET_<SLUG> for your own module (Tango Vision issues this at promotion time for partner modules; there is no self-service way to obtain one earlier), or the shared TV_PLATFORM_SIGNING_SECRET if your module mounts the platform key. Without it, your endpoint refuses its only caller. Local development against NODE_ENV unset or development is unaffected.
First-party (Tango Vision) modules
A first-party module gets its own signing secret at deployment and is called by Copilot from day one. Guard POST /tools/:toolName with McpSignatureGuard, constructed with your module's registry slug:
// mcp.controller.ts
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';
import { McpSignatureGuard } from '@tv/extension-sdk/nestjs';
interface McpCallContext {
userId: string;
organizationId: string;
buildingId?: string;
roles: string[];
}
interface McpToolRequest {
arguments: Record<string, unknown>;
context: McpCallContext;
}
// 'cafm' is this module's registry slug — the same string tv-api sends as
// `moduleSlug`, and the suffix of TV_PLATFORM_SIGNING_SECRET_CAFM.
@Controller('mcp/tools')
@UseGuards(new McpSignatureGuard('cafm'))
export class McpController {
@Post(':toolName')
callTool(@Param('toolName') toolName: string, @Body() body: McpToolRequest) {
switch (toolName) {
case 'cafm_list_work_orders':
return { result: this.listWorkOrders(body.arguments, body.context) };
default:
return { error: { code: 'UNKNOWN_TOOL', message: `No such tool: ${toolName}` } };
}
}
private listWorkOrders(args: Record<string, unknown>, context: McpCallContext) {
// ... look up work orders scoped to context.organizationId
return [];
}
}If your module also has a user-initiated caller, admit a realm JWT alongside the signature rather than instead of it. McpSignatureGuard is the only piece the SDK exports for this; the JWT half is plain NestJS plus a JOSE library, not platform-private code:
// realm-jwt.guard.ts — the JWT half; combine with McpSignatureGuard above
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
interface RequestWithMcpContext {
headers: Record<string, string | string[] | undefined>;
body: { context?: McpCallContext };
}
@Injectable()
export class RealmJwtGuard implements CanActivate {
private readonly jwks = jwksClient({
jwksUri: `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
});
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RequestWithMcpContext>();
const header = request.headers['authorization'];
const token = typeof header === 'string' ? header.replace(/^Bearer /, '') : undefined;
if (!token) {
throw new UnauthorizedException({
code: 'MCP_MISSING_BEARER',
message: 'Authorization: Bearer <realm JWT> is required.',
});
}
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid;
if (!decoded || typeof kid !== 'string') {
throw new UnauthorizedException({ code: 'MCP_BAD_TOKEN', message: 'Malformed JWT.' });
}
const signingKey = await this.jwks.getSigningKey(kid);
let claims: jwt.JwtPayload;
try {
claims = jwt.verify(token, signingKey.getPublicKey(), {
algorithms: ['RS256'],
issuer: process.env.KEYCLOAK_ISSUER,
}) as jwt.JwtPayload;
} catch {
throw new UnauthorizedException({ code: 'MCP_BAD_TOKEN', message: 'Signature or issuer mismatch.' });
}
const roles: string[] =
claims.realm_access?.roles ?? (Array.isArray(claims.roles) ? claims.roles : []);
// Client-supplied context is never trusted for identity: rewrite it
// from the verified token.
request.body.context = {
userId: String(claims.sub),
organizationId: String(claims.organization_id ?? ''),
buildingId: typeof claims.building_id === 'string' ? claims.building_id : undefined,
roles,
};
return true;
}
}Combine the two by trying the signature header first, falling back to the JWT guard only when it is absent:
// mcp-access.guard.ts — either credential admits
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { McpSignatureGuard } from '@tv/extension-sdk/nestjs';
import { RealmJwtGuard } from './realm-jwt.guard';
@Injectable()
export class McpAccessGuard implements CanActivate {
constructor(
private readonly hmac: McpSignatureGuard,
private readonly jwtGuard: RealmJwtGuard,
) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> {
const request = context.switchToHttp().getRequest();
if (request.headers['x-tv-platform-signature']) {
return this.hmac.canActivate(context);
}
return this.jwtGuard.canActivate(context);
}
}Do not invent a third scheme.
Partner modules
Partner modules — like the module you're building now — follow a different timeline than the description above.
You declare mcpTools in your manifest from the start; nothing about writing or validating them differs from a first-party module. But Copilot does not call your endpoint until your module is promoted — the manual review described in what you can and cannot do. Promotion is also when Tango Vision issues your module's signing secret (TV_PLATFORM_SIGNING_SECRET_<SLUG>); there is no self-service way to obtain or generate one earlier, so nothing you build before promotion makes Copilot invoke your tool any sooner.
What to do meanwhile:
- Simplest: leave
mcpEndpointout of your manifest until you're close to promotion.mcpToolsstill declares and validates; the tools just resolve to the "not executable" stub described above. This is the safe default while you're still building, and what we recommend. - If you want your endpoint built and tested ahead of time, guard it with
McpSignatureGuardexactly as in the first-party example above, using your own module slug. It compiles and runs today against the published SDK. Read the warning above first: without a secret — which you will not have until promotion — the guard refuses every request with401 MCP_SIGNING_SECRET_MISSINGin any deployment whereNODE_ENV=production(and still admits unsigned traffic with a warning anywhere else, so local development againstNODE_ENV=developmentor unset keeps working). Do not deploy that endpoint anywhere reachable from the public internet before promotion; keep it onlocalhost, or on your sandbox host the same way the rest of your module development already runs. - You will not need the JWT variant before promotion: nothing loads your module into a shell a user signs into yet, so there is no user-initiated caller to admit.
Tell your contact when your module is ready for review — they'll walk you through promotion, including provisioning the secret above.
Full example
Ask for the reference module tv-module-example — its repository is private, so a copy is provided on request (developers@tango.vision) — for a complete, validated manifest with mcpTools, mcpEndpoint, heartbeat, and HMAC-signed federation. The module catalog lists every module shipping today.
Stability of the schema
The manifest schema follows the SDK's semver. Breaking changes (new required fields, removed fields) bump the SDK major. Additive fields bump the minor. See the stability policy.