> An integration is a GitHub repository (or a folder in one) with a localassistant-plugin.json manifest and the code it names. LocalAssistant builds the setup forms from the…
> Source: https://orochibraru.com/localassistant/canary/docs/plugins · Site index: https://orochibraru.com/llms.txt

# Writing an integration

An integration is a GitHub repository (or a folder in one) with a
`localassistant-plugin.json` manifest and the code it names. LocalAssistant
builds the setup forms from the manifest, shows each device on one of its
built-in cards, and calls your code to connect, find, read and drive devices.

Write it in TypeScript, Go or Python. Each has an SDK and the same example, a
Tasmota plug, under
[`sdk/`](https://github.com/orochibraru/localassistant/tree/main/sdk):

| Language   | Runtime   | SDK                                            | Example                   |
| ---------- | --------- | ---------------------------------------------- | ------------------------- |
| TypeScript | `bun`     | `sdk/typescript/localassistant-plugin.ts`      | `sdk/typescript/example/` |
| Go         | `process` | `github.com/orochibraru/localassistant/sdk/go` | `sdk/go/example/`         |
| Python     | `process` | `sdk/python/localassistant_plugin.py`          | `sdk/python/example/`     |

The official integrations in
[`src/plugins/`](https://github.com/orochibraru/localassistant/tree/main/src/plugins)
use the same manifest and hooks; read them for real-world examples.

## The manifest

```json
{
  "id": "tasmota",
  "name": "Tasmota plugs",
  "description": "Plugs running Tasmota, turned on and off over your network.",
  "author": "Someone",
  "version": "1.0.0",
  "localassistant": "1.1.0",
  "runtime": "bun",
  "entry": "dist/index.js",
  "icon": "plug",
  "needs": [],
  "device": {
    "title": "Add a Tasmota plug",
    "description": "Give the plug a fixed IP in your router.",
    "submit": "Add the plug",
    "fields": [{ "name": "host", "label": "IP address", "type": "text" }]
  },
  "kinds": [
    {
      "id": "tasmota-plug",
      "label": "Tasmota plug",
      "card": "switch",
      "icon": "plug",
      "polled": true
    }
  ]
}
```

| Key              | What it is                                                                                                                     |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `id`             | Lowercase letters, digits and dashes. Unique across installed integrations.                                                    |
| `version`        | Shown to admins. Releases are what updates follow.                                                                             |
| `localassistant` | The oldest LocalAssistant it runs on, `x.y.z`. Older instances refuse to install it.                                           |
| `runtime`        | `bun` (TypeScript or JavaScript, in the server) or `process` (anything else, its own process). Default `bun`.                  |
| `entry`          | The file to start: one `.js` bundle for `bun`; a script or binary for `process`, or one binary per platform.                   |
| `interpreter`    | `python3` for a Python script. Leave it out for a binary.                                                                      |
| `files`          | Other files to download next to the entry, like the Python SDK.                                                                |
| `icon`           | One of `blinds bot car cctv door-open fan gauge heater lightbulb lock plug printer puzzle smartphone speaker thermometer zap`. |
| `needs`          | `["mqtt"]` when it uses the household's MQTT broker: its sheet asks for the broker first.                                      |
| `connection`     | An account or server to connect once: `description`, `connected` (shown once connected), `fields`, `find` (button).            |
| `device`         | A form per device: `title`, `description`, `fields`, `submit`.                                                                 |
| `automatic`      | Text for integrations whose devices show up on their own.                                                                      |
| `kinds`          | The device kinds it provides. Each `id` is the plugin id or starts with it and a dash.                                         |

Per-platform binaries, for Go:

```json
"entry": { "linux-x64": "dist/linux-x64", "linux-arm64": "dist/linux-arm64" }
```

Platforms are `linux-x64`, `linux-arm64`, `darwin-x64` and `darwin-arm64`. The
Docker image is Alpine: build static binaries (`CGO_ENABLED=0`).

### Fields

| Key                          | What it is                                                                                                                                |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `name`                       | Letters and digits. Not `id`, `name`, `roomId` or `plugin`.                                                                               |
| `label`                      | What the form shows.                                                                                                                      |
| `type`                       | `text`, `url`, `secret`, `headers`, `number`, `boolean` or `choice` (see below).                                                          |
| `choices`                    | `[{ "value", "label" }]` for a choice: two to seven show as buttons, more as a list. Leave it out to supply them from the `choices` hook. |
| `default`                    | Used when the field is left empty.                                                                                                        |
| `optional`                   | It may stay empty.                                                                                                                        |
| `min`, `max`                 | Limits of a number.                                                                                                                       |
| `pattern`                    | A regular expression the value must match, with `message` saying what's wrong.                                                            |
| `keyboard`                   | `decimal`, `url` or `email`, for phones.                                                                                                  |
| `description`, `placeholder` | Help under and inside the field.                                                                                                          |

The device form also asks for a name and a room; you don't declare those.

- `url` takes an `http://` or `https://` address, internal names and IPs
  included (`http://frigate:5000`, `http://192.168.1.10:5000`), normalized and
  without a trailing slash. Saving checks the server can resolve its host.
- A `choice` without `choices` asks your `choices` hook when its form opens and
  shows what it answers in a searchable list; saving checks the value against a
  fresh answer. Use it for what only the service knows, like the devices a
  router sees.
- `secret` is stored encrypted and never shown again: "leave empty to keep".
- `headers` is for a proxy or firewall in front of a service, like Pangolin or
  an API gateway: one `Name: value` per line, stored encrypted like a secret.
  Turn it into a record with `requestHeaders(value)` (TypeScript),
  `localassistant.RequestHeaders(value)` (Go) or `request_headers(value)`
  (Python) and send it with every request.

### Kinds and cards

A kind shows on one of LocalAssistant's cards, and its devices report the
matching state:

| `card`     | State                                                                              | Commands it sends |
| ---------- | ---------------------------------------------------------------------------------- | ----------------- |
| `cover`    | `{ type: "cover", position }` (0 closed, 100 open, or null)                        | `cover`           |
| `climate`  | `{ type: "climate", power, mode, modes, targetTemperature, roomTemperature, ... }` | `climate`         |
| `switch`   | `{ type: "switch", on, power, energy }`                                            | `switch`          |
| `valve`    | `{ type: "valve", target, temperature, battery, setpoint, min, max, ... }`         | `valve`           |
| `contact`  | `{ type: "contact", open, battery }`; opening one triggers routines                |                   |
| `meter`    | `{ type: "meter", power, energy }`, watts and kWh; counted in Energy               |                   |
| `linky`    | `{ type: "linky", day, kilowattHours }`, a daily meter                             |                   |
| `printer`  | `{ type: "printer", stage, progress, ..., camera }`                                | `printer`         |
| `vacuum`   | `{ type: "vacuum", stage, battery, ... }`                                          | `vacuum`          |
| `camera`   | `{ type: "camera", streaming, recording, live, snapshot, source, ... }`            |                   |
| `light`    | `{ type: "light", on, brightness, temperature, color, features, ... }`             | `light`           |
| `lock`     | `{ type: "lock", locked, jammed, battery }`                                        | `lock`            |
| `fan`      | `{ type: "fan", on, speed, speedSteps, oscillating }`                              | `fan`             |
| `media`    | `{ type: "media", power, playback, title, artist, volume, source, sources, ... }`  | `media`           |
| `sensor`   | `{ type: "sensor", readings: [{ name, value, unit }] }`                            |                   |
| `button`   | `{ type: "button", pressedAt }`; a tap presses it                                  | `button`          |
| `presence` | `{ type: "presence", home, zone, battery }`; counts for its owner in Who's home    |                   |

A light's `features` lists `brightness` (0 to 100), `temperature` (kelvin,
between `minTemperature` and `maxTemperature`) and `color`
(`{ hue, saturation }`) when it has them; a fan's `speedSteps` is 0 when it has
no speeds. The SDKs carry every state's full type. `houseMeter: true` makes a
meter count as the whole house in Energy; `polled: true` has the worker call
`read` every two minutes; `size` (`small`, `medium`, `large`) and `order` set
the card's default size and place.

## Hooks

Implement only what you need. Every hook gets the context (below).

| Hook       | Called when                                                                                                                                                                                           | Returns                                   |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `connect`  | An admin saves the connection form. Throw to refuse it with a message.                                                                                                                                | nothing                                   |
| `discover` | Right after `connect`, and on the sheet's find button.                                                                                                                                                | found devices                             |
| `add`      | An admin adds a device from the device form, or edits one added that way. A discovered device, none of whose `fields` are the form's, is only renamed and moved.                                      | the device (`kind`, `externalId`, `host`) |
| `test`     | An admin taps Test on the device form before adding: try the values without saving anything. Throw with what's wrong.                                                                                 | what it found, one sentence               |
| `start`    | The worker starts (and after settings change): open connections, subscribe, start timers.                                                                                                             | nothing                                   |
| `stop`     | The worker stops or restarts the plugin.                                                                                                                                                              | nothing                                   |
| `read`     | Every two minutes for polled kinds, and soon after a device is added.                                                                                                                                 | its state                                 |
| `command`  | Someone taps a control. `{ type: "cover", move }`, `{ type: "climate", change }`, `{ type: "switch", on }`, `{ type: "valve", target }`, `{ type: "printer", change }`, `{ type: "vacuum", change }`. | the new state, or nothing                 |
| `serve`    | A page asks for `/media/<device>/<path>`: camera streams, snapshots, clips.                                                                                                                           | a response                                |
| `clips`    | A camera's page opens.                                                                                                                                                                                | `{ title, empty, clips }`                 |
| `choices`  | A form with a `choice` field that lists no `choices` opens, and when it's saved; it gets the field's `name`. Throw with what's wrong: it shows under the field.                                       | `[{ value, label }]`                      |

Throw (or return an error) with a sentence a person can act on: it's shown as it
is, on the card or in the form.

## The context

| Call                                | Does                                                                    |
| ----------------------------------- | ----------------------------------------------------------------------- |
| `settings()`                        | The connection's values, secrets decrypted, or null.                    |
| `devices()`, `device(id)`           | The plugin's own devices, with `fields` and `secret(name)`.             |
| `found(devices)`                    | Adds or updates discovered devices, keeping names and rooms people set. |
| `saveState(device, state)`          | Records a pushed state.                                                 |
| `saveError(device, message)`        | Records why a device can't be reached.                                  |
| `refreshLater(device, delay)`       | Reads a device again later, after a blind stopped moving.               |
| `notify({ title, body, audience })` | Pushes the admins or everyone.                                          |
| `mqtt.subscribe`, `mqtt.publish`    | The household's MQTT broker, shared with the other integrations.        |
| `energy.readings`, `energy.record`  | A meter's stored counter, for history fetched online.                   |
| `folder(...parts)`                  | A folder of its own under the data folder.                              |
| `logger`                            | The server log.                                                         |

TypeScript also gets `every(ms, task)` (a timer stopped for you) and
`sendFile(path, request, cacheControl)` (a file with Range support). Go and
Python plugins run their own timers.

## TypeScript

Copy `sdk/typescript/localassistant-plugin.ts` into your repository, write the
plugin and bundle it with its dependencies into one ES module:

```bash
bun build src/index.ts --target=bun --format=esm --outfile dist/index.js
```

Commit `dist/index.js` (or build it in CI before tagging a release). It runs
inside the LocalAssistant server and worker, so keep timers in `every` and
always catch in fire-and-forget promises: an unhandled rejection stops the
process.

## Go

```bash
go get github.com/orochibraru/localassistant/sdk/go
```

Implement the hooks as methods (`Add`, `Read`, `Command`, ...) and call
`localassistant.Run(plugin)` from `main`. Build one static binary per platform
and list them in `entry`:

```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/linux-x64 .
```

```bash
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o dist/linux-arm64 .
```

## Python

Copy `sdk/python/localassistant_plugin.py` next to your script and list it in
`files`; set `"interpreter": "python3"`. Write a class with the hooks
(`add(context, values)`, `read(context, device)`, ...) and call `run(Plugin())`.
Only the standard library is available in the Docker image.

## Publishing

1. Push the repository to GitHub, GitLab, Gitea, Forgejo or Codeberg. Public for
   everyone; a private one installs only where an admin saved a token for its
   site ([how](https://orochibraru.com/localassistant/canary/docs/integrations#add-a-private-integration)).
2. Tag it with the topic `localassistant-plugin` so it shows up under **Browse**
   (GitHub, gitlab.com and Codeberg are searched).
3. Publish releases: LocalAssistant installs and updates from the latest
   release, or from the default branch when there's none. A link to a folder
   (`github.com/you/repo/tree/main/plugins/lamps`,
   `gitlab.com/you/repo/-/tree/main/plugins/lamps`) installs from that folder.

While writing one, install it from a folder on the server instead
([how](https://orochibraru.com/localassistant/canary/docs/integrations#add-one-from-a-folder)): **Check for updates** picks up
each rebuild.

Admins see how maintained it is from when you last pushed: keep it alive, or
archive it when you stop.

## The process protocol

Go and Python plugins (and anything else with `"runtime": "process"`) speak
JSON-RPC 2.0, one message per line, on standard input and output. Standard error
goes to the server log. The SDKs implement all of it; this is for other
languages.

LocalAssistant starts the process from its folder and calls `initialize`
(`{ protocolVersion: 1, folder }`), expecting `{ hooks: [...] }` back: the hooks
it implements. It then calls the hooks by name with these params: `connect`,
`add` and `test` get `{ values }`; `read`, `clips` and `serve` get `{ device }`
(`serve` also `path`, `method`, `headers`, and answers
`{ status, headers, body }` with the body in base64); `command` gets
`{ device, command }`; `choices` gets `{ field }`, the field's `name`, and
answers `[{ value, label }]`. A device has `id`, `kind`, `name`, `externalId`,
`host`, `homeId`, `roomId`, `fields`, `state`, `stateAt` and `secrets`, its
secrets decrypted.

The plugin calls the host the same way, while a hook runs or any time after
`start`: `settings`, `devices`, `device` (`{ id }`), `found` (`{ devices }`),
`saveState` (`{ deviceId, state }`), `saveError` (`{ deviceId, message }`),
`refreshLater` (`{ deviceId, delayMs }`), `notify`, `folder` (`{ parts }`),
`mqtt.configured`, `mqtt.subscribe` (`{ filter }`), `mqtt.publish`
(`{ topic, payload }`), `energy.readings` (`{ deviceId }`) and `energy.record`
(`{ deviceId, hour, total }`). A `log` notification
(`{ level, message, context }`) writes to the server log; subscribed MQTT
messages arrive as `mqtt.message` notifications (`{ topic, payload }`, payload
in base64).

Answer within a minute. A process that exits is started again on the next call,
and `start` is called again if it was running.
