Files
Signage/docs/provider-contract.md
T
2026-08-28 16:41:54 -05:00

14 KiB

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:

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

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:

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

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<readonly SourceDescriptor[]>;
  listItems?(request: DiscoveryRequest & { sourceId?: string }): Awaitable<readonly ItemDescriptor[]>;
  resolveNext(request: ResolveRequest): Awaitable<Resolution | null>;
  refresh(request: RefreshRequest): Awaitable<Resolution | null>;
}

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<ResolveRequest, "sourceId" | "selectionScopeId"> {
  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:

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:

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:

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.

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(<Slide input={input} />);
    return {
      update(next) { root.render(<Slide input={next as SignageSlideRendererInput} />); },
      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

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:

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.