Lox Nexus

SDK at a glance

This page is the compact reference: one place to look up a signature, a default or a limit. It is maintained by hand next to the generated SDK reference; if the two ever disagree, the reference wins.

It does not teach. For when and why to use each member, and for what happens in which order, read the plugin guide (start with its lifecycle), Values and Testing. Each section below links to the matching part of the guide. To learn by building, follow the tutorial.

Imports: @lox-nexus/plugin-sdk (classes), @lox-nexus/plugin-sdk/values, @lox-nexus/plugin-sdk/testing, @lox-nexus/plugin-sdk/wire.

@lox-nexus/plugin-sdk

Plugin (reference)

abstract class Plugin<Config = unknown> extends Disposable {
  constructor(protected readonly context: PluginContext)
  get config(): Config
  get log(): PluginLogger
  get logLevel(): PluginLogLevel                           // { current, onChange(listener) }
  get store(): PluginStore
  get status(): PluginStatus
  get http(): PluginHttp
  get slug(): string | undefined
  get timeZone(): string                                   // IANA, e.g. "Europe/Vienna"
  get clock(): Clock                                       // now(), and the clock every()/after() run on
  start(): void | Promise<void>
  catalog(): CatalogNode[] | Promise<CatalogNode[]>
  panel(): Panel | undefined | Promise<Panel | undefined>
  panelChanged(): void                                     // tell open pages the panel changed (at most once a second)
  unhandledRejection(reason: unknown): void
  get watch(): WatchView                                   // the chosen signals (watching plugins)
  watched?(values: readonly WatchedValue[], dropped: number): void | Promise<void>
  watchChanged(): void
}
Member Notes
start() Reject only for what a restart can't fix (bad credentials): the instance goes to the error state and is not restarted. For an unreachable device resolve and call status.set("degraded", ...).
catalog() Asked on demand by the UI; may query the device; a throw is shown to the user. Default []. Answers (catalog, panel, webhook responses) over 16 MiB as JSON fail instead.
panel() Asked every 3 s while the page is open; with static panelUpdates = "push" only when panelChanged() says so and every 30 s. undefined shows nothing. Never put secrets in it. See Panel.
panelChanged() Tells open pages the panel changed (a device paired, a poll finished). Reaches the host at most once a second; does nothing on older hosts.
unhandledRejection(reason) Called for a promise in the worker that was rejected without a handler (a forgotten .catch). The worker keeps running; the default logs an error with the stack. Override to say more or to set a degraded status; it must not throw. PluginTestHost.unhandledRejection(reason) calls it in tests.
config Already validated against the static config schema, defaults applied. Available in field initializers of subclasses.
slug The instance's slug: its topic segment and webhook path (/hook/<slug>/…). Undefined with hosts that don't send it. Fixed for one run (a slug change restarts the instance).
clock this.clock.now() instead of Date.now(); every() and after() run on it. Also on sensors and actuators. See Time.
logLevel current is "debug" while the user switched on debug logging for the instance (for 15 minutes) or the server's own log is at debug, "info" otherwise; onChange(listener) returns the unsubscribe. Lines below it are dropped in the worker. Use it to raise a library's own verbosity; plain log.debug() needs nothing. "debug" for good with older hosts.
timeZone The installation's IANA time zone, for anything that depends on the local time. Nexus takes it from the Loxone project it read last; without one (and with older hosts) it is the server's zone, which in a container is often UTC. Fixed for one run.

Static declarations on the subclass (PluginClass); all optional:

static readonly config?: z.ZodType      // default: no config (empty object)
static readonly sensors?: readonly SensorClass[]      // ids unique among sensors
static readonly actuators?: readonly ActuatorClass[]  // ids unique among actuators
static readonly watches?: { label: string; description?: string }  // receives values of signals the user chooses
static readonly configVersion?: number  // the config's shape; default 1, raise it with migrateConfig
static migrateConfig?({ config, from, store }: ConfigMigration): unknown  // older config → current shape
static test?(config): Promise<string | void>  // Test connection; read only, throw a message the user can act on
static readonly actions?: readonly ActionClass[]      // see Action
static readonly panelUpdates?: "push"   // the plugin calls panelChanged(); pages then ask for the panel when told, else every 30 s
static discover?(field: string, config: unknown): Promise<Discovered>  // Find for discoverable() fields; read only, at most 15 s

discover runs in a throwaway worker, like test, for a config field marked with discoverable(); config is the form as filled in so far, unvalidated. It answers up to 100 { label, value, detail? }: value is what the field gets when the user picks it. Use Mdns.browse for devices that announce themselves. See Find on the network.

A watching plugin implements watched(values, dropped) (batches, oldest first; the next comes when it returns) and optionally watchChanged(); this.watch.signals() / signal(id) describe the chosen signals. See Watching signals.

type WatchDeclaration = { label: string; description?: string }
interface WatchView { signals(): WatchedSignal[]; signal(id: string): WatchedSignal | undefined }
type WatchedSignal = { id: string; topic: string; label: string; valueType: ValueType | "any"; owner?: { kind: "miniserver" | "plugin"; id: string }; unit?: string; decimals?: number }
type WatchedValue = { signalId: string; ts: number; kind: "state" | "command"; value: Value; forced?: true; initial?: true }

The guide explains each of these: the plugin class, starting and failing, catalog, panel.

Sensor (reference)

abstract class Sensor<P extends Plugin = Plugin, Params = unknown> extends Disposable {
  constructor(readonly plugin: P, readonly params: Params, private readonly output: SensorOutput, readonly signal: SignalInfo = { id: "", label: "" })
  start(): void | Promise<void>
  protected emit(value: Value): void
  protected fail(message: string): void
  protected recover(): void
}
Member Notes
start() A rejection makes adding the signal fail and disposes the sensor.
emit(value) Sends a value to the bus; equal consecutive values are deduped there. No-op after dispose.
fail(message) The sensor can't deliver any more; shown on the signal until the next emit() or recover().
recover() The failure is over although there is no value to send (an entity back as "unavailable"). Only tells the host after a fail(), so it is cheap to call on every change.
signal { id, label } of the configured signal this sensor is; label "" with hosts that don't send it.

Static declarations (SensorClass):

static readonly id: string                       // required; stored with the signal, never rename
static readonly label?: string                   // default: id
static readonly description?: string             // one sentence the UI shows when adding it
static readonly params?: z.ZodType               // default: empty object; validated before construction
static readonly valueType?: ValueType | "any"    // what emit() sends; default "any"
static readonly unit?: string                    // suggested for new signals, e.g. "W" (up to 16 characters)
static readonly decimals?: number                // suggested for new signals: decimals to show (0-6)
static readonly staleAfterMs?: number            // suggested for new signals: warn after this long without a value

SensorOutput: { emit(value: Value): void; fail(message: string): void; recover?(): void }, the runtime's side, not used by plugin code.

In the guide: Sensors.

Actuator (reference)

abstract class Actuator<P extends Plugin = Plugin, Params = unknown, V extends Value = Value> extends Disposable {
  constructor(readonly plugin: P, readonly params: Params, readonly signal: SignalInfo = { id: "", label: "" })
  start(): void | Promise<void>
  abstract run(value: V, command?: ActuatorCommand): void | Promise<void>
  protected fail(message: string): void
  protected recover(): void
}
interface ActuatorCommand { readonly replayed: boolean }
Member Notes
run(value, command) Throwing or rejecting reports a failed result. Emits and logs made while it runs (also from timers and promises it started) carry the command's cause. Never overlaps itself. command.replayed is true when the host delivers a value again (an active force after the plugin started), not a new command: an actuator that restarts a device or opens a door should ignore it. Older hosts never set it.
start() A rejection makes adding the signal fail.
fail(message) The actuator can't work at the moment (its device is gone); shown on the signal until recover(). Commands still reach run().
recover() Ends a failure. Only tells the host after a fail(), so it is cheap to call on every change.

Static declarations (ActuatorClass):

static readonly id: string                          // required; stored with the signal, never rename
static readonly label?: string                      // default: id
static readonly description?: string                // one sentence the UI shows when adding it
static readonly params?: z.ZodType                  // default: empty object
static readonly accepts?: ValueType | "any"         // wires convert to this before run(); default "any"
static readonly concurrency?: ActuatorConcurrency   // "queue" (default) | "latest"
static readonly unit?: string                       // suggested for new signals, e.g. "%"
static readonly decimals?: number                   // suggested for new signals (0-6)

"latest": while a command runs, only the newest waiting one runs; the replaced ones are reported as superseded (SupersededError, thrown by the runtime, not by plugins).

In the guide: Actuators.

Action (reference)

Something a user starts from the instance page with a small form: pair a device, open a pairing window, remove a paired controller. Declared in the plugin's static actions; the page shows a button per action at the top, and a panel table's rowActions open one with its input filled in. static placement = "panel" takes it off the top: its buttons are then only where the panel puts them, in a section's heading (actions) or on a table row. An input that is a z.discriminatedUnion shows as a choice of its discriminator and the chosen branch's fields.

Member Notes
run() Returns an ActionResult: the text shown to the user (clipped to 2000 characters), { message?, panel? } with a panel part under it (a QR code, tiles, a notice), or nothing ("Done."). Older hosts show only the message. A throw is shown as the failure, and the form stays filled in. Runs with a 2-minute timeout; one action at a time per instance (a second is refused while one runs). Disposed when it settles or the plugin stops.
progress(text, fraction?) Protected. Shows how a long action gets on in its dialog ("Searching…", 0.4 for 40 %). At most four a second reach the host, the latest always; nothing on older hosts. Not logged.
input Validated against the static input schema, defaults applied. May hold secrets such as pairing codes: never log it.
plugin The plugin instance.

Static declarations (ActionClass):

static readonly id: string            // required; unique among the plugin's actions
static readonly label?: string        // the button's text; default: id
static readonly description?: string  // one sentence in the dialog
static readonly input?: z.ZodType     // the form; default: no input
static readonly confirm?: string      // asked before it runs, for actions that undo something
static readonly placement?: "toolbar" | "panel"  // "panel": no button at the top, only where the panel puts it

Actions run only while the instance runs or is degraded; a reverse proxy in front of Nexus should allow requests of up to two minutes, or a slow pairing shows as a timeout although it worked.

Disposable (reference)

abstract class Disposable implements DisposableLike {
  static readonly MAX_TIMER_MS: 2_147_483_647
  get isDisposed(): boolean
  dispose(): Promise<void>
  protected onDispose(teardown: Teardown): void
  protected own<T extends DisposableLike>(resource: T): T
  protected useClock(clock: Clock | undefined): void
  protected currentClock(): Clock
  protected every(ms: number, fn: () => void): () => void
  protected after(ms: number, fn: () => void): () => void
  protected listen(emitter: Listenable, event: string | symbol, listener: Listener): void
}
Method Notes
dispose() Teardowns run in reverse registration order; idempotent. Throws AggregateError after all ran if any failed.
onDispose(teardown) Throws Error if already disposed.
useClock(clock) Sets the clock every() and after() run on, unless one is set; timers already running keep theirs. Plugin, Sensor and Actuator set it from the plugin's context.
currentClock() That clock (the system clock if none is set). A plain Disposable reads the time from it; plugins, sensors and actuators have this.clock.
own(resource) Disposes resource with this object; returns it. A Disposable child without a clock inherits this one's.
every(ms, fn) Returns a cancel function. Throws RangeError unless 0 < ms <= MAX_TIMER_MS.
after(ms, fn) Returns a cancel function. Throws RangeError for negative or non-finite ms; long delays are stepped. Firing or cancelling releases its teardown, so calling it again and again (a restarted pulse) doesn't pile anything up.
listen(emitter, event, listener) Subscribes until disposed.

Related types: Clock { now(); setTimeout(fn, ms); clearTimeout(handle); setInterval(fn, ms); clearInterval(handle) } (SystemClock.instance in production, TestClock from /testing in tests), Teardown = () => void | Promise<void>, DisposableLike { dispose(): void | Promise<void> }, Listenable { on(event, listener): unknown; off(event, listener): unknown } (an EventEmitter fits), Listener = (...args: any[]) => void.

In the guide: Lifetimes.

Context objects

/** which configured signal a sensor or actuator is; label "" with hosts that don't send it */
interface SignalInfo { id: string; label: string }
interface PluginContext {
  readonly instanceId: string
  readonly slug?: string
  readonly timeZone?: string
  readonly config: unknown
  readonly log: PluginLogger
  readonly store: PluginStore
  readonly status: PluginStatus
  readonly http: PluginHttp
}
type PluginLogger = Record<"debug" | "info" | "warn" | "error", (message: string, data?: unknown) => void>
interface PluginStore {
  get<T = unknown>(key: string): T | undefined
  set(key: string, value: unknown): void
  delete(key: string): void
  keys(): string[]
}
interface PluginStatus { set(status: "running" | "degraded", message?: string): void }
interface PluginHttp { route(method: string, path: string, handler: HttpHandler): void }
type HttpHandler = (request: HttpRequest) => HttpResponse | Promise<HttpResponse>
Item Notes
store.get The cast to T is not checked; validate data an older version wrote.
store.set Value must be JSON; BigInt and cycles throw Error; keys are 1 to 200 characters, values at most 1 MiB as JSON (else Error).
status.set message is clipped to 10 000 characters; "running" clears "degraded".
http.route Reachable at /hook/<instance slug><path> without a login: check a secret yourself. Method "*" matches any method; a path ending in /* matches everything below that prefix ("/a/*" matches /a/ and /a/b, not /a; "/*" all). The most specific route wins: exact before prefix, longer prefix first, a named method before "*". Throws Error on a duplicate method + path. A throwing handler gives the caller 502.
log.* data is structured-cloned; more than 64 KiB as JSON is replaced by a note with its size. Never log secrets.

In the guide: Logging, store and status and Webhooks. Every number (timeouts, sizes) is in Limits at a glance.

Entry, config and re-exports

Export Signature Notes
PluginEntry static define<T extends PluginClass>(type: T): T export default PluginEntry.define(MyPlugin) in src/index.ts. Starts the worker runtime only when loaded by Nexus.
secret secret<T extends z.ZodType>(schema: T): T Marks a field writeOnly: never sent back to the UI, kept when an update omits it. Top-level fields only.
code code<T extends z.ZodType>(schema: T, language?: CodeLanguage): T Marks a string field as source code (format: "code" plus the language's contentMediaType), so the UI edits it in a code editor. CodeLanguage is "javascript" (default), "json" or "text". Validating the code stays the plugin's job.
token token<T extends z.ZodType>(schema: T): T Marks a string field as a random token (format: "token"), so the form shows a Generate button that fills in 48 hex characters. Leave it visible for copying, or add secret() to hide it.
signalName signalName<T extends z.ZodType>(schema: T): T Marks the top-level param of a sensor or actuator whose value names the signal (signalName: true), e.g. a hook or variable name: the form names a new signal, and so its topic, after it until the user names it.
signalGroup signalGroup<T extends z.ZodType>(schema: T): T Marks the top-level param of a sensor or actuator that names the device the signal belongs to (signalGroup: true), e.g. a Home Assistant entity: signal lists show the signals with the same value under one collapsible header. Several marked params form a path ("node ▸ endpoint"). Only the lists use it.
z zod Use this one, not your own copy.
SupersededError class extends Error See concurrency: "latest".
CatalogNode, CatalogEntry, Panel, PanelPart, RowAction, Cell, Tone, Discovered, HttpRequest, HttpResponse types, see wire Return values of catalog(), panel(), discover and webhook handlers.
Action, ActionClass, ActionResult class, types See Action.
ActuatorCommand type What run() learns besides the value: replayed.
duration duration(options?: { unit?: "ms" | "s" | "min" | "h"; min?; max? }) A number of units (default seconds); the form offers seconds, minutes and hours and stores the field's unit.
timeOfDay timeOfDay() "HH:MM", checked; a time picker.
color color() "#rrggbb", checked; a colour picker with the hex next to it.
slider slider(min, max, step = 1) A number in a range, shown as a slider with the number.
file file(options?: { accept?; maxBytes?; encoding?: "text" | "base64" }) A picked file's contents (text, or base64 for binary), at most maxBytes (default 256 KiB).
discoverable discoverable<T extends z.ZodType>(schema: T): T Marks a top-level config field for Find: the form runs static discover for it and offers what it found.
Mdns, MdnsOptions, MdnsService static browse(type: string, options?: MdnsOptions): Promise<MdnsService[]> Finds services that announce themselves with mDNS ("_home-assistant._tcp.local"): name, host, port, addresses, TXT. Asks once from a random port and listens for timeoutMs (default 3000); needs no daemon or port 5353. For discover.

@lox-nexus/plugin-sdk/values

In the guide: Values.

Value (reference)

type Value =
  | { type: "null" }
  | { type: "boolean"; value: boolean }
  | { type: "number"; value: number }
  | { type: "string"; value: string }
  | { type: "singleChannel"; value: SingleChannel }
  | { type: "rgbw"; value: Rgbw }
  | { type: "tunableWhite"; value: TunableWhite }
  | { type: "t5"; value: T5 }
type ValueType = Value["type"]
type ValueOf<T extends ValueType> = Extract<Value, { type: T }>
interface SingleChannel { channel: number; fadeTime: number }
interface Rgbw { red: number; green: number; blue: number; white: number; fadeTime: number; flags: number }
interface TunableWhite { temperature: number; brightness: number; fadeTime: number }
interface T5 { buttons: number }

Units: channels and brightness 0-100 (whole percent), fadeTime seconds, temperature Kelvin (conversions keep 2700-6500), flags raw bits (experimental), buttons bitmask 0-31 (experimental).

Value is also an object (an instance of ValueFactory):

Value.null(): ValueOf<"null">
Value.boolean(value: boolean): ValueOf<"boolean">
Value.number(value: number): ValueOf<"number">
Value.string(value: string): ValueOf<"string">
Value.singleChannel(value: SingleChannel): ValueOf<"singleChannel">
Value.rgbw(value: Rgbw): ValueOf<"rgbw">
Value.tunableWhite(value: TunableWhite): ValueOf<"tunableWhite">
Value.t5(value: T5): ValueOf<"t5">
Value.isType(type: string): type is ValueType
Value.equals(a: Value | undefined, b: Value | undefined): boolean
Value.types: readonly ValueType[]
Gotcha
Structured factories Round to whole percent (clamped 0-100) and 0.1 s fades; t5 masks outside 0-31 become 0. They don't validate.
equals Structural; NaN equals NaN, 0 equals -0.

Conversion and helpers (reference)

Export Signature Notes
Converter static convert<T extends ValueType>(value: Value, to: T): ValueOf<T> Never throws; impossible conversions give the target's default. Same-type input is returned as is.
ValueDefaults static for<T extends ValueType>(type: T): ValueOf<T>; FADE_TIME = 0.2 (s); TEMPERATURE = 4000 (K) A fresh default value per call.
ValueSchema z.ZodType<Value> Validates untrusted values; accepts only what a Miniserver can carry.
ValueTypeSchema z.enum([...ValueType])
ValueFormatter new ValueFormatter().format(value: Value | undefined): string Display only, lossy; never parse it. Strings come out quoted ("eco").
StringToNumber new StringToNumber().parse(input: string): number Lenient: "20%" gives 0.2, "1.234,5" gives 1234.5, unparseable gives 0.
T5Buttons new T5Buttons(mask); isPressed(button): boolean; names(): string[]; toString(): string; constants LEFT_UPPER=1 RIGHT_UPPER=2 MIDDLE=4 LEFT_LOWER=8 RIGHT_LOWER=16 Experimental.
Numbers clamp(value, min, max, fallback = min), percent(value, fallback = 0), fade(seconds), scale(value, min, max, toMin, toMax), percentToHex(value) (all static) percent rounds and clamps to 0-100; fade limits to 0-6553.5 s in 0.1 s steps; scale with an empty source range gives toMin.

@lox-nexus/plugin-sdk/testing

In the guide: Testing.

PluginTestHost (reference)

class PluginTestHost<T extends PluginClass = PluginClass> {
  readonly sent: WorkerMessage[]
  readonly store: Record<string, unknown>
  get plugin(): InstanceType<T>
  static start<T extends PluginClass>(type: T, options?: TestHostOptions): Promise<PluginTestHost<T>>
  static describe(type: PluginClass): Promise<PluginDescription>
  static checkConfig(type: PluginClass, config: unknown, configVersion?: number, store?: Record<string, unknown>): Promise<ConfigCheckResult>
  static testConfig(type: PluginClass, config: unknown): Promise<string | undefined>
  static discover(type: PluginClass, field: string, config?: unknown): Promise<Discovered>
  addSensor(capability: string, params?: unknown, label?: string): Promise<string>
  addActuator(capability: string, params?: unknown, label?: string): Promise<string>
  remove(id: string): Promise<void>
  values(id: string): Value[]
  next(id: string, timeoutMs?: number): Promise<Value>
  actuate(id: string, value: Value, causeId?: string, replayed?: boolean): Promise<void>
  catalog(): Promise<CatalogNode[]>
  panel(): Promise<Panel | undefined>
  http(request: Partial<HttpRequest> & Pick<HttpRequest, "path">): Promise<Required<HttpResponse>>
  action(id: string, input?: unknown): Promise<string | undefined>  // runs an action like its button; resolves with its message, rejects with its failure
  actionAnswer(id: string, input?: unknown): Promise<ActionAnswer | undefined>  // the whole answer: text, or { message?, panel? }
  get status(): { status: "running" | "degraded"; message?: string }
  watchSignals(signals: WatchedSignal[]): Promise<void>
  pushValues(values: Array<Omit<WatchedValue, "ts" | "kind"> & Partial<Pick<WatchedValue, "ts" | "kind">>>, dropped?: number): Promise<void>
  stop(): Promise<void>
}
interface ConfigCheckResult { issues: ConfigIssue[]; config: unknown; version: number; store?: { set: Record<string, unknown>; delete: string[] } }
interface ConfigIssue { path: (string | number)[]; message: string }
interface TestHostOptions {
  config?: unknown; store?: Record<string, unknown>; instanceId?: string; slug?: string /* default "test" */; clock?: Clock /* e.g. a TestClock */
  timeZone?: string; forwardRejections?: boolean; configVersion?: number
  hostFeatures?: string[]  // default all (HOST_FEATURES); [] shows what an older Nexus gets
}
function cellText(cell: Cell | undefined): string  // a panel cell's text, for assertions

class TestClock implements Clock {
  constructor(start?: number | string | Date)
  get pending(): number
  advance(ms: number): Promise<void>       // runs each due timer at its own due time
  advanceTo(time: number | string | Date): Promise<void>
  jump(ms: number): void                   // a wall-clock step; timers keep their remaining delay
}
Member Notes
start Defaults: config {}, store {}, instanceId "test". stop() waits 2 s (real time) for the plugin to stop; production allows 5 s. Throws Error with the plugin's message when init fails (invalid config, start() threw).
addSensor / addActuator Return the new signal's id. Throw for unknown capabilities, invalid params or a failing start().
values(id) Every value emitted so far.
next(id, timeoutMs = 2000) Waits for the next value emitted after the call; throws on timeout. Waits use the real timers, also while a test mocks them (mock.timers), so a missing value fails instead of hanging.
checkConfig Checks settings as the host does before saving them, without starting the plugin: zod issues at their fields, and the config migrated from configVersion (default: the current version). With store, store in the result says what the migration wrote.
plugin The plugin instance, typed as its class, for reaching into it from a test (e.g. waiting for a poller).
actuate Throws Error with the message run() threw. causeId is the bus message it answers; replayed what run() sees in command.replayed.
hostFeatures By default the plugin runs as on a current Nexus. hostFeatures: [] runs it as on an older one: v1 panels (cells as text, tiles and notices as key-value), an action's message only, no panel.changed or action.progress (both are in sent otherwise).
cellText(cell) Panel values are cells (text or { text?, tone?, until?, since? }): cellText(row[1]) reads the text in an assertion.
http Method defaults to POST; unknown route answers 404; a throwing handler rejects.
status Last reported status, running if none.
sent Everything the plugin sent, structured-cloned like the worker boundary. A log line is { type: "log", level, message, data?, causeId? }; the other types are WorkerMessage in ./wire.

PluginRuntimeForTests is internal (its constructor may change); don't use it.

@lox-nexus/plugin-sdk/wire

Plugin authors need only the shapes (reference):

interface CatalogNode { label: string; id?: string; children?: CatalogNode[]; entries?: CatalogEntry[] }  // id: for a panel's "Signals" button
type CatalogEntry = { kind: "sensor" | "actuator"; capability: string; params: unknown; label: string; value?: Value; unit?: string }
type Tone = "ok" | "warn" | "error" | "info" | "muted"
type Cell = string | { text?: string; tone?: Tone; until?: number; since?: number; at?: number }  // epoch ms, counted live: "in 12:30", "for 3 min", "12 s ago"
type PanelPart =
  | { type: "keyValue"; items: { label: string; value: Cell; control?: RowAction; catalog?: string }[] }
  | { type: "table"; columns: string[]; rows: Cell[][]; rowActions?: RowAction[][]; rowCatalog?: (string | null)[] }  // up to 4 buttons per row; catalog node ids
  | { type: "tiles"; tiles: { label: string; value: number | string | null; unit?: string; decimals?: number; tone?: Tone; min?: number; max?: number; until?: number; hint?: string }[] }  // at most 12
  | { type: "notice"; tone: Tone; title?: string; text: string; link?: { label: string; href: string } }  // href: http(s) or "#/…"
  | { type: "qr"; data: string; caption?: string }  // e.g. a Matter pairing payload; hosts before it refuse the panel
  | { type: "json"; data: unknown }
type Panel = PanelPart | { type: "sections"; sections: { title: string; panel: PanelPart; actions?: RowAction[] }[] }  // at most 20 sections; up to 4 buttons in a heading
type RowAction = { action: string; label?: string; input?: Record<string, unknown>; instant?: boolean; switch?: { checked: boolean } }
type ActionAnswer = string | { message?: string; panel?: PanelPart }
type Discovered = { label: string; value: string; detail?: string }[]  // at most 100
type HttpRequest = { method: string; path: string; query: Record<string, string>; headers: Record<string, string>; body: string }
type HttpResponse = { status: number; headers?: Record<string, string>; body?: string }
type PluginHealth = "running" | "degraded"
type LogLevel = "debug" | "info" | "warn" | "error"
Item Notes
CatalogEntry.params Validated against the capability's params schema when the user adds it.
HttpRequest path is below /hook/<instance>; repeated query keys and headers are joined with ", "; header names lowercase; the session cookie is not forwarded; body is utf-8 text.
HttpResponse status 100-599; body is utf-8 text.
Panel cells Text, or text with a tone (a dot and colour) and a time the page counts down to (until) or up from (since). Format numbers yourself. sections shows titled parts one below the other (facts above a table); hosts before it refuse that type, which fails only the panel.
RowAction Opens the action's dialog with input filled in. instant: runs at once (after the action's confirm), the result shows above the panel. switch: a switch in state checked that runs the action with input when flipped (put the new state in input).
v2 parts Tiles, notices, rich cells, item controls and instant/switch need the host feature panel.v2; on older hosts the runtime turns them into text and key-value lists, so plugins don't check.

Messages. Host and worker talk in these messages (type tells them apart). A plugin never sends them itself; the runtime does. Requests carry a requestId and are answered with response { requestId, ok, result?, error? }.

Host → worker (HostMessage) Fields Answer
init instanceId, slug?, config, store, timeZone?, logLevel?, configVersion?, hostFeatures? ready or failed (after config.migrated when it migrated). hostFeatures (HOST_FEATURES): what the host understands beyond version 1
describe requestId the PluginDescription
signal.add requestId, signal (SignalSpec) ok once the sensor's or actuator's start() resolved
signal.remove requestId, id ok once disposed
actuate requestId, signalId, value, causeId?, replayed? ok, or the error run() threw
catalog requestId CatalogNode[]
panel requestId a Panel, or none
http.request requestId, request (HttpRequest) an HttpResponse
action requestId, action, input an ActionAnswer (an object only to hosts with action.result), or none; only sent for actions the description declares
watch.signals signals (WatchedSignal[]) none
watch.values seq, values (WatchedValue[]), dropped? watch.ack
log.level level none; only to workers whose hello lists the feature log.level
config.check requestId, config, configVersion?, store? { issues, config, version, store? }; feature config.check
config.test requestId, config the test's message; feature config.check
config.discover requestId, field, config? Discovered; only for fields the description lists as discoverable
ping seq pong
shutdown stopped
Worker → host (WorkerMessage) Fields Meaning
hello version, features? the wire version, sent first; features names additions the worker understands (log.level, config.check)
ready init done
failed error init failed
emit signalId, value, causeId? a sensor's value
status status, message? running or degraded
log level, message, data?, causeId? a log line
store.set key, value persist a store entry
store.delete key remove a store entry
response requestId, ok, result?, error? the answer to a request
signal.error signalId, error a sensor or actuator stopped working
signal.recovered signalId the failure is over
watch.ack seq a watch.values batch is handled
config.migrated config, version init migrated the stored config; only when init carried configVersion
panel.changed the panel changed (panelChanged()); only to hosts with panel.push
action.progress requestId, text, fraction? a running action's progress; only to hosts with action.progress
pong seq answers ping
stopped shutdown done

For hosts and tools: WIRE_VERSION (1), HOST_FEATURES (HostFeature), HostMessageSchema, WorkerMessageSchema, SignalSpecSchema, HttpRequestSchema, HttpResponseSchema, CatalogEntrySchema, CatalogNodeSchema, PanelSchema, PanelPartSchema, RowActionSchema, CellSchema, ToneSchema, ActionAnswerSchema, DiscoveredSchema, CapabilityDescriptionSchema, PluginDescriptionSchema, PluginHealthSchema, LogLevelSchema, the matching types (HostMessage, WorkerMessage, SignalSpec, CapabilityDescription, PluginDescription, JsonSchema) and PluginWorkerData.