Writing add-ons

Publishing

Release layout, the add-on library, versioning and updates.

Interface 0.2, and 0.1. niles plugin check and installing, updating and rolling back from GitHub releases are built (Niles 0.2.0), as described here. The add-on library (Browse) and install counts are built (Niles 0.5.0).

A plugin is published as a GitHub release on a public repo. Anyone with the repo's URL can install it, and once the repo has the topic niles-addon people also find it in the app, under Add-ons → Browse (see the add-on library).

Release layout

The release Niles installs is the repo's latest release: not a draft, not marked pre-release. (Somebody can install another by pasting its address, https://github.com/you/your-repo/releases/tag/v0.2.0.) Its assets must include:

AssetRequiredWhat it is
niles-integration.tomlyesThe manifest, at most 64 KiB. Its version is the release tag, with or without a v.
integration.wasmyesThe component, at most 64 MiB.
logo.svgif the manifest names oneThe logo, at most 32 KiB. The asset is found by the file name of the manifest's logo, so logo = "assets/logo.svg" is the asset logo.svg.
niles-installedto be listedA few bytes, at most 1 KiB, any content. Niles downloads it once per house after a fresh install, so its download count is your number of installs.

Each cap holds whatever GitHub reports as the asset's size: a download is cut off at it.

Other assets are ignored by the install. The release notes (the release's body) are shown to the household when they are offered the update, so write them for people: what changed for them, not the commit log.

The workflow

The template's .github/workflows/release.yml builds and publishes on a version tag:

name: release

on:
  push:
    tags: ["v*"]

permissions:
  contents: write

env:
  CRATE: niles_spotprice   # the crate name, with _ for -

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-wasip2

      - name: Tag matches the manifest
        run: |
          version=$(grep -m1 '^version' niles-integration.toml | cut -d '"' -f2)
          if [ "v$version" != "$GITHUB_REF_NAME" ]; then
            echo "Tag $GITHUB_REF_NAME but the manifest says $version" >&2
            exit 1
          fi

      - name: Build
        run: cargo build --release --target wasm32-wasip2

      - name: Collect
        run: |
          cp "target/wasm32-wasip2/release/$CRATE.wasm" integration.wasm
          # Niles downloads this once per house after a fresh install:
          # its download count is your install count.
          printf 'Niles counts installs of this add-on by this file.\n' > niles-installed

      - name: Release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release create "$GITHUB_REF_NAME" \
            --title "$GITHUB_REF_NAME" \
            --generate-notes \
            integration.wasm niles-integration.toml logo.svg niles-installed

Once niles publishes release binaries, a niles plugin check . step before Release is worth adding. niles plugin check --listing you/your-repo checks a published release as the add-on library's listing check does.

For JavaScript, Python or Go, replace the toolchain and build steps with jco componentize, componentize-py or tinygo build, and keep the rest.

Versioning your plugin

Semver, judged by what the household sees:

  • Patch (0.1.0 → 0.1.1): fixes. Same tools, devices, settings and permissions.
  • Minor (0.1.1 → 0.2.0): new tools, devices, settings or surfaces; new permissions.
  • Major (0.2.0 → 1.0.0): anything that breaks what the household built on it: a device renamed or removed (their rules name it), a setting renamed (its value is lost), a tool renamed.

Never rename a device id lightly: rules, scenes and the app refer to it. Never change the plugin's id: that is a different plugin to Niles, installed beside the old one, with none of its settings, secrets or storage.

How updates reach people

  • Niles asks each installed plugin's repo for its releases every 6 hours, first 2 minutes after it starts, and when someone presses Check for updates on its panel.
  • The newest release whose tag is a higher semver version than the installed one shows as Update available, with its version, release notes and the change in permissions (what is added, what is removed). A pre-release is offered only to someone already on a pre-release, so mark betas as pre-releases on GitHub. Nothing updates by itself.
  • Updating downloads the release and runs the same checks as an install, and the household sees the difference before they confirm. New required settings put the plugin in "Not set up" until they are filled in.
  • The previous version is kept. If the new one traps, times out or will not load within 10 minutes of the update, Niles puts the previous one back by itself and says so on the panel; init returning an error does not count, since that is your plugin saying what is wrong. The household can also roll back by hand, once.

Storage and settings carry across updates. If your stored data's shape changes, read the old shape too, or keep a version number in storage and migrate in init.

The add-on library

The app lists what other people published under Add-ons → Browse, from one file the registry repo (MarkNygaard/niles-addons) builds from GitHub's public data. You don't upload anything; your repo's releases are the truth.

Being found

Give the repo the topic niles-addon, under the repo's About → Topics, or with gh repo edit --add-topic niles-addon. The library picks it up within six hours, as Unreviewed.

Getting listed

Open a pull request on MarkNygaard/niles-addons adding your repo to addons.toml:

[[addon]]
repo = "you/your-repo"

The check runs niles plugin check --listing you/your-repo on your latest release (everything an install checks, plus niles-installed), and puts the permissions your add-on asks for in its summary. Mark reads them and merges; then it is Listed.

Listed means it passed the checks and someone read what it asks for. It isn't an audit of your code, later releases aren't reviewed again, and every household still approves its permissions on install.

Install counts

The library shows your installs: GitHub's download count of niles-installed, summed over your releases. A preview, an update or installing the same add-on again doesn't count, and nothing about the household is sent. It's a total, not active installs: removals aren't counted. Without niles-installed the library shows your component's downloads instead, labelled as downloads, which counts previews and updates.

Leaving the library

Remove the topic, or open a pull request removing your entry. An add-on taken off the list keeps working where it's installed, and its panel says it is no longer listed.

Pinning: why a release cannot change under people

When somebody previews an install or an update, Niles downloads your assets, computes their sha256 and holds them for 15 minutes. Where GitHub reports a digest for an asset, the two must agree. Installing takes exactly those bytes, checked against the sha256 the preview showed, and keeps them in Niles's own database; nothing is downloaded again, at install or at any restart after. Replacing an asset on an existing release changes nothing for anyone who installed it; publish a new release instead.

Which Niles versions you support

  • interface = "0.3" in the manifest and the WIT your component was built with say which interface you target, and they must agree: Niles reads the version your component's imports carry, and refuses a component built for 0.3 under a manifest that says 0.2, or the reverse. A Niles that runs 0.2 runs any 0.2.x component; patch versions of the interface only add.
  • min_niles is the oldest Niles your plugin needs: "0.3.0" for interface 0.3, and later when you use a function or UI node added after that.

Interface versions

InterfaceNilesWhat changed
0.30.3.0Accounts and webhooks: the secrets and inbound interfaces, the exports authorize-url, on-callback and on-webhook, and the callback and webhook permissions. Nothing 0.2.1 had changed.
0.2.10.2.1config.time-zone and devices.set-room-light. Any device class may report-action. Nothing 0.2.0 had changed.
0.2.00.2.0The mqtt interface and the exports on-mqtt-message and on-mqtt-status. Nothing else.
0.10.2.0The first.

How a patch version stays compatible, both ways:

  • Patch versions only add functions or interfaces. Nothing a component already imports changes: no record, variant or signature. The WIT's package version says the patch (niles:integration@0.2.1); the manifest's interface says only "0.2".
  • A component built against an older patch runs unchanged. It imports niles:integration/devices@0.2.0; Niles defines devices@0.2.1, and the WebAssembly linker (wasmtime's) treats versions that differ only in the patch of a 0.x as the same interface, so it links. The types are then checked function by function, for the functions the component imports only, and those have not changed. Niles's tests load a component built against 0.2.0 (relay.wasm) to hold it to that.
  • A component built against a newer patch needs a Niles that has it. Built against 0.2.1 and calling config.time-zone, it fails to load on a Niles that only has 0.2.0, naming the function it is missing. Raise min_niles to the first Niles with the patch, so the household is told before they install it rather than after.

Niles runs plugins built for the previous interfaces beside the current one for at least six months, and their panel says "Built for an older Niles". Moving a plugin from 0.2 to 0.3 takes a copy of the new WIT, interface = "0.3", min_niles = "0.3.0", and the three new exports, answering nothing if you connect no account and have no webhook:

fn authorize_url(_callback_url: String, _state: String) -> Result<String, String> {
    Err("This add-on connects no accounts.".into())
}
fn on_callback(_: String, _: Vec<(String, String)>, _: Option<String>) -> Result<String, String> {
    Err("This add-on connects no accounts.".into())
}
fn on_webhook(_request: InboundRequest) -> InboundResponse {
    InboundResponse { status: 404, content_type: None, body: Vec::new() }
}

From 0.1, add the two MQTT exports as well, empty if you use no MQTT:

fn on_mqtt_message(_conn: u32, _topic: String, _payload: Vec<u8>) {}
fn on_mqtt_status(_conn: u32, _status: ConnectionStatus) {}

The older interfaces are kept, frozen, as niles-integration-0.2.wit and niles-integration-0.1.wit.

Private repos, and GitHub's limit

Not yet. Installing from a private repo needs a GitHub token in Niles, which is planned but not specified. Without a token GitHub allows 60 API requests an hour from one address: a preview takes one or two, an update check one per plugin. Browsing the library takes none of them. When the limit is reached Niles says so, with the time it resets.