segfault labs

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
}
  • manifest is 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 / onThemeChange expose 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.js that import { activate } from '@readout/sdk'. The host serves an import map mapping @readout/sdk to its runtime, so the bare import resolves.
  • Your own bundler. npm i -D @readout/sdk for the types, build your static output, and keep @readout/sdk external (don't bundle it) so it still resolves to the host-provided runtime. Then zip the output to install it.