Events
Modules communicate through a versioned event bus rather than calling each other directly. Your module declares what it publishes and subscribes to in the manifest; the platform validates those declarations before you ship.
Declare in the manifest
"events": {
"publishes": [
{ "name": "cafm.work-order.created", "version": "1.0.0" }
],
"subscribes": [
{ "name": "building.alarm.triggered", "version": "1.0.0" }
]
}Say what you read, and what you promise — payloadContract
Either side of an event declaration can carry a payloadContract. It is what makes check-pact able to tell you something useful, and it is the field people most often leave off.
"events": {
"publishes": [
{
"name": "cafm.work-order.created",
"version": "1.0.0",
"payloadContract": {
"type": "object",
"required": ["workOrderId", "buildingId"],
"properties": {
"workOrderId": { "type": "string" },
"buildingId": { "type": "string" },
"priority": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "URGENT"] }
}
}
}
],
"subscribes": [
{
"name": "building.alarm.triggered",
"version": "1.0.0",
"payloadContract": {
"type": "object",
"required": ["buildingId", "severity"],
"properties": {
"buildingId": { "type": "string", "reason": "Scopes the work order we open." },
"severity": { "type": "string", "reason": "Maps onto work-order priority." }
}
}
}
]
}The two sides mean different things, deliberately:
- On
subscribes, it is the subset of the producer's payload you actually read.check-pactfails you if the producer doesn't offer a field you declared — that is the whole point.reasonis free text explaining why you need the field; write it for the producer's maintainer, who sees it when they consider changing the payload. - On
publishes, it is what you guarantee to emit. Consumers check themselves against it.
A publishes entry with no payloadContract still registers the event — you do publish it, so a subscriber must never be told EVENT_NOT_PUBLISHED. It is marked opaque instead, and check-pact reports it as an info-level PRODUCER_PAYLOAD_OPAQUE: visible as a coverage gap, without turning anyone's CI red.
Where events come from
Events have two sources, and which one produces the event you consume determines the tool you reach for below:
- Platform events (
building.*) are published by the platform itself. They are versioned together with the SDK in its catalog snapshot (tv-events.snapshot.json) — the full list with payload schemas is Platform events. - Module events (
cafm.*,service-desk.*, …) are published by individual modules. By design there is no central catalog of them: a module's published events are declared in its manifest (events.publishes) and nowhere else, so there is no registry you have to get your own event added to. The module catalog shows which modules publish and subscribe.
Validate your subscriptions — check-pact
check-pact verifies that every contract in your events.subscribes actually fits the producer's schema:
# Subscribing only to first-party platform events:
npx @tv/extension-sdk check-pact module-manifest.json
# Subscribing to an event published by another MODULE — federate its
# manifest in, because module events aren't in the first-party catalog:
npx @tv/extension-sdk check-pact module-manifest.json \
--producer=../tv-module-cafm/module-manifest.jsonPass one --producer per producing module. Without it, a subscription to a module event fails as "unknown event" — which is the tool working correctly, not a bug.
Guard your own published events — check-events
If you publish events, snapshot their schemas and commit the snapshot. check-events compares the current schemas against it and fails CI on any breaking change — before it reaches your consumers:
npx @tv/extension-sdk snapshot-events ./tv-events.snapshot.json # regenerate
npx @tv/extension-sdk check-events ./tv-events.snapshot.json # verify in CIBoth commands take the snapshot path as an argument.
Receive platform events in your module's UI
Your module's frontend receives platform events with usePlatformEvent, from @tv/extension-sdk 1.18.0 or later. It is @experimental, as are subscribePlatformEvent and createMockPlatformEvents below: useful, but they may change in any release (Stability tiers).
import { usePlatformEvent } from '@tv/extension-sdk/react';
import { useState } from 'react';
export function AlarmBanner() {
const [alarm, setAlarm] = useState<string | null>(null);
// Alarms for the building selected in the shell's header, as they happen.
usePlatformEvent('building.alarm.triggered', (a) => {
setAlarm(`${a.severity}: ${a.title}`);
});
return alarm ? <p role="status">{alarm}</p> : null;
}What arrives, and why:
- Seven events, the ones tv-api sends to a browser:
building.point.updated,building.alarm.triggered,building.fault.createdand fourwifi-sensing.*events. Their payloads are typed; for thebuilding.*ones see Platform events. Inside the shell, and with the test stand-in below, subscribing to anything else throws aTypeError. Events other modules publish do not reach a browser at all. - Only for the building selected in the header, and only what the signed-in user may read. Alarms and faults (
building.alarm.triggered,building.fault.created) needbuilding.faults:read, and readings (building.point.updated) needtelemetry.points:subscribe, exactly as the REST API asks. Thewifi-sensing.*events need no permission: being signed in to the organization that owns the building is enough, as it is for the WiFi-sensing REST reads. - Your own copy of each payload. Another module cannot change it.
- Nothing to publish. A module's frontend cannot publish events: change state through the API, and let the backend that owns the data announce it (below).
Outside a shell (the standalone page under pnpm dev, a unit test with no stand-in) and in a shell that predates the channel, nothing arrives and nothing throws: the subscription is a no-op, whatever type you pass, so the same code runs there and does nothing. isPlatformEventsAvailable() from @tv/extension-sdk/context (also @experimental) tells you which case you are in. In tests, install a stand-in and emit events yourself:
import { createMockPlatformEvents } from '@tv/extension-sdk/testing';
const events = createMockPlatformEvents();
const uninstall = events.install(); // needs a DOM test environment
// render your component, then:
events.emit('building.alarm.triggered', {
buildingId, faultId, severity: 'high', title: 'Supply air too warm',
});
uninstall();Outside React, subscribePlatformEvent(type, handler) from @tv/extension-sdk/context does the same and returns an unsubscribe function (a no-op one when no shell delivers events).
Not usePlatformContext().eventBus
usePlatformContext() throws inside the shell: React context does not cross the federation boundary (Federation and the shell). Use usePlatformEvent.
Publish and receive in your backend
Module events (cafm.work-order.created and the like) travel between module backends over NATS JetStream, not through the browser. The subject is tv.building.<buildingId>.<event> and the message an envelope around your payload; the SDK builds both:
import { buildSubject, createEnvelope } from '@tv/extension-sdk/events';
const subject = buildSubject(wo.buildingId, 'cafm.work-order.created');
const envelope = createEnvelope({
type: 'cafm.work-order.created',
buildingId: wo.buildingId,
producer: 'cafm',
payload: { workOrderId: wo.id, buildingId: wo.buildingId, priority: wo.priority },
});
// publish `envelope` on `subject` with your NATS clientA backend subscribes the same way, to tv.building.*.<event> for every building. Platform events (building.*) arrive there too.
Not in a sandbox yet
Sandboxes run no NATS server, and your backend runs on your own machine, so module events cannot be published or received in a sandbox today. Develop the handler against payloads you build in a test, and declare the events in your manifest: check-pact checks your side of each contract.
Need a response? That's not an event
An event is an announcement. Nothing replies to it, and publishing it tells you nothing about whether or when anyone processed it.
When your code needs a result, call the API of the module that owns the data — createPlatformApiClient() for tv-api's resources (buildings, spaces, faults, per the api client), your own backend (Add a backend) for the module's own. The event is how everyone else finds out:
import { createPlatformApiClient } from '@tv/extension-sdk/context';
const api = createPlatformApiClient();
// The API call IS the request/response: you get the created entity back.
// `createPlatformApiClient()` reaches tv-api; work orders are the CAFM
// module's own resource, so the call goes to its own backend (see the
// "Add a backend" guide), not through this client.
const wo: WorkOrder = await createWorkOrder(dto);
// The CAFM backend, which owns work orders, then publishes
// cafm.work-order.created for other modules.Rule of thumb: API for questions, events for announcements. If you catch yourself publishing an event and then waiting for a "reply" event, replace the pair with one API call.
Versioning
Event names are namespaced (<module>.<entity>.<action>) and carry a semver version. Bumping a payload's shape in a breaking way means a new major on that event — consumers pin the version they understand, so you can evolve without breaking them.
→ Next: Testing