@lox-nexus/plugin-sdk
The public SDK for plugins: the base classes (Plugin, Sensor, Actuator, Action), lifetimes (Disposable), the worker entry (PluginEntry) and config helpers (secret, code, token, signalName, signalGroup, z).
Values, the host/worker protocol and the test helpers live in the subpaths
@lox-nexus/plugin-sdk/values, /wire and /testing.
Plugin
The class you extend (Plugin), the one line that exports it (PluginEntry), and what the runtime hands it: its config, log, store and status (see PluginContext).
ConfigMigration
What static migrateConfig gets: a stored config of an older configVersion.
Properties
config
config:
unknown
the stored config, not validated: it has the shape of version from
from
from:
number
the version it was stored with; 1 for instances from before the plugin declared one. A lower bound: the config may already have newer fields
store
store:
PluginStore
the instance's store, for moving data out of the config (e.g. a list that grew too big for it). Writes are kept when the instance starts; a settings check works on a throwaway copy.
PluginClass
PluginClass = (new (
context) =>Plugin) &object
The static side of a plugin class, as the runtime sees it. The declarations are static fields
of the subclass rather than of Plugin, so subclasses don't need override on them.
Type Declaration
name
readonlyname:string
used in error messages
config?
readonlyoptionalconfig?:z.ZodType
the instance config schema; default: no config (an empty object)
sensors?
readonlyoptionalsensors?: readonlySensorClass[]
the sensors this plugin offers; ids must be unique among sensors
actuators?
readonlyoptionalactuators?: readonlyActuatorClass[]
the actuators this plugin offers; ids must be unique among actuators
watches?
readonlyoptionalwatches?:WatchDeclaration
set when the plugin receives values of signals the user chooses (an exporter): Plugin.watched() gets them. The label and description are shown where the user chooses them.
actions?
readonlyoptionalactions?: readonlyActionClass[]
what users can start from the instance page (pair a device, open a pairing window); ids must be unique
configVersion?
readonlyoptionalconfigVersion?:number
The version of the config's shape, raised when a change needs migrateConfig; default 1.
Configs are stored with the version they were saved with.
migrateConfig?
readonlyoptionalmigrateConfig?: (migration) =>unknown
Turns a config saved with an older version into the current shape; may also move data into
the store. Runs before validation when an instance starts or its settings are checked, and the
host stores the result. It must be safe to run again on the same data: older hosts run it at
every start with from 1, also on a config already in the current shape. Keep current fields
that are there, and don't overwrite what the store already holds.
Parameters
migration
Returns
unknown
test?
readonlyoptionaltest?: (config) =>Promise<string|void>
Test connection: reaches the device or service with this config (already validated) and resolves with a short message ("Connected to Fronius GEN24 at 10.0.0.5"), or throws with one a user can act on. Runs in a throwaway worker without starting the plugin; read, never write.
Parameters
config
never
Returns
Promise<string | void>
panelUpdates?
readonlyoptionalpanelUpdates?:"push"
"push": the plugin calls panelChanged() whenever its panel changes, so open pages ask for the panel then, and only every half minute otherwise. Without it they ask every few seconds.
discover?
readonlyoptionaldiscover?: (field,config) =>Promise<any[]>
Find on the network: offers values for a config field marked with discoverable(), e.g. the
Home Assistant servers that announce themselves (see Mdns.browse). config is the form as
filled in so far, not validated. Runs in a throwaway worker without starting the plugin, for at
most 15 seconds; read, never write. Resolves with at most 100 entries.
Parameters
field
string
config
unknown
Returns
Promise<any[]>
PluginLogger
PluginLogger =
Record<LogLevel, (message,data?) =>void>
Log lines go to the host's log, tagged with the instance and the bus message being handled.
Text is clipped to 10 000 characters, and data (structured-cloned; one that cannot be cloned
is logged as text) is replaced by a note when it is more than 64 KiB as JSON. Never log secrets.
PluginLogLevel
The lowest level the host keeps for this instance: "debug" while the user switched on debug logging for it, while the server's own log is at debug, and with older hosts; "info" otherwise. Lines below it are dropped in the worker, so debug calls cost little; a plugin can raise its library's own verbosity with it.
Properties
current
readonlycurrent:"error"|"warn"|"info"|"debug"
Methods
onChange()
onChange(
listener): () =>void
calls listener when current changes;
Parameters
listener
(level) => void
Returns
the unsubscribe
() => void
PluginStore
Small persisted key-value state of one instance (tokens, cursors, "last seen" ids). Reads are synchronous from memory; writes are sent to the host, which stores them.
Methods
get()
get<
T>(key):T|undefined
The value last set (also earlier in this run), or undefined. The cast to T is not checked;
validate data that an older version may have written.
Type Parameters
T
T = unknown
Parameters
key
string
Returns
T | undefined
set()
set(
key,value):void
Remembers value under key: visible to get() at once, saved by the host in the background
and handed to the next run's init. The value must be JSON (it is stored as JSON; functions,
undefined fields and class instances are dropped or flattened, BigInt and cycles throw), at
most 1 MiB as JSON, and not undefined itself (use delete()). Keys are 1 to 200 characters.
Parameters
key
string
value
unknown
Returns
void
Throws
Error for an invalid key or value
delete()
delete(
key):void
removes the key; an absent key is a no-op
Parameters
key
string
Returns
void
keys()
keys():
string[]
every key currently stored, in insertion order
Returns
string[]
PluginStatus
The plugin's own view of its health: "degraded" with a message while something is wrong.
Methods
set()
set(
status,message?):void
message is clipped to 10 000 characters; "running" clears an earlier degraded state
Parameters
status
"running" | "degraded"
message?
string
Returns
void
PluginContext
Everything a plugin gets from the runtime.
Properties
instanceId
readonlyinstanceId:string
stable for the life of the configured instance (webhook URLs use the instance's slug instead).
Plugin does not expose it as a getter; subclasses read it as this.context.instanceId.
slug?
readonlyoptionalslug?:string
the instance's slug: its topic segment and webhook path (/hook/
config
readonlyconfig:unknown
already validated against the plugin's static config schema
log
readonlylog:PluginLogger
store
readonlystore:PluginStore
persisted across restarts and updates of this instance
status
readonlystatus:PluginStatus
http
readonlyhttp:PluginHttp
watch?
readonlyoptionalwatch?:WatchView
only for plugins that declare watches; absent with older runtimes
clock?
readonlyoptionalclock?:Clock
the time and timers the plugin runs on (Plugin.clock); the system clock in production, absent with older runtimes
timeZone?
readonlyoptionaltimeZone?:string
IANA name of the installation's time zone (Plugin.timeZone); absent when unknown and with older hosts
logLevel?
readonlyoptionallogLevel?:PluginLogLevel
which log lines the host keeps (Plugin.logLevel); absent with older runtimes
Sensors and actuators
The signals a plugin offers. Extend Sensor for a value that comes from the device and
Actuator for a command that goes to it; list them in the plugin's static sensors and
actuators.
ActuatorConcurrency
ActuatorConcurrency =
"queue"|"latest"
How commands that arrive while one is running are handled (set with the static concurrency):
- "queue": each runs, in order
- "latest": only the newest waiting command runs (a dimmer dragged across the range sends many values; only the last one matters)
ActuatorCommand
What run() learns about a command besides its value.
Properties
replayed
readonlyreplayed:boolean
the host delivers the value again (a force after a restart), not a new command
ActuatorClass
ActuatorClass = (new (
plugin,params,signal?) =>Actuator<Plugin,unknown,never>) &object
The static side of an actuator class, as the runtime sees it.
Type Declaration
id
readonlyid:string
unique within the plugin; part of the stored signal, so don't rename it
label?
readonlyoptionallabel?:string
shown in UIs; default: the id
description?
readonlyoptionaldescription?:string
one sentence for UIs: what it does, e.g. "Dims a light; 0 % turns it off"
params?
readonlyoptionalparams?:z.ZodType
default: no params (an empty object)
accepts?
readonlyoptionalaccepts?:ValueType|"any"
the value type run() expects; wires convert to it. Default "any": values pass unchanged
concurrency?
readonlyoptionalconcurrency?:ActuatorConcurrency
default "queue"
unit?
readonlyoptionalunit?:string
the unit new signals of it show their values in, e.g. "%" (up to 16 characters); the user can change it
decimals?
readonlyoptionaldecimals?:number
the decimals new signals of it round their values to for display (0-6); the user can change it
SensorOutput
Where a sensor's values go; the runtime forwards them to the bus.
Methods
emit()
emit(
value):void
sends a value to the bus, tagged with the cause of the work that is running (see Actuator)
Parameters
value
Returns
void
fail()
fail(
message):void
the sensor stopped working after it started; shown on the signal until it emits or recovers
Parameters
message
string
Returns
void
recover()?
optionalrecover():void
the failure is over although there is no value to send; optional for outputs made elsewhere
Returns
void
SensorClass
SensorClass = (new (
plugin,params,output,signal?) =>Sensor<Plugin,unknown>) &object
The static side of a sensor class, as the runtime sees it.
Type Declaration
id
readonlyid:string
unique within the plugin; part of the stored signal, so don't rename it
label?
readonlyoptionallabel?:string
shown in UIs; default: the id
description?
readonlyoptionaldescription?:string
one sentence for UIs: what it reads or does, e.g. "The value of a sensor entity or one of its attributes"
params?
readonlyoptionalparams?:z.ZodType
default: no params (an empty object)
valueType?
readonlyoptionalvalueType?:ValueType|"any"
what emit() sends, so wires can convert; default "any" (it varies)
unit?
readonlyoptionalunit?:string
the unit new signals of it show their values in, e.g. "W" (up to 16 characters); the user can change it
decimals?
readonlyoptionaldecimals?:number
the decimals new signals of it round their values to for display (0-6); the user can change it
staleAfterMs?
readonlyoptionalstaleAfterMs?:number
after how long without a value (an unchanged one counts) new signals of it warn that it is stale, e.g. three poll intervals; rounded up to minutes. The user can change it.
SignalInfo
Which configured signal a sensor or actuator instance is, as the user knows it.
Properties
id
id:
string
the signal's stable id
label
label:
string
the name the user gave the signal; "" when the host didn't send one (older hosts)
Settings form
Helpers that mark config and params fields so the UI shows the right input: secret, code, token, duration, slider and more. Mdns finds devices on the network for a field marked with discoverable.
MdnsService
A service found on the network, e.g. a Home Assistant server.
Properties
name
name:
string
the instance's name, e.g. "Home._home-assistant._tcp.local"
host
host:
string
the host name it runs on, e.g. "homeassistant.local"
port
port:
number
addresses
addresses:
string[]
the host's IPv4 and IPv6 addresses the answer gave
txt
txt:
Record<string,string>
its TXT record as key-value pairs, e.g. { base_url: "http://10.0.0.5:8123" }
MdnsOptions
where to ask; tests ask a responder on the loopback interface
Properties
timeoutMs?
optionaltimeoutMs?:number
how long answers are collected, in ms; default 3000
address?
optionaladdress?:string
default 224.0.0.251, mDNS's multicast group
port?
optionalport?:number
default 5353
clock?
optionalclock?:Clock
the timer's clock; tests may pass a TestClock
CodeLanguage
CodeLanguage =
"javascript"|"json"|"text"
Languages the UI knows how to present; others are shown as plain text.
code()
code<
T>(schema,language?):T
Marks a string config field as source code (a script, a template), so the UI edits it in a
code editor instead of a one-line input; a field that is also secret() stays a masked input.
It is described as format: "code" with the language's contentMediaType. Validating the code
stays the plugin's job, e.g. compiling it in start() and throwing with the line and column.
Type Parameters
T
T extends ZodType<string | null | undefined, unknown, $ZodTypeInternals<string | null | undefined, unknown>>
Parameters
schema
T
language?
CodeLanguage = "javascript"
Returns
T
duration()
duration(
options?):ZodNumber
a duration stored as a number of units (default seconds); the form offers seconds, minutes and hours
Parameters
options?
unit?
"min" | "ms" | "s" | "h"
min?
number
max?
number
Returns
ZodNumber
timeOfDay()
timeOfDay():
ZodString
a time of day as "HH:MM" (24 h), e.g. "06:30"; the form shows a time picker
Returns
ZodString
color()
color():
ZodString
a colour as "#rrggbb"; the form shows a colour picker next to the hex text
Returns
ZodString
slider()
slider(
min,max,step?):ZodNumber
a number from min to max, shown as a slider with the number next to it
Parameters
min
number
max
number
step?
number = 1
Returns
ZodNumber
file()
file(
options?):ZodString
A file the user picks, e.g. a certificate: its contents as text (default), or as base64 for
binary files. accept filters the picker (".pem,.crt"); maxBytes (default 256 KiB) is checked
in the form before upload and again here.
Parameters
options?
accept?
string
maxBytes?
number
encoding?
"text" | "base64"
Returns
ZodString
discoverable()
discoverable<
T>(schema):T
Marks a top-level config field as one the plugin can find on the network: the form shows Find
next to it, which runs the plugin's static discover for the field and offers what it found.
Type Parameters
T
T extends ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>
Parameters
schema
T
Returns
T
secret()
secret<
T>(schema):T
Marks a config or params field as secret (a password, a token). It is described as writeOnly,
so the host never sends it back to a UI and keeps the stored value when an update omits it.
Only top-level fields of the instance config are protected. A secret inside a nested object or an array is not detected: its value is sent to the UI, and saving the form erases it.
Type Parameters
T
T extends ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>
Parameters
schema
T
Returns
T
signalGroup()
signalGroup<
T>(schema):T
Marks the param of a sensor or actuator that names the device the signal belongs to, such as a
Home Assistant entity or a bridged Matter device: signal lists show the signals with the same
value together, as one group. Several marked params form a path ("node ▸ endpoint"), in the
order they are declared. It is described as signalGroup: true, which the UI reads on
top-level params only. Nothing else uses it: wires, history and exports address single signals.
Type Parameters
T
T extends ZodType<string | number | null | undefined, unknown, $ZodTypeInternals<string | number | null | undefined, unknown>>
Parameters
schema
T
Returns
T
signalName()
signalName<
T>(schema):T
Marks the param of a sensor or actuator whose value names the signal, such as a webhook's hook
name or a script's variable: the form names a new signal (and so its topic) after what is typed
there, until the user names it. Without one a new signal is named after its kind. It is
described as signalName: true, which the UI reads on top-level params only.
Type Parameters
T
T extends ZodType<string | null | undefined, unknown, $ZodTypeInternals<string | null | undefined, unknown>>
Parameters
schema
T
Returns
T
token()
token<
T>(schema):T
Marks a string config field as a random token (a shared secret that callers present, such as a
webhook token), so the UI offers a button that fills it with a new one: 24 random bytes as 48 hex
characters, which the schema must allow. It is described as format: "token". Leave the field
visible so the token can be copied into the caller, or combine it with secret() to hide it.
Type Parameters
T
T extends ZodType<string | null | undefined, unknown, $ZodTypeInternals<string | null | undefined, unknown>>
Parameters
schema
T
Returns
T
Discovered
Discovered =
z.infer<typeofDiscoveredSchema>
What static discover found for a config field, e.g. Home Assistant servers on the network:
value is what the field gets when the user picks it, label and detail what the list shows.
Catalog, panel and actions
What the plugin shows in the UI beyond the form: a catalog of ready-to-add signals, a status panel, and buttons a user can press. Extend Action for something the user starts from the instance page.
ActionResult
ActionResult =
string|void| {message?:string;panel?:PanelPart; }
What an action's run() answers: a text, or a message with a panel part shown under it (a QR code, tiles, a notice). Hosts without the "action.result" feature show only the message.
ActionClass
ActionClass = (new (
plugin,input) =>Action<Plugin,unknown>) &object
The static side of an action class.
Type Declaration
id
readonlyid:string
unique within the plugin; the API addresses the action by it
label?
readonlyoptionallabel?:string
the button's text; default: the id
description?
readonlyoptionaldescription?:string
one sentence shown in the action's dialog
input?
readonlyoptionalinput?:z.ZodType
the form's schema; default: no input (an empty object)
confirm?
readonlyoptionalconfirm?:string
a sentence the user confirms before it runs, for actions that undo something
placement?
readonlyoptionalplacement?:"toolbar"|"panel"
where its button is: "toolbar" (default) at the top of the instance page, or "panel" only
where the panel puts it, as a section's button (actions) or a table row's (rowActions)
Tone
Tone =
z.infer<typeofToneSchema>
How a value reads at a glance: "ok" green, "warn" amber, "error" red, "info" blue, "muted" grey. The UI shows it with a dot and the colour, never by colour alone.
Cell
Cell =
z.infer<typeofCellSchema>
A panel value: plain text, or an object with text, a Tone and a time the browser shows live: until
counts down, since counts up, at shows a moment ("12 s ago"). Times are epoch ms. A host without
the "panel.v2" feature gets the text instead.
RowAction
RowAction =
z.infer<typeofRowActionSchema>
A button for an action, on a table row, a key-value item or a section's heading; it opens the
action's dialog with input filled in (its keys must be fields of the action's input schema).
instant runs it at once, switch shows it as a switch in the state checked.
PanelPart
PanelPart =
z.infer<typeofPanelPartSchema>
A key-value list, a table, a JSON tree, a QR code, tiles or a notice.
Panel
Panel =
z.infer<typeofPanelSchema>
A status view: one part, or titled sections of parts; see PanelSchema.
CatalogEntry
CatalogEntry =
z.infer<typeofCatalogEntrySchema>
A ready-to-add sensor or actuator in a catalog: the capability with its params filled in.
CatalogNode
A browsable tree ("Kitchen ▸ Light ▸ brightness") returned by Plugin.catalog(). A node has a label and children, entries, or both; the user picks entries to add them as signals:
[{ label: "Kitchen", children: [{ label: "Light", entries: [
{ kind: "sensor", capability: "state", params: { entityId: "light.kitchen" }, label: "Kitchen light" }
] }] }]
Properties
label
label:
string
id?
optionalid?:string
Names the node for a panel's "Signals" button (rowCatalog, an item's catalog), e.g.
"client:aa:bb:cc:dd:ee:ff"; unique within the catalog. Older hosts drop it.
children?
optionalchildren?:CatalogNode[]
entries?
optionalentries?:object[]
kind
kind:
"sensor"|"actuator"
capability
capability:
string
params
params:
unknown
validated against the capability's params schema when the entry is added
label
label:
string
suggested name for the signal
value?
optionalvalue?:Value
optional: what the sensor reads (or the device reports for an actuator) right now, shown while browsing. Only informative, so an invalid one is dropped instead of failing the catalog.
unit?
optionalunit?:string
optional: the value's unit as the device names it, e.g. "W" or "°C"; dropped when longer than 32 characters
Webhooks
HTTP routes the plugin registers with this.http, reachable at /hook/<slug>/....
HttpHandler
HttpHandler = (
request) =>HttpResponse|Promise<HttpResponse>
A webhook handler; when it throws (or answers with something that is not a valid HttpResponse), the caller gets 502 and the error goes to the server log. The host waits up to 30 seconds for the answer. The plugin sees at most 8 calls at once per instance (more get 429).
Parameters
request
Returns
HttpResponse | Promise<HttpResponse>
PluginHttp
Webhook routes, reachable at /hook/
Methods
route()
route(
method,path,handler):void
Parameters
method
string
e.g. "POST" (case-insensitive), or "*" for any method
path
string
below the instance, e.g. "/github"; ending in "/" it matches every path below
that prefix ("/a/" matches "/a/" and "/a/b", not "/a"; "/" matches all). The most specific route wins: exact before prefix, longer
prefix before shorter, a named method before "". The handler sees the full request.path.
handler
Returns
void
Throws
Error when the method (case-insensitive) and path are already registered
HttpRequest
HttpRequest =
z.infer<typeofHttpRequestSchema>
An HTTP request to the plugin's webhook routes; path is below /hook/
HttpResponse
HttpResponse =
z.input<typeofHttpResponseSchema>
the input type: headers and body may be omitted, the runtime fills them in
Lifetimes and time
Everything with a lifetime extends Disposable: timers, listeners and children registered through it are cleaned up for you. Clock is the time it runs on, so tests can move time by hand.
Clock
Time and timers for a Disposable's every() and after(), and for plugin code that asks what time
it is (this.clock.now() instead of Date.now()). The runtime gives a plugin the system clock;
tests can give it a TestClock (@lox-nexus/plugin-sdk/testing) and move time by hand.
Methods
now()
now():
number
milliseconds since the epoch, like Date.now()
Returns
number
setTimeout()
setTimeout(
fn,ms):unknown
like the global setTimeout; the handle is only meant for clearTimeout
Parameters
fn
() => void
ms
number
Returns
unknown
clearTimeout()
clearTimeout(
handle):void
Parameters
handle
unknown
Returns
void
setInterval()
setInterval(
fn,ms):unknown
like the global setInterval; the handle is only meant for clearInterval
Parameters
fn
() => void
ms
number
Returns
unknown
clearInterval()
clearInterval(
handle):void
Parameters
handle
unknown
Returns
void
Teardown
Teardown = () =>
void|Promise<void>
Cleanup callback registered with a Disposable. May be async; errors are collected, not swallowed.
Returns
void | Promise<void>
DisposableLike
The minimal shape of something with a lifetime; lets own() accept objects that are not Disposable subclasses.
Methods
dispose()
dispose():
void|Promise<void>
Returns
void | Promise<void>
Listener
Listener = (...
args) =>void
An event listener as listen() takes it. Typed with any[] like Node's EventEmitter, so emitters fit.
Parameters
args
...any[]
Returns
void
Listenable
Structural subset of EventEmitter (and similar) needed by listen().
Methods
on()
on(
event,listener):unknown
Parameters
event
string | symbol
listener
Returns
unknown
off()
off(
event,listener):unknown
Parameters
event
string | symbol
listener
Returns
unknown
Watching signals
For plugins that receive the values of signals the user chooses (exporters), declared with the
static watches.
WatchView
The signals the user chose for a watching plugin; empty for others and until the host sends them.
Methods
signals()
signals():
object[]
Returns
object[]
signal()
signal(
id): {id:string;topic:string;label:string;valueType:"string"|"number"|"boolean"|"null"|"singleChannel"|"rgbw"|"tunableWhite"|"t5"|"any";owner?: {kind:"miniserver"|"plugin";id:string; };unit?:string;decimals?:number; } |undefined
Parameters
id
string
Returns
Type Literal
{ id: string; topic: string; label: string; valueType: "string" | "number" | "boolean" | "null" | "singleChannel" | "rgbw" | "tunableWhite" | "t5" | "any"; owner?: { kind: "miniserver" | "plugin"; id: string; }; unit?: string; decimals?: number; }
id
id:
string
topic
topic:
string
its bus topic, e.g. loxone/home/in/temperature; changes when the signal or its owner is renamed
label
label:
string
valueType
valueType:
"string"|"number"|"boolean"|"null"|"singleChannel"|"rgbw"|"tunableWhite"|"t5"|"any"
owner?
optionalowner?:object
the Miniserver or plugin instance it belongs to
owner.kind
kind:
"miniserver"|"plugin"
owner.id
id:
string
unit?
optionalunit?:string
the unit the user gave the signal, e.g. "W"; absent when none (and from older hosts)
decimals?
optionaldecimals?:number
the decimals the user wants its numbers shown with; absent when not set
undefined
WatchDeclaration
WatchDeclaration =
z.infer<typeofWatchDeclarationSchema>
What a plugin that watches signals declares (static readonly watches): shown to the user where
they choose the signals it receives.
WatchedSignal
WatchedSignal =
z.infer<typeofWatchedSignalSchema>
A signal the user chose for a watching plugin: any signal of Nexus, a Miniserver's or another plugin's.
WatchedValue
WatchedValue =
z.infer<typeofWatchedValueSchema>
One value of a watched signal, as subscribers of the bus saw it.