Writing add-ons

Host API

Every import and export of the plugin interface, with examples.

Interface 0.3: 0.1 plus mqtt and its two exports (0.2.0), config.time-zone and devices.set-room-light (0.2.1), and secrets, inbound and three exports for accounts and webhooks (0.3). The examples compile against niles-integration.wit with wit-bindgen 0.63; the WIT file is the authority. A plugin built for 0.2 has everything here but secrets, inbound and their exports, and one built for 0.1 not MQTT or the 0.2.1 functions either; see interface versions.

A plugin imports functions Niles provides and exports functions Niles calls. Examples are Rust; in JavaScript and Python the names are the same in camelCase and snake_case (see the worked example).

Rules that hold for every call:

  • One call at a time. Niles never calls into your plugin twice at once, and never while your plugin is inside a host call. Global state needs no locks; thread_local! with a RefCell, or a static Mutex, is fine.
  • Calls have deadlines: 10 s for most exports, 30 s for init, 3 s for render, 2 s for stop. Time spent waiting in host calls counts. Past the deadline the instance is thrown away and restarted with backoff.
  • Your instance can disappear after a trap, a timeout, a settings change or an update. Anything that must survive goes in storage.

In Rust, records passed to imports are borrowed when they contain strings or lists (&Request, &Device, and &DeviceState, whose readings is a list) and passed by value when they do not. wit-bindgen does not derive Default; keep an empty_state() helper for DeviceState, with readings: Vec::new() (see the worked example).

Exports: what you implement

init() -> result<_, string>

Called once per instance: when the plugin starts, after every settings change, after a restart. 30 seconds. Read your settings, register devices, set timers, subscribe to events. Do not assume a fetch will succeed here; a device that is off at boot should leave you "working, device unavailable", not "not working".

An err becomes the plugin's status line, so write it for the person reading the card: Err("The address is not set.".into()). Niles retries when settings are saved and every 10 minutes.

tools() -> list<tool-def>

Read after each successful init. Return Vec::new() for none. See tools.

call-tool(name, args-json) -> result<string, string>

name is the bare name from your tool-def (prices, not spotprice_prices). args-json is a JSON object the model produced against your schema; parse it, and do not trust it further than that. ok is what the model reads; err is told to the model as the tool's failure, so it can say "The bus service isn't answering" rather than nothing.

set-device(id, desired) -> result<_, string>

For your controllable devices only. desired holds just the fields to change. Send the command, and once the device confirms, report the new state with devices::report_state; the app shows the change as soon as it is asked for and settles it when you report. See devices.

on-timer(id)

A timer you set fired. If the previous call for the same timer id is still running, this one is skipped rather than queued, so a slow poll cannot pile up.

on-event(event)

An event you subscribed to. See events.

fn on_event(event: Event) {
    match event {
        Event::DeviceState(change) if change.state.open == Some(true) => {
            // change.id is the full id, e.g. "z2m:office/door"
        }
        Event::DeviceAction(press) => { /* press.id, press.action */ }
        Event::Presence(HomeState::Away) => { /* nobody home */ }
        _ => {}
    }
}

render(surface-id, context) -> result<ui-tree, string> and on-action(...)

Pages and widgets. See pages and widgets.

on-mqtt-message(conn, topic, payload) and on-mqtt-status(conn, status)

New in 0.2. A message on a topic one of your MQTT subscriptions matches, and a change in a connection's state: connected, disconnected(why) while Niles reconnects, failed(why) when it stopped trying, certificate-changed when a device's certificate is not the one trusted before. A plugin that uses no MQTT still exports both, empty. See MQTT.

authorize-url(callback-url, state), on-callback(callback-url, query, person) and on-webhook(request)

New in 0.3: connecting an account through a service's sign-in page, and answering a webhook. A plugin that does neither still exports all three: authorize-url returns an err, and on-webhook a 404. See accounts and webhooks.

stop()

The instance is about to go: Niles is stopping, or the plugin is being updated, disabled, removed or restarted for new settings. Two seconds. Nothing is guaranteed to run after it, and a crash skips it, so do not rely on it to save anything important.

Imports: what Niles provides

log

use niles::integration::log;

log::log(log::Level::Info, "polling the inverter");

Levels Debug, Info, Warn, Error. Lines go to Niles's log, tagged with your id, and to your card's log in the app (the last 1,000). At most 100 lines per 10 s and 2 KiB each; beyond that lines are counted, not kept. Stdout and stderr (println!, eprintln!, a panic message) also land here, as info and warn.

config

use niles::integration::config;

let address = match config::get("address") {
    Some(config::SettingValue::Text(address)) => address,
    _ => return Err("The address is not set.".into()),
};
let key: Option<String> = config::secret("api_key");

get returns what the user entered, else the manifest's default, else None. choice and room settings come back as Text. secret returns secret settings only; get returns None for them. Asking for a key the manifest does not declare returns None and logs a warning, which is how typos show up.

let zone: String = config::time_zone(); // "Europe/Copenhagen"

time_zone (0.2.1) is the house's time zone, an IANA name, from Niles's own configuration ([home] timezone), so a plugin can say "done at 14:30" in the household's clock without a setting of its own. WASI's clock is UTC; convert with a time-zone crate such as chrono-tz, or jiff, built for wasm32-wasip2. Read it when you need it rather than once in init: the household can change it while your plugin runs. Under niles plugin dev it is your machine's zone, or --time-zone's.

storage

A private key-value store of strings, kept in Niles's database. Nobody else can read it; removing the plugin deletes it.

use niles::integration::storage;

storage::set("cursor", "2026-10-14T12:00:00Z").map_err(|e| format!("{e:?}"))?;
let cursor: Option<String> = storage::get("cursor");
for key in storage::list_keys("day:") {
    storage::delete(&key);
}

Keys up to 256 bytes. All keys and values together up to 1 MiB; a set that would go over fails with HostError::Limit. Keys starting with .niles/ are Niles's own, kept for your plugin (the certificates MQTT trusts): they are not listed, and reading or writing one fails, but they count towards the 1 MiB. Store JSON with serde_json when you need structure. list_keys returns keys in sorted order, so date-stamped keys (day:2026-10-14) list oldest first.

http

One request, the whole response.

use niles::integration::http;

let response = http::send(&http::Request {
    method: http::Method::Post,
    url: "https://api.example.com/v1/login".into(),
    headers: vec![("content-type".into(), "application/json".into())],
    body: Some(br#"{"user":"me"}"#.to_vec()),
    timeout_ms: None,
});
match response {
    Ok(r) if r.status == 200 => { /* r.body is Vec<u8> */ }
    Ok(r) => return Err(format!("The service answered {}.", r.status)),
    Err(http::HttpError::Timeout) => return Err("The service did not answer in time.".into()),
    Err(e) => return Err(format!("{e:?}")),
}
  • The host must be in your manifest's http list, exactly or under a *.example.com wildcard, or covered by local_network. Otherwise: HttpError::NotPermitted.
  • After DNS, the address is checked again. Private, loopback and link-local addresses (192.168.x.x, 10.x.x.x, 127.0.0.1, 169.254.x.x, and their IPv6 kin) are refused unless local_network names them, whatever the host name says.
  • Internet hosts must be https. Plain http is allowed to local-network addresses only.
  • Redirects are followed, up to 5, each checked the same way.
  • A 404 or 500 is an Ok response with that status; only failures to get an answer are errors: Timeout, Connection (DNS, refused, TLS), TooLarge (over 4 MiB), InvalidRequest.
  • timeout_ms defaults to 5,000 and is cut to whatever is left of the current call's deadline.
  • Niles adds user-agent: niles-plugin/<id>/<version> unless you set one. No cookies are kept between requests; if a service needs a session, keep its token in storage.

Language-native clients (reqwest, fetch, Python's urllib.request) do not work: there are no sockets and no wasi:http/outgoing-handler. Use this.

devices

Your plugin's devices. Needs devices = true. Ids are room/name. Full treatment in devices.

use niles::integration::devices;
use niles::integration::types::{Device, DeviceClass, LightCapabilities};

devices::upsert(&Device {
    id: "living_room/floor_lamp".into(),
    class: DeviceClass::Light,
    light: LightCapabilities { color_temp: true, rgb: false },
    controllable: true,
    state: empty_state(),
})
.map_err(|e| format!("{e:?}"))?;

let mut state = empty_state();
state.on = Some(true);
state.brightness = Some(60);
devices::report_state("living_room/floor_lamp", &state);

devices::report_action("hall/button", "single");
devices::report_action("office/printer", "finished");
devices::set_available("living_room/floor_lamp", false);
devices::set_room_light("office/chamber_light", false);
devices::remove("living_room/old_lamp");
let mine: Vec<String> = devices::registered();

Any class may report_action, not only a switch (actions). set_room_light (0.2.1) keeps a light out of its room's lights: room and house commands, scenes, presence, the curve and the morning routine (lights and the room).

timers

use niles::integration::timers;

timers::every("poll", 60);    // on_timer("poll") every 60 s, first in 60 s
timers::after("retry", 30);   // once, in 30 s
timers::cancel("retry");

every is at least 5 s, after at least 1 s. At most 32 timers. A timer with the same id replaces the old one. Timers do not survive a restart of the instance, which always runs init again, so set them there. If you want a first run straight away, call your handler at the end of init.

events

Things happening elsewhere in the house, delivered to on_event. Each kind must be listed under events in the manifest's permissions.

use niles::integration::events;
use niles::integration::types::EventKind;

events::subscribe(EventKind::DeviceState, &["office/*".to_string()]);
events::subscribe(EventKind::Presence, &[]);
events::unsubscribe(EventKind::DeviceAction);
  • DeviceState: another device's state changed. You get its full id (z2m:office/door), class, and only the fields that changed — including only the readings that changed, so a plug's power arrives without its energy.
  • DeviceAction: a button or wall switch was pressed. action uses Zigbee2MQTT's words: single, double, hold, on_press, up_hold…
  • Presence: the house became Home, Away or Unknown. Never who.

The patterns narrow device events by room/name: office/door, office/*, */door; an empty list means every device. You never get events for your own devices. At most 256 events wait for you; past that the oldest are dropped and the count is shown on your card, so keep on_event quick.

notify

Speak through the satellites, by way of Niles's notifications. Needs notify = true; Urgent needs notify = "urgent".

use niles::integration::notify;

notify::say("The washing machine is done.", notify::Priority::Normal, Some("kitchen"));
  • Low waits out quiet hours and is replayed in the morning; Normal plays quietly during them; Urgent plays at normal volume whatever the hour.
  • room picks the room; None lets Niles choose (normally the satellite last spoken to).
  • One or two short sentences, the important part first. It is spoken by a text-to-speech voice: write numbers and units as words would be read ("twenty degrees"), skip symbols and URLs.
  • At most 6 an hour. Remember what you said (in storage) so a restart does not say it again.

ui

The node types for pages and widgets, and one function:

use niles::integration::ui;

ui::invalidate("electricity");

which asks every app showing that surface to render it again. Cheap; see pages and widgets.

mqtt

New in 0.2. Connections to the brokers your manifest's mqtt permission names, held by Niles: it reconnects with a backoff and subscribes again, and nothing here waits on the network.

use niles::integration::mqtt::{self, ConnectOptions, Qos};
use niles::integration::types::HostError;

let conn = mqtt::connect("printer", &ConnectOptions {
    client_id: None,
    username: Some("bblp".into()),
    password: config::secret("access_code"),
    keep_alive: None,
    clean_session: None,
})
.map_err(|e| format!("{e:?}"))?;
let fail = |e: HostError| format!("{e:?}");
mqtt::subscribe(conn, "device/+/report", Qos::AtMostOnce, false).map_err(fail)?;
let pushall = br#"{"pushing":{"sequence_id":"0","command":"pushall"}}"#;
mqtt::publish(conn, "device/01S00A/request", pushall, Qos::AtMostOnce, false).map_err(fail)?;
mqtt::unsubscribe(conn, "device/+/report").map_err(fail)?;
mqtt::accept_certificate(conn).map_err(fail)?; // only after asking the household
mqtt::disconnect(conn);

At most 4 connections, 64 subscriptions each, 256 KiB a message, 20 publishes a second. publish fails with unavailable while the connection is down. The full story, with the address rules and trust-device certificates, is in MQTT.

secrets

New in 0.3. Credentials the plugin keeps itself, such as an OAuth token it refreshed, in Niles's encrypted secret store. storage is plain text in Postgres: it is no place for a token.

use niles::integration::secrets;

secrets::set("token", &serde_json::to_string(&pair)?).map_err(|e| format!("{e:?}"))?;
let pair: Option<String> = secrets::get("token");
secrets::delete("token").map_err(|e| format!("{e:?}"))?;
  • set is atomic. It returns ok once the value is kept; on err the previous value is still there. Keep what changes together (an access token, its refresh token, when it expires) in one value, as JSON.
  • Keys are [a-z0-9_]+, at most 64 bytes; at most 16 keys, of at most 4 KiB each. Past either, limit; the store not answering, unavailable.
  • Not your settings: nothing here appears on the setup panel, and config.secret reads only the secret settings the household typed in.
  • Removed with the plugin. Without a secret store (no database) they are kept in memory and lost on a restart.

inbound

New in 0.3, with the webhook permission.

use niles::integration::inbound;

let address: Option<String> = inbound::hook_url();
// "https://home.example.dk/plugins/withings/hook/9f2c…"

The webhook's full address, to give the service that calls it, from init. None until the household sets Niles's public address; say so in your status line. See accounts and webhooks.

WASI

Toolchains import parts of WASI 0.2 whether you use them or not, so Niles provides them, empty. What works: the wall clock and monotonic clock (SystemTime::now(), Instant), randomness, stdout and stderr (to your log). What does not: files (no directories exist), environment variables (none), sockets and DNS (refused), std::process, threads.

Coming later

Not yet:

  • WebSockets, held by Niles, which reconnects them and delivers each message to a new export, for devices and services that push.
  • Local TCP and UDP: one request, one reply, to an address under local_network, for devices that speak something other than HTTP.
  • wasi:http/outgoing-handler, so that fetch and other native clients work under the same checks.
  • A page of your own: your static HTML in a sandboxed iframe, under a separate permission.
  • Reading other devices' state on demand, not only through events.