# Writing a Signage content provider Signage decides **when** something is on screen. Your module decides **what**. This document is the published contract for `signage.content-providers` version **1.0.0**, and for the renderer input Signage hands your browser contribution. It is written to be implementable from here alone — you will not import a line of Signage's code, because Ekklesia modules are independently installed peers and `pnpm boundaries` refuses a module that imports another module. --- ## 1. What you declare In your own `ekklesia.module.json`: ```json { "dependencies": { "modules": { "required": { "signage": "^0.1.0" } }, "capabilities": { "required": { "signage.content-providers": "^1.0.0", "signage.slide-renderer": "^1.0.0" } } }, "contributions": [ { "id": "announcements.signage-slide", "version": "1.0.0", "capability": "signage.slide-renderer", "capabilityRange": "^1.0.0", "artifact": "./dist/contributions/signage-slide/index.js" } ] } ``` The module dependency is what makes the platform start Signage before you and stop you before Signage — so your registration always has something to register with, and your unregistration always happens first. ## 2. What you register During your own `start` hook: ```ts const signage = context.capabilities.resolve("signage.content-providers"); if (!signage) { return; // Signage is not installed. Nothing else about your module should care. } const registry = signage.implementation as SignageContentProviders; this.handle = registry.register({ providerId: "announcements", providerVersion: "1.0.0", contractVersion: "1.0.0", renderer: { contributionId: "announcements.signage-slide", contributionVersion: "1.0.0" }, defaultDurationMs: 10_000, listSources, listItems, resolveNext, refresh, }); ``` And release it in `stop`: ```ts this.handle?.unregister(); ``` `unregister` is idempotent, which matters: your `stop` hook runs **before** rollback if your own `start` throws partway, and again during reverse-order shutdown. ### The types, to copy into your own module ```ts type SignageJsonValue = | boolean | number | string | null | readonly SignageJsonValue[] | { readonly [key: string]: SignageJsonValue }; interface SignageContentProviders { readonly contractVersion: string; register(registration: SignageProviderRegistration): SignageProviderHandle; } interface SignageProviderHandle { unregister(): void; invalidate(change?: { sourceId?: string; itemId?: string }): void; } interface SignageProviderRegistration { providerId: string; // stable, yours, `[\w][\w.:-]*`, ≤ 200 chars providerVersion: string; // semantic version contractVersion: string; // "1.0.0" renderer: { contributionId: string; contributionVersion: string }; defaultDurationMs: number; // 1000 … 3_600_000 listSources(request: DiscoveryRequest): Awaitable; listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable; resolveNext(request: ResolveRequest): Awaitable; refresh(request: RefreshRequest): Awaitable; } interface DiscoveryRequest { at: string; timezone: string } interface SourceDescriptor { id: string; label: string; description?: string } interface ItemDescriptor { id: string; label: string; description?: string; availableUntil?: string | null } interface ResolveRequest { selectionScopeId: string; // stable and opaque — see §3 sourceId: string; timelineId: string; channelGroupId: string; audienceKeys: readonly string[]; at: string; // absolute ISO 8601 instant timezone: string; // IANA zone } interface RefreshRequest extends Omit { itemId: string; sourceId?: string; selectionScopeId?: string; } interface Resolution { itemId: string; itemRevision: string; // changes when the payload changes, item unchanged durationMs?: number; // 1000 … 3_600_000 availableUntil?: string | null; // absolute ISO 8601 instant withdrawn?: boolean; presentation?: SignageJsonValue; // yours, for your own renderer variants: readonly { audienceKey: string | null; payload: SignageJsonValue }[]; } ``` ## 3. The selection scope is the whole idea An operator's timeline might be: ```text Announcements Announcements Announcements Trivia Announcements Announcements Scripture ↻ ``` Those five Announcement slots are five **turns of one rotation**, not five rotations. Signage expresses that by giving every slot that references one configured source instance the same `selectionScopeId`, and asking `resolveNext` once per turn. - The scope id is **stable**: it survives edits, reorders, renames, and restarts. - It is **opaque**: its shape means nothing, so never parse it. - It is **not** derived from the timeline or slot id, because either would silently reset your rotation when an operator dragged a row. Keep your cursor, your "already shown in this pass" set, and anything else you need **in your own tables, keyed by scope id**. Signage stores none of it and will never tell you what it thinks your cursor is. Two configured source instances of the same source are two independent rotations. That is an operator deliberately asking for one. ## 4. Repeat policy is yours Signage implements none of it: | Your module | Your policy | | --- | --- | | Announcements | Cycle the active set forever, wrapping where you choose | | Trivia | Never repeat until every currently available question has appeared | | Scripture | Never repeat until the reading pool is exhausted | Signage asks "what is next in this scope?" and shows what you hand back. ## 5. Three answers, and why they stay three | Return | Meaning | What Signage does | | --- | --- | --- | | `null` | Nothing available for this source right now | **Skips the slot immediately.** No error, no slate, no gap. | | `{ withdrawn: true, … }` | This item is explicitly gone | Removes it at the next safe reconciliation boundary, never mid-slide. | | throw / reject / exceed the deadline | A temporary failure | **Keeps the last known good selection and marks it stale.** The slot is not reset and playback does not stop. | Collapsing these is the single most damaging mistake a provider can make. An empty question pool that throws becomes an error on a lobby wall; a genuine outage that returns `null` silently rewrites the running order and nobody finds out. Every call is bounded by a deadline (an installation setting, 2 seconds by default). A call that never settles is a temporary failure — Signage does not wait, and does not cancel your work. ## 6. One selection, many audiences A channel group has one clock and one or more audience channels (`default`, and optionally `in-person`, `online`, …). Signage asks you **once** per slot occurrence, with every audience key, and you return **one logical item** with a variant per audience: ```ts return { itemId: "advent-service", itemRevision: "3", variants: [ { audienceKey: null, payload: { headline: "Join us Sunday, 5pm" } }, { audienceKey: "online", payload: { headline: "Streaming Sunday, 5pm" } }, ], }; ``` - The **default variant is required** — `null` or the literal `"default"`. - An audience with no variant of its own renders the default. That fallback is the contract, not a convenience, and a provider with nothing audience-specific simply returns one variant. - Resolving once is what keeps every channel on the same slide with the same deadline. There is no per-channel resolution and no way to ask for one. ## 7. Invalidation updates a slide without restarting it When your content changes — an event is cancelled, a countdown ticks past a threshold, a service time is corrected — tell Signage: ```ts this.handle.invalidate({ itemId: "advent-service" }); ``` Signage decides what to do: it re-resolves the affected selection through `refresh`, and if your `itemRevision` changed it stores the new payloads, bumps the selection's revision, and broadcasts. A mounted React renderer receives new input **in place**, with the slot's authoritative deadline unchanged. This is a **notification, not a command**. You cannot skip a slot, extend a deadline, reorder a timeline, or make something appear. That is deliberate: one provider must not be able to take over a channel group. ## 8. What Signage validates, and rejects Everything crossing the boundary, before it is believed: - A registration whose id, version, renderer identity, duration, or functions are not the shape above is refused with `SIGNAGE_PROVIDER_REGISTRATION_INVALID` — and a `contractVersion` outside `1.x` with `SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED`, so an operator can see that your module needs an update rather than that it is broken. - A resolution is refused with `SIGNAGE_PROVIDER_RESULT_INVALID` when it has no default variant, a duplicate audience key, more than 16 variants, payloads totalling over 32 KiB, a duration outside 1 s … 1 h, a non-absolute `availableUntil`, or a payload that is not genuinely finite JSON — a `Date`, a function, `NaN`, `undefined`, a class instance, or a cycle. - Every rejection carries a stable code and **nothing of your value**: not your payload, not your exception, not your message. Signage will not put your words on a screen or in a log. None of this is a sandbox, and it is not described as one. Your module is trusted code in the same process. This is the difference between your bug staying your bug and a malformed value reaching a television. ## 9. Your renderer Your browser contribution is the actual live renderer for your content. Signage mounts it through the platform's contribution ABI and treats it as completely opaque. ```tsx export default defineModuleContribution({ id: "announcements.signage-slide", version: "1.0.0", styles, mount(container, context) { const root = createRoot(container); const input = context.input as SignageSlideRendererInput; root.render(); return { update(next) { root.render(); }, unmount() { root.unmount(); }, }; }, }); ``` `mount` is asynchronous by contract. **Resolve it only when you are genuinely ready to be seen** — a font loaded, an image decoded, a layout measured. Signage waits for that promise before it fades your layer in, which is what makes a transition into a live renderer look like a transition into an image. ### The input, version 1.0.0 ```ts interface SignageSlideRendererInput { contract: "signage.slide-renderer"; version: string; // "1.0.0" providerId: string; itemId: string; itemRevision: string; audienceKey: string; payload: unknown; // yours, exactly as you returned it presentation: unknown; // yours, or null timing: { startedAtMs: number; // absolute, server clock deadlineAtMs: number; durationMs: number; paused: boolean; remainingMs: number | null; // when paused }; clock: { serverNowMs: number; localNowMs: number }; generation: string; // changes whenever anything above changed } ``` **Use the clock anchor, not `Date.now()` alone.** A television's clock is routinely wrong by hours. The authoritative now is: ```ts const serverNow = input.clock.serverNowMs + (Date.now() - input.clock.localNowMs); ``` That stays correct however wrong the device's clock is, and it is what makes a countdown on two screens agree. ### What your renderer must and must not do Must: - Be ready asynchronously, and update in place through `update` without remounting. - Handle timing changes: paused, resumed, re-anchored after a reconnect. - Unmount deterministically, releasing every timer, listener, and framework root. - Survive repeated mount/unmount without leaking. - Render sensibly for an input shape older or newer than the one you developed against. Must not: - Reach the network, a database, storage, or a credential. **You are given none of them**, on purpose: mounting your renderer grants your module nothing, and everything you need arrives in `payload` because Signage is the party that was authorized to fetch it. - Assume your module's management UI is mounted, or that any other renderer exists. - Extend the slot. `deadlineAtMs` was decided before you mounted. - Depend on an external internet service. A renderer must work from its cached artifact and its cached payload, because a display's network is the first thing that fails. A provider that genuinely needs cloud access needs a **later** contract version. Version 1 will not be weakened to allow it. ## 10. A checklist before you ship - [ ] `providerId` is stable and will never change for this module. - [ ] Cursor and no-repeat state live in your own tables, keyed by `selectionScopeId`. - [ ] `resolveNext` returns `null` for "nothing right now" and throws only for a genuine failure. - [ ] `refresh` returns `{ withdrawn: true }` for content that is genuinely gone. - [ ] Every resolution has a default variant. - [ ] Payloads are finite JSON, well under 32 KiB for all variants together. - [ ] `itemRevision` changes exactly when the payload changes. - [ ] `invalidate` is called when your content changes, and never used to try to control playback. - [ ] Your renderer resolves `mount` only when it is ready, implements `update`, and cleans up. - [ ] Your renderer needs no network. - [ ] `unregister` is called from `stop`, and calling it twice is safe.