@enfinitos/sdk-renderer-core
EnfinitOS substrate-agnostic renderer SDK — the foundation every substrate-specific renderer (DOOH player, mobile, CTV, streaming, AR / glasses, HUD, smart-home, wearables, audio, messaging, hologram) wraps.
Today the platform supports 24 substrate kinds (DOOH plus 22 sequenced on the post-DOOH roadmap, plus a substrate-agnostic catch-all). Each substrate has its own delivery primitive — a DOOH player drives a video panel on a bus shelter; a mobile SDK drives in-app overlays; an audio SDK drives an Alexa skill insertion. But all of them need the same five primitives against the platform:
- Connect to the platform with a device-scoped token.
- Resolve what to show in a slot.
- Deliver the content (substrate-specific — not in this SDK).
- Report what happened (proof-of-play events).
- Report device health (heartbeat).
This package implements primitives 1, 2, 4, 5 once. Every substrate-specific renderer takes a dependency on this package and adds only its own delivery primitive (primitive 3) on top.
Platform-side counterpart. This SDK consumes the runtime plane endpoints inapps/api/src/modules/runtime/*—resolve,grant,event-ingest,contract, andhealth-ingest— plus/v1/rights/consent/checkfrom the rights module. Those endpoints exist on the platform today. The SDK is the device-side counterpart; substrate-specific wrappers depend on this package and on whichever delivery primitive their substrate uses.
Architecture
┌──────────────────────────┐
│ EnfinitOS Platform │
│ (runtime, rights, │
│ audit, fleet health) │
└────────────┬─────────────┘
│ HTTPS REST
│ + optional WS push
│ (JWT in handshake)
│
┌── (1) resolve+grant ──────────────▼───────────────┐
│ │
│ ┌─────────────────────────────────────┐ │
│ │ @enfinitos/sdk-renderer-core │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ResolveLoop│ │ Event │ │ │
│ │ │ │ │ Reporter │ │ │
│ │ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Health │ │ Consent │ │ │
│ │ │ Reporter │ │ Client │ │ │
│ │ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Transport (HTTPS or WS) │ │ │
│ │ └──────────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
│ ▲
(2) play │ │ (3) push directives
events │ │ (WS substrates only)
(4) health │ │
▼ │
┌──────────────────────────────────┴───────────────┐
│ Substrate-specific renderer wrapper │
│ (DOOH / mobile / CTV / AR / audio / etc.) │
└───────────────────────────────────────────────────┘
│
▼
Substrate's delivery primitive
(video panel / mobile view /
CTV ad-pod inserter / etc.)
Why this package exists
Without a shared core, every substrate-specific SDK would have to implement the five primitives independently. The team surveyed that path and rejected it for three reasons:
- Consistency at the audit layer. Proof-of-play events are the billing stream. If a DOOH SDK and a CTV SDK shape their events differently, the reconciliation logic on the platform side becomes a per-substrate mess. By pooling the event shape here, every substrate ships compatible events.
- Compliance reuse. Pre-render consent gating (
/v1/rights/consent/check) applies to seven of the 24 substrates (mobile, social, audio, wearables, neural, …). The wire-shape for that call is non-trivial; building it once is correct. - Operability. A single health-heartbeat shape across all substrates means the SRE dashboard speaks one schema. Per- substrate health envelopes would require a fan-in normaliser the platform side doesn't want to own.
Getting started
Install
pnpm add @enfinitos/sdk-renderer-core
Two classes — pick one
Two public classes live in this package; they share the same five subsystems, they just differ in API shape:
- EnfinitOSRendererClient — the recommended class for new code. Async-shaped report* methods, an optional in-process cache via cache, a standalone grant(assetId) for two-phase substrates, and the brief's canonical API surface (reportClick, reportConversion). - EnfinitOSRenderer — the original class. Synchronous report* methods, no built-in cache, resolve+grant folded into resolveNext. Kept for callers that wired against the v1 SDK.
Both compose the same subsystems and consume the same platform endpoints. Substrate-specific wrappers should prefer EnfinitOSRendererClient; substrate wrappers built against the v1 SDK keep working unchanged.
Five-minute hello-world
import { EnfinitOSRendererClient } from "@enfinitos/sdk-renderer-core";
const renderer = new EnfinitOSRendererClient({
apiBaseUrl: "https://api.enfinitos.com",
deviceId: process.env.DEVICE_ID!,
substrate: "DOOH", // or CTV, MOBILE, AUDIO, …
slotPositionHint: "placement_paddington_1",
authToken: process.env.DEVICE_JWT!,
cache: { defaultTtlMs: 30_000 }, // optional: 30s offline-blip cache
});
// 1) connect
await renderer.start();
// 2) resolve one asset
const asset = await renderer.resolveNext({
location: { lat: 51.5151, lng: -0.1410 },
});
if (asset) {
// 3) deliver (substrate-specific — your code, not the SDK's)
const startedAt = new Date();
await myPlayer.show(asset.assetUrl, asset.renderSpec);
await renderer.reportPlayStarted(asset, startedAt);
const dwellMs = Date.now() - startedAt.getTime();
await renderer.reportPlayEnded(asset, new Date(), dwellMs);
// 3b) on viewer tap-through:
await renderer.reportClick(asset, new Date());
// 3c) on post-render attribution match:
await renderer.reportConversion(asset, "purchase", new Date());
}
// 4) optional: heartbeat every 30 s
renderer.startHealthHeartbeat(30_000, () => ({
state: "OK",
subsystems: { decoder: "nominal", network: "ok" },
}));
// 5) on shutdown
await renderer.stop();
Standalone grant() for two-phase substrates
CTV ad-pods and streaming SSAI use a two-phase flow: resolve a pod ahead of the cue, grant per-slot at the cue point. Use the standalone primitive for that:
import { EnfinitOSRendererClient } from "@enfinitos/sdk-renderer-core";
const renderer = new EnfinitOSRendererClient({ /* ... */ });
await renderer.start();
// Resolve discovers a pod with 3 candidates (assetIds: a, b, c).
// At the cue point, the player picks one and grants it:
const granted = await renderer.grant("asset_a", { assetVersion: 7 });
await myPlayer.show(granted.assetUrl, granted.renderSpec);
30-line minimal renderer for a generic substrate
import {
EnfinitOSRendererClient,
type RightSubstrate,
type ResolvedAsset,
} from "@enfinitos/sdk-renderer-core";
export async function runOneSlot(
substrate: RightSubstrate,
deliver: (a: ResolvedAsset) => Promise<void>,
) {
const renderer = new EnfinitOSRendererClient({
apiBaseUrl: process.env.ENFINITOS_API!,
deviceId: process.env.DEVICE_ID!,
authToken: process.env.DEVICE_JWT!,
substrate,
cache: { defaultTtlMs: 30_000 },
onError: (e) => console.error("[sdk]", e),
});
try {
await renderer.start();
const asset = await renderer.resolveNext();
if (!asset) return;
const startedAt = new Date();
await renderer.reportPlayStarted(asset, startedAt);
try {
await deliver(asset);
await renderer.reportPlayEnded(
asset, new Date(), Date.now() - startedAt.getTime(),
);
} catch (e) {
await renderer.reportPlayError(asset, e as Error);
}
} finally {
await renderer.drainEvents();
await renderer.stop();
}
}
Substrate matrix
Each substrate-specific renderer wraps this core. The "wrap" is just a thin package that:
- chooses the transport (HTTPS REST or WebSocket push); - chooses the resolve cadence (poll, ad-cue-driven, navigation- driven); - implements the substrate-specific delivery primitive (video panel, audio playback, in-app overlay, AR scene); - calls the renderer-core's resolveNext / reportPlay* / reportHealth / checkConsent as appropriate.
| Substrate | Transport | Consent gate | Status |
|---|---|---|---|
DOOH | HTTPS | no | shipped (pilot) |
CTV | HTTPS | maybe | post-DOOH |
MOBILE | HTTPS | yes | post-DOOH |
STREAMING | WS (SSAI cues) | maybe | post-DOOH |
AUDIO | HTTPS | yes | post-DOOH |
AR_CONTACTS / GLASSES | HTTPS | viewer-attested | post-DOOH |
HUD / AUTOMOTIVE | HTTPS | n/a (vehicle bus) | post-DOOH |
SMART_HOME | HTTPS | yes | post-DOOH |
WEARABLES | HTTPS | yes | post-DOOH |
MESSAGING | HTTPS | yes | post-DOOH |
HOLOGRAM / VOLUMETRIC | WS | varies | post-DOOH |
ROBOTICS | (uses @enfinitos/sdk-robotics instead) | n/a | shipped |
DRONE / SATELLITE / AVIATION / MARITIME | varies | n/a | post-DOOH |
NEURAL | HTTPS | always | far-future |
See docs/launch/substrate-readiness-matrix.md for the per-substrate institutional-grade tracker.
Transport guidance
Two transports ship in the package.
HTTPS REST (default)
Pick when:
- The renderer is fundamentally request/response. - The slot cadence is predictable (DOOH ~30 s, CTV per-break, mobile per-navigation). - The substrate has no need for unsolicited platform → device pushes.
How to choose:
new EnfinitOSRenderer({
// ... (defaults to HTTPS)
});
WebSocket push
Pick when:
- The platform needs to push to the device at unpredictable times (live-event hologram, streaming SSAI cue injection). - The substrate's latency budget can't afford a polling round-trip. - The substrate model is "stay connected, accept directives".
How to choose:
new EnfinitOSRenderer({
// ...
transport: { kind: "ws" },
});
The WS transport speaks an envelope-shaped sub-protocol on top of the WebSocket (request, response, push). The platform's WS endpoint is /runtime/connect derived from apiBaseUrl.
Note: the WS transport in this SDK is distinct from the @enfinitos/sdk-robotics WS transport. Robotics has its own tagged-union wire shape for control-plane messages; renderer-core speaks an HTTP-style envelope so the same call sites work on either transport. They are not interchangeable.
Event-queue semantics & delivery guarantees
Every reportPlay* and reportInteraction call enqueues an event and returns synchronously. A background drain ships batches of events to /runtime/event-ingest with:
- Bounded backlog. Default 10 000 events per device. Configurable. Beyond the bound, oldest events drop first and droppedEventCount increments — surfaced in the next heartbeat so the SRE dashboard sees the loss. - Idempotent eventIds. Every event carries a deterministic id derived from (substrate, assetId, assetVersion, occurredAt, kind, seq). A retried event collides with the original on the platform side and is de-duped. The renderer can safely retry after a transient transport failure. - Exponential backoff with jitter. Retryable failures (5xx, 429, network) → exponential backoff capped at 30 s. Non- retryable failures (4xx) drop the batch and increment droppedEventCount. - Batched. Up to 50 events per drain pass (configurable). Reduces the platform's ingest QPS during recovery from a network drop.
This gives an at-least-once guarantee for events: every event either lands on the platform OR is counted as dropped. There are no silent losses.
The same machinery does NOT apply to health heartbeats — health is best-effort by design. A missed heartbeat at t=12 doesn't matter if t=13 lands.
How to write a substrate-specific wrapper
The recommended pattern is composition, not inheritance. Inherit from EnfinitOSRenderer only if you need to override createDefaultTransport (e.g. to add a substrate-specific custom transport).
// packages/sdks/dooh-player-ts/src/index.ts
import {
EnfinitOSRenderer,
type EnfinitOSRendererOptions,
type ResolvedAsset,
} from "@enfinitos/sdk-renderer-core";
export type DoohPlayerOptions = Omit<EnfinitOSRendererOptions, "substrate"> & {
/** DOOH-specific: the video element / canvas to render into. */
surface: HTMLVideoElement | OffscreenCanvas;
/** DOOH-specific: dwell estimator (a camera-based or proximity-
* based metric). */
dwellEstimator?: () => number;
};
export class DoohPlayer {
private readonly core: EnfinitOSRenderer;
private readonly surface: HTMLVideoElement | OffscreenCanvas;
private readonly dwellEstimator: () => number;
constructor(opts: DoohPlayerOptions) {
this.core = new EnfinitOSRenderer({
...opts,
substrate: "DOOH",
});
this.surface = opts.surface;
this.dwellEstimator = opts.dwellEstimator ?? (() => 0);
}
async start(): Promise<void> {
await this.core.start();
// Optional: start the DOOH player's substrate-specific
// scheduling loop here (typically 30s slot cadence with a
// safety-margin re-resolve at ~25s).
}
async playOne(): Promise<void> {
const asset = await this.core.resolveNext();
if (!asset) return;
const startedAt = new Date();
this.core.reportPlayStarted(asset, startedAt);
try {
await this.renderTo(asset);
this.core.reportPlayEnded(asset, new Date(), this.dwellEstimator());
} catch (e) {
this.core.reportPlayError(asset, e as Error);
}
}
private async renderTo(asset: ResolvedAsset): Promise<void> {
// DOOH-specific delivery — load the asset URL into the video
// surface, await playback completion, etc. This is the bit
// the renderer-core doesn't know about.
}
}
A typical wrapper adds 100–300 lines on top of this core — most of it substrate-specific delivery code. The shared primitives stay right here.
Error model
Every public API throws / rejects with RendererError. Each RendererError carries:
- code — greppable platform error code or one of the RENDERER_ERROR_CODES. - domain — coarse failure category (auth, resolve, grant, ingest, health, consent, transport, contract, config). - httpStatus — original HTTP status if the error came from a server response. - retryable — default per-domain; overridable.
import { isRendererError } from "@enfinitos/sdk-renderer-core";
try {
await renderer.resolveNext();
} catch (e) {
if (isRendererError(e)) {
if (e.domain === "auth") {
// refresh the device JWT
} else if (e.retryable) {
// schedule a retry
} else {
// permanent — show a fallback
}
}
}
Endpoint mapping
| Renderer-core call | Platform endpoint | Module |
|---|---|---|
resolveNext() (resolve phase) | POST /runtime/resolve | apps/api/src/modules/runtime/resolveRoutes.ts |
resolveNext() (grant phase) | POST /runtime/grant | apps/api/src/modules/runtime/grantRoutes.ts |
reportPlay* (drain) | POST /runtime/event-ingest | apps/api/src/modules/runtime/eventIngestRoutes.ts |
getContract() | GET /runtime/contract | apps/api/src/modules/runtime/contractRoutes.ts |
checkConsent() | POST /v1/rights/consent/check | apps/api/src/modules/rights/contracts/consent.ts |
reportHealth() / heartbeat | POST /runtime/health-ingest | (paired with the existing runtime module) |
Each endpoint can be overridden via the endpoints constructor option for customers running the platform on a non-default path.
See also
packages/sdks/robotics-ts/README.md— the reference SDK whose composition + reconnection + queueing pattern this package mirrors for the renderer substrates.apps/api/src/modules/runtime/*— platform-side counterparts to the five renderer-core primitives.apps/api/src/modules/rights/contracts/scope.ts—RIGHT_SUBSTRATESsource of truth on the platform side.docs/launch/substrate-readiness-matrix.md— per-substrate institutional-grade readiness tracker. Today: DOOH shipped, ROBOTICS shipped, every other substrate has type-system support; substrate-specific wrappers sit on the post-DOOH roadmap.