SDK API
Everything a module can do is reached through @readout/sdk. Import activate and register your
surfaces; each surface receives the same ps object.
activate
import { activate } from '@readout/sdk'
activate((ps) => { /* register your surfaces here */ })
Call it once. Register every surface your manifest declares; the host mounts the one this particular iframe was created for. Your content size is auto-reported, so widgets and settings size to fit.
The SDK object
interface Sdk {
manifest: ModuleManifest
host: HostApi
theme: ThemeVars
onThemeChange(cb: (theme: ThemeVars) => void): () => void
registerWidget(def: WidgetDef): void
registerWindow(def: WindowDef): void
registerSettings(def: SettingsDef): void
reload(): void
}
manifestis your validated manifest (id, name, version, permissions, allowedDomains) — handy for showing your own version, or branching on a granted permission.reload()re-mounts all of the module's surfaces — call it after a settings surface changes config that another surface reads, so the other surface refreshes.theme/onThemeChangeexpose the current theme as CSS custom properties (see Theming and the full Styling reference).
Surface definitions
type Mount = (root: HTMLElement) => void | (() => void)
interface WidgetDef { id: string; gated?: boolean; render: Mount }
interface WindowDef { kind: string; title: string; initial?: { w: number; h: number }
launcher?: { icon: string; label?: string; order?: number }; render: Mount }
interface SettingsDef { id: string; title: string; icon?: string; order?: number; render: Mount }
render(root) draws into root (your iframe's document.body). Return a function to clean up on unmount
(unsubscribe streams, clear timers).
The host bridge
interface HostApi {
nowPlaying: Stream<NowPlaying | null>
controllers: Stream<ControllerSnapshot[]>
metrics: Stream<SystemMetrics>
session: Stream<SessionEvent>
storage: {
get<T = unknown>(key: string): Promise<T | undefined>
set(key: string, value: unknown): Promise<void>
remove(key: string): Promise<void>
}
notify(opts: { title: string; body?: string; level?: 'info' | 'warn' | 'error' }): Promise<void>
actions: {
play(): Promise<void>; pause(): Promise<void>; next(): Promise<void>; previous(): Promise<void>
}
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<FetchResponse>
}
Each member requires the matching manifest permission, or the call is rejected.
Data streams
interface Stream<T> { subscribe(cb: (value: T) => void): () => void }
subscribe returns an unsubscribe function — return it from render so the stream is torn down with the
surface. The value shapes:
interface NowPlaying {
status: 'playing' | 'paused' | 'stopped'
title: string; artist: string; album: string
positionSec: number; durationSec: number
}
interface ControllerSnapshot { id: string; kind: string; buttons: string[]; battery?: number }
interface SystemMetrics { fps?: number; cpu?: number; gpu?: number; memory?: number }
interface SessionEvent { app: string; active: boolean; secondsToday: number }
Storage
await ps.host.storage.set('city', 'Berlin')
const city = await ps.host.storage.get<string>('city')
await ps.host.storage.remove('city')
Async and shared across a module's surfaces (each surface is a separate iframe). Read it inside
render — not only at activate — so a freshly-mounted surface reflects the saved value. After a settings
surface writes a value a widget reads, call ps.reload() so the widget re-renders.
Notifications and playback
await ps.host.notify({ title: 'Done', body: 'Sync complete', level: 'info' }) // needs ui:notify
await ps.host.actions.next() // needs action:playback
Networking
const res = await ps.host.fetch('https://api.open-meteo.com/v1/forecast?...')
const data = JSON.parse(res.body)
interface FetchResponse { status: number; headers: Record<string, string>; body: string }
Raw browser fetch is blocked by the module's CSP — all network goes through ps.host.fetch, which
only reaches your manifest's allowedDomains. See the security model
for the full set of guards (SSRF protection, no cookies/redirects, size/time caps, rate limiting).
Theming
On load (and whenever the theme changes) the host pushes CSS custom properties — accent colours, surface
tints, the UI font, a color-scheme hint — and the SDK applies them plus a small base stylesheet, so a
module looks integrated out of the box. Use the variables in your CSS (var(--ps-accent),
var(--ps-foreground), var(--ps-font), ...), or read them from ps.theme and subscribe with
ps.onThemeChange(cb) if you compute styles in JS.
See the full Styling reference for every variable, the base stylesheet, and how surfaces are sized.
Authoring with or without a bundler
- Plain JS (no build). Ship
index.html+index.jsthatimport { activate } from '@readout/sdk'. The host serves an import map mapping@readout/sdkto its runtime, so the bare import resolves. - Your own bundler.
npm i -D @readout/sdkfor the types, build your static output, and keep@readout/sdkexternal (don't bundle it) so it still resolves to the host-provided runtime. Then zip the output to install it.