--- title: Addon contracts --- ## Status and trust The version-one contracts in `pkg/addons/contract` define managed-process addons. The owner installs, verifies, starts and supervises them, and a deployment with several frontends forwards every addon request to that owner over a pinned mutual TLS carrier. The routes are described in [Public API](#public-api), the Apps and Add-ons pages in [Apps](#apps), and Tables, the first first-party addon, in [Tables](../tables/). The first-party package is described in [First-party package and release channels](#first-party-package-and-release-channels); it is not published to a registry by any test or by acceptance. Trust has three parts, and they are different: - **Package identity.** A package is installed only after its digest matches the catalog entry and, when the library requires it, its signature matches the identity policy of that library. A library, a channel and a trusted signer never change by themselves, and an upgrade is refused when the trusted identity of the new package differs from the installed one. - **Executable code.** An installed executable is trusted code, not sandboxed code. It runs as the owner's operating system user, with the owner's file system and network. The host gives it a loopback port, a per-start credential and its own data directory; it does not confine it. Install only packages you trust. - **Authority.** An addon never decides who a caller is or whether a change was approved. The host supplies the actor from the authenticated session or key, decides every role, and applies a mutation from an API client, an MCP session or the OpenAI-compatible chat route only after the proposer's own browser session confirms it. The one exception is the chats on the server (`/api/chats`), their agents and their triggers: turning an add-on on for a chat or an agent is the consent, and the change is applied at once, with the credential of the chat turn, for the user who turned the add-on on, and with an undo where the add-on keeps one (see [Proposals and confirmation](#proposals-and-confirmation)). Prompt text, a model and an addon cannot supply that approval. Every addon requires an executable; a UI bundle alone is not an addon. ## Singleton owner and its limits Addon state lives in one place. Exactly one LocalAI instance is the **owner** of an addon root (its storage identifier and `Root` directory): it holds the journal of installed packages, the resource policy store, the proposals, the artifact store, the backups and the data of every addon, and it supervises the addon processes. The other instances are **frontends**: they authenticate the caller against the shared account store and forward the typed operation to the owner. The consequences are by design: - **No second owner.** A second process that opens the same root is refused while the first holds it; two owners are never active. There is no election and no automatic failover. Moving the owner to another machine is an operator action: stop the owner, move or mount the root, configure the new owner and start it. - **An owner outage is an addon outage.** While the owner is down, every frontend answers `503` for every response type (JSON, progress, status, file, asset and proposal calls) with the same typed error, and executes nothing locally. Nothing is queued or applied later. A mutation whose reply was lost is reported as `outcome_unknown` with its original operation ID and is resolved by receipt lookup after the owner is back, never by sending it again under a new ID. - **No state on frontends.** A frontend keeps no addon data, no addon process and no sticky session. Any frontend can serve any request, and a request may use another frontend than the previous one. - **One writer per database.** A Tables database is one SQLite file on the owner's disk with a single writer. Throughput is bounded by that machine, and the data is only as available as that disk and its backups. - **The addon process is local to the owner.** It listens on loopback only. The carrier between a frontend and the owner is the only network path, and it is authenticated in both directions by pinned certificates and signed requests. A deployment with one instance is the owner and has no frontend; everything above still holds. The `--addons-role` flag chooses how a process takes part. `combined` is the default and is both the owner and the only frontend. `owner` and `frontend` split them, as described in [Running the owner as its own workload](#running-the-owner-as-its-own-workload). **Distributed state.** In [distributed mode]({{% relref "features/distributed-mode" %}}), the owner is a single runner by design: the operator runs one owner replica, and the lock file in its root is the guard against a mistake, not a way to scale. The frontends are stateless for add-ons. The one exception is the add-on pins of a frontend that has no account database, which are per-instance state (see [Frontend limits](#frontend-limits)). ## Turning the owner on or off A normal `local-ai run` starts the addon owner, so Apps, Add-ons and Tables work without extra setup. The node is its own owner: it keeps addon state under the `addons` folder of the data path (`--data-path`) and reaches the owner over a loopback carrier with keys that are made at each start and never written to disk. | Flag | Environment variable | Default | What it does | |---|---|---|---| | `--addons-enabled` | `LOCALAI_ADDONS_ENABLED` | `true` | Runs the addon owner. `--addons-enabled=false` turns it off completely: no owner starts, no addon runs, the UI hides the add-on entries, and every add-on, fine-tuning and quantization route answers `503`. See [The addons feature gate](#the-addons-feature-gate). | | `--addons-ownership-mode` | `LOCALAI_ADDONS_OWNERSHIP_MODE` | `local-owner` when authentication is off, `named-user` when it is on | `named-user` gives each account its own access. `local-owner` makes every caller share one owner. A value you set always wins over the default. Any other value stops startup with an error. | | `--addons-official-library` | `LOCALAI_ADDONS_OFFICIAL_LIBRARY` | `true` | Registers the [official library](#the-official-library) on the first start. `--addons-official-library=false` does not register it. | | `--addons-backend-library` | `LOCALAI_ADDONS_BACKEND_LIBRARY` | `true` | Registers the [backend library](#the-backend-library) on the first start, like the official library. A build from master reads it as the backend source; a tagged build reads the backend gallery until the release cutover. `--addons-backend-library=false` starts without it and reads the backend gallery on every build. A library you remove stays removed. | | `--official-libraries` | `LOCALAI_OFFICIAL_LIBRARIES` | `true` | Use the default library sources of add-ons, backends and models. `--official-libraries=false` leaves them listed but unread. It is also the runtime setting `official_libraries`. See [Library sources](#library-sources). | | `--addons-backend-max-bytes` | `LOCALAI_ADDONS_BACKEND_MAX_BYTES` | `8589934592` (8 GiB) | Largest compressed size of a backend image installed from a library. Add-ons keep their own limit of 512 MiB. | | `--addons-role` | `LOCALAI_ADDONS_ROLE` | `combined` | `combined` is the single node described above. `owner` runs only the owner and serves the carrier on the network. `frontend` runs no owner and forwards every add-on request to it. Any other value stops startup with an error. | | `--addons-owner-address` | `LOCALAI_ADDONS_OWNER_ADDRESS` | empty | `https://host:port` of the owner carrier, with no path. Use a stable service name, never a pod address. Required for `owner` and `frontend`; an error with `combined`. | | `--addons-owner-listen` | `LOCALAI_ADDONS_OWNER_LISTEN` | empty | `owner` only. The address the carrier listens on. Empty listens on all interfaces, on the port of `--addons-owner-address`. | | `--addons-carrier-dir` | `LOCALAI_ADDONS_CARRIER_DIR` | empty | Directory with the carrier key set made by `local-ai util addons-carrier-keys`. Required for `owner` and `frontend`; an error with `combined`. | `named-user` needs authentication. With authentication on and no mode set, LocalAI uses `named-user`. With authentication off there are no accounts, so LocalAI uses `local-owner` by default: every caller that can reach the server shares one owner and can install, run and remove add-ons. See [Explicit local-owner mode](#explicit-local-owner-mode) for what that means. To keep add-ons off on a server without authentication, set `--addons-enabled=false`, or set `--addons-ownership-mode=named-user`; with authentication off, `named-user` leaves the owner off and LocalAI logs a warning. The `owner` and `frontend` roles split the owner from the frontends. The `frontend` role does not create or open the `addons` folder of the data path. The key set for both roles comes from `local-ai util addons-carrier-keys`; see [Running the owner as its own workload](#running-the-owner-as-its-own-workload). ### The addons feature gate Fine-tuning and quantization are add-ons, so `--addons-enabled=false` turns off all three. `GET /api/features` reports one key, `addons`, and the keys `fine_tuning` and `quantization` carry the same value. The key is `true` when add-ons are available on this instance: - In the `combined` and `owner` roles it is `true` when add-ons are enabled. - In the `frontend` role it is `true` when add-ons are enabled and the owner answered a reachability call within 2 seconds. The answer is kept for 10 seconds, so `/api/features` does not contact the owner on every request. While the key is `false`: - The UI shows no Add-ons entry, no pinned apps, and no fine-tuning or quantization entry. `/app/addons`, `/app/fine-tune` and `/app/quantize` lead back to `/app`. The UI does not call `/api/addons/pins` or `/api/addons/builtin`. A server that sends no `addons` key counts as `true` in the UI. - Every path under `/api/addons`, `/api/fine-tuning` and `/api/quantization` answers `503` with the same body, also for a path that does not exist. The answer does not show which routes exist. The body is the LocalAI error envelope (`{"error": {"message", "type", "code"}}`, code `unavailable`). The message is "add-ons are turned off on this instance" when the feature is off and "the add-on owner is not reachable" when a frontend cannot reach the owner. - With `--addons-enabled=false` the handlers of these routes are not registered at all. A `frontend` with `--addons-enabled=false` needs no `--addons-owner-address` and no `--addons-carrier-dir`. It loads no keys and builds no carrier. If you set `--addons-role`, `--addons-owner-address`, `--addons-owner-listen` or `--addons-carrier-dir`, LocalAI ignores them and logs one line. This gate is for the whole instance. It does not replace the permission of each user: the `addons`, fine-tuning and quantization permissions of a user are checked after the gate. The reachability answer of a frontend is a per-instance observation and is not shared. Two frontends can disagree for up to 10 seconds after the owner goes up or down. A request through a frontend that still reports `true` gets the `503` of the carrier while the owner is down. ## Running the owner as its own workload A normal `local-ai run` is both the owner and the only frontend (the `combined` role). In a deployment with several frontend replicas, run the owner as one separate workload and let the frontends forward to it. The owner keeps all its state in files under one root, as before. Frontends keep none and scale freely. - **One owner, one volume.** Run exactly one owner replica (`--addons-role=owner`) with its own volume for the data path. Use a recreate-style rollout, so two owner processes never overlap: the old one must stop before the new one starts. A second owner on the same root stops at once with "addon storage already owned" and the process exits with an error. It does not wait. - **Fixed mount path.** The journal stores absolute paths. Mount the volume at the same path on every start. If the data path or the mount point changes, every installed add-on points at a directory that does not exist. - **A local volume.** The root needs a local file system with working `flock`, atomic rename and directory `fsync`. A network block volume that one node mounts at a time is fine. A shared network file system is not supported. - **A stable owner address.** Frontends reach the owner at `--addons-owner-address`. The owner does not store it, so you can change it between starts. Use a stable service name, never a pod address. - **Frontends have no add-on volume.** A frontend (`--addons-role=frontend`) does not create or open the `addons` folder. Give it no volume for it. - **Existing add-ons.** A root written by a single node loads under the owner role when the owner uses the same owner and storage identity. The stored address does not matter. The journal keeps absolute paths, so the owner must see the same data path and mount point as the node did. Otherwise the installed add-ons point at missing directories. For an empty volume, the owner starts fresh. {{% notice warning %}} The owner is a node-bound singleton. If the owner is down, add-ons are down: the frontends stay up and answer `503` for every add-on request. Failover is manual. There is no election and no standby owner. {{% /notice %}} ### Make the carrier keys The frontends and the owner trust each other through a static key set. Make it once, anywhere you can run `local-ai`: ```bash local-ai util addons-carrier-keys --out ./carrier-keys --owner-host owner.example ``` `--owner-host` is the name the frontends dial, and it must match the host in `--addons-owner-address`. The command prints which files go where: | Role | Mounts | |---|---| | Owner | `ca.pem`, `owner.pem`, `owner.key`, `frontend.pem`, `frontend-sign.pub` | | Frontend | `ca.pem`, `owner.pem`, `frontend.pem`, `frontend.key`, `frontend-sign.key` | Store the set as a secret and mount only the files of each role. Point `--addons-carrier-dir` at the mount. LocalAI refuses key files that group or other users can read, so give them mode `0600` or tighter. All frontend replicas share one identity and one signing key, so a new or rescheduled frontend is accepted at once and the owner needs no list of replicas. Anyone who holds the frontend files can act as a frontend. A forwarded request still carries the user, and the owner checks that user against the account database, so the key set does not give a user any authority on its own. The command does not keep the CA key, so nobody can make a third certificate from the set. {{% notice note %}} Rotation is a coordinated restart. The frontends pin the exact certificate of the owner and the owner pins the one of the frontends. To change the key set, make a new one, update the secret, and restart the owner and every frontend. There is no overlap window and no automatic renewal. {{% /notice %}} ### Example This Docker Compose file shows the add-on flags of one owner and two frontends. Add the other flags of your deployment (the database, NATS and the rest of [distributed mode]({{% relref "features/distributed-mode" %}})) to each service as usual. ```yaml services: owner: image: localai/localai:latest command: run hostname: owner.example environment: LOCALAI_ADDONS_ROLE: owner LOCALAI_ADDONS_OWNER_ADDRESS: https://owner.example:8443 LOCALAI_ADDONS_CARRIER_DIR: /run/addon-keys LOCALAI_ADDONS_OWNERSHIP_MODE: named-user LOCALAI_DATA_PATH: /data LOCALAI_AUTH: "true" LOCALAI_AUTH_DATABASE_URL: postgres://localai:secret@db.example:5432/localai volumes: - owner-data:/data - ./carrier-keys-owner:/run/addon-keys:ro deploy: replicas: 1 update_config: order: stop-first frontend: image: localai/localai:latest command: run environment: LOCALAI_ADDONS_ROLE: frontend LOCALAI_ADDONS_OWNER_ADDRESS: https://owner.example:8443 LOCALAI_ADDONS_CARRIER_DIR: /run/addon-keys LOCALAI_ADDONS_OWNERSHIP_MODE: named-user LOCALAI_AUTH: "true" LOCALAI_AUTH_DATABASE_URL: postgres://localai:secret@db.example:5432/localai volumes: - ./carrier-keys-frontend:/run/addon-keys:ro deploy: replicas: 2 volumes: owner-data: ``` `./carrier-keys-owner` holds the owner files and `./carrier-keys-frontend` holds the frontend files from the table above. ### Settings every role shares - **The same ownership mode.** Every role of a deployment must use the same `--addons-ownership-mode`. - **The same account database.** In `named-user` mode all roles must use the same account database. The owner checks the user of each forwarded request against it. - `--addons-enabled=false` keeps its meaning in every role. ### What a restart looks like When the owner stops, the kernel drops its lock and its add-on processes end with it. The new owner takes the lock, checks its identity file, and reads the journal: operations that had only started are marked failed, and operations that were already prepared or applied are finished. Recovery journals and pending proposals finish or wait for an administrator. Then the add-ons that were enabled start again. While this happens, the frontends answer `503`. A frontend that starts while the owner is down starts normally and answers `503` until the owner is up. Some state is in memory only and is lost: an upgrade that was being canceled becomes failed, and the memory of failed automatic upgrades is cleared. A mutation whose reply was lost is reported as `outcome_unknown` with its operation ID, and relays that were in progress (progress, file and asset) are cut. The owner picks a new audience at each start. A request signed for the old owner is refused, and the frontend gets the new audience on its next call without any action. ### The models path Fine-tuning and quantization export models into the models path of the owner. The owner and every frontend must mount the same models storage at the same absolute `--models-path`. LocalAI checks this with a probe file: - At start, the owner writes `.localai-addons-probe.json` into its models path. It holds a random nonce that changes at every start. Do not delete it, and do not exclude it from the shared volume. LocalAI does not list it as a model. - Each frontend checks at start, and then every 60 seconds, that it sees the same file with the same nonce at the same absolute path. If it does not, the frontend logs an error, reports `model_exports: false` in `/api/features`, and answers the export and import routes with `503`. - The owner refuses an export or import while its own probe file is missing, also for the add-on host operations. If the owner cannot write the probe at start, it keeps running and refuses exports. - If the owner is unreachable, or is an older version without the probe, the state is `unknown`. LocalAI logs a warning and leaves exports on. The state clears within 60 seconds after the owner is back. The `/api/fine-tuning` and `/api/quantization` routes of a frontend still write to the models path of that frontend. The probe is what blocks them when that path is not the shared storage. ### Frontend limits A process with `--addons-role=frontend` holds no add-on state. This has these consequences: - **Library sources live on the owner and are changed through it.** The frontend never opens, creates or writes `sources.json` or anything else under the `addons` folder of the data path. Every `/api/library/sources` route is forwarded to the owner, which is the only writer. The caller must be a named account and an administrator: the frontend checks the account, and the owner checks the administrator right again, as it does for its own routes. A legacy API key has no named user and gets `401` on a frontend, in `local-owner` mode too. The routes answer the same statuses as on a single node (`404` for an unknown source, `409` for a duplicate, `400` for an invalid request). With add-ons off a frontend has no owner to reach, and these routes answer `503` with the message "library sources are managed on the owner". When the owner is down they answer `503` with "the add-on owner is not reachable". A change whose reply was lost answers `503` with the operation ID in the message; read the list again before you repeat it. - **The gallery lists follow the owner.** A frontend asks the owner for its source list every 60 seconds (a read over the pinned carrier that needs no signed-in user) and, only when the list or the `official_libraries` setting changed, rebuilds its model and backend gallery lists from the galleries named by `--galleries` and `--backend-galleries` plus the owner's sources. A change made on the owner shows up on every frontend after at most 60 seconds, and a change made through a frontend shows up on that frontend at once. Until the owner has answered once, a frontend reads only its own flags. While the owner does not answer, the frontend keeps its last lists and logs that they may be stale. This is a per-instance observation: two frontends can show different lists for up to 60 seconds. - **`official_libraries` is set on the owner.** The owner's value is the value of the deployment, and a frontend takes it at each refresh. `POST /api/settings` with a different `official_libraries` value answers `409` on a frontend. Set it on the owner. - **Backend installs run on the frontend node.** `POST /backends/library/install` and `GET /backends/library/updates` work on a frontend. The owner resolves the entry (including the choice of a meta backend for the capability of the frontend) and pins the image by digest. The frontend then installs it into its own backends path, verifies the signature there, and applies its own size limit and `--require-backend-integrity`. The owner's backends path stays unchanged. The update check compares the local backends with the releases the owner lists. The background update check has no user, so it works in named-user mode through one read-only system call (`POST /addons/library/system`, capability `library-system`). The owner accepts it only from a pinned frontend, only for the release lookup, and without an actor. Every call a user starts still needs the administrator check on the owner. - **The owner and the frontends must run on the same OS and architecture.** The owner resolves entries for its own platform, so a frontend checks the platform the owner reports (`GET /addons/capabilities`) and refuses every library call to an owner on another one with a `503`. A frontend also refuses library calls to an owner that does not serve them, with "the add-on owner does not support library operations; upgrade the owner". Upgrade the owner before the frontends. - **Pins are per frontend without an account database.** With an account database, the pins of the Apps entry are shared by all frontends. Without one, each frontend keeps its own pins in a file in its own data path, so two frontends can show different pins. A frontend logs a warning at start when it has no account database. ## The official library On the first start, when the owner has no `sources.json` in the `addons` folder yet, LocalAI registers one library with the ID `official`. It is a YAML index of the first-party add-ons (Tables, Voice, Face, Fine-tuning, Quantization, Studio, Talk, Office, Sandbox and Web). The index has one entry for each enabled platform of each add-on, so an install on Linux (amd64 or arm64) and on macOS (Apple silicon or Intel) finds the add-ons for its own platform. The Sandbox add-on is Linux only. No add-on is built for Windows yet, so the official library lists nothing there. Which index the library reads depends on the build: | Build | Index | Entries | Signer the library requires | |---|---|---|---| | A release (the version is a `v` tag) | `addons-index.yaml`, an asset of that GitHub release | Pinned by digest to the packages the release built | The package workflow run of that tag (`refs/tags/v`) | | Any other build (master, a source build, a commit after a tag) | `gallery/addons/index.yaml` on the master branch | The mutable `development` builds | The package workflow run on master (`refs/heads/master`) | ``` https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/addons/index.yaml https://github.com/mudler/LocalAI/releases/download/v/addons-index.yaml ``` So a release installs the immutable, tag-pinned packages that the same release built and signed, and never a development build signed by master. The first start writes the index and signer of the running build into `sources.json`. After an upgrade, the next start moves the `official` library to the index and signer of the new build; a library that you pointed at another URL is left as it is. The index has the same format as the other libraries (see [Catalog document and limits](#catalog-document-and-limits)). A package that the named workflow run did not build and sign is refused at install. `gallery/addons/index.yaml` is generated, never edited by hand: ```bash sh scripts/addons/catalog.sh --channel development \ --repository quay.io/go-skynet/local-ai-addons --out gallery/addons/index.yaml ``` CI runs the same command with `--check` and fails when the committed file is stale, for example when a platform is enabled in `addons//package/platforms.yaml` or a manifest changes. The release index is rendered by the add-on package workflow when a `v` tag is pushed and attached to the release when it is published (see [Preparing a release](https://github.com/mudler/LocalAI/blob/master/.agents/preparing-a-release.md)). ### Add-ons that are not built in Studio, Talk, Voice, Face, Fine-tuning, Quantization, Office and Web are not part of the LocalAI binary or image. A fresh install has none of them. The old links to the pages they replaced (see [Old links](#old-links)) open the add-on page, which says the add-on is not available until it is installed. To get them on a new install: 1. Sign in as an administrator (with authentication off, you already are one) and open **Add-ons**, then **Find add-ons**. The list is the official library for this machine's operating system and architecture. 2. Pick the add-on and choose **Install and turn on**. The install checks the signature against the library policy before anything runs. Studio is the usual first one: it holds the image, video, 3D, speech, sound and audio tools. 3. The add-on then appears in **My add-ons**. Use **Show in sidebar** to pin it. An install needs the network to reach the library and the package registry. A machine without it can run a private library (see [Private catalog service](#private-catalog-service)) that mirrors the packages. `--addons-official-library=false` starts without the official library, and `--addons-enabled=false` turns the add-on owner off, so nothing can be installed. Installed add-ons live in the `addons` folder under the data path, so keep that path on a volume in a container, or the add-ons must be installed again after the container is replaced. The first start writes `sources.json`, and from then on that file is the record of your choices. If you remove the `official` library (in the Add-ons page or through the source routes), it stays removed, and a `sources.json` you already have is never changed. To start without it, set `--addons-official-library=false` before the first start. To get it back, add a source with the URL above, or enable it again (see [Library sources](#library-sources)). ### Upgrading: Studio and Talk are add-ons now Studio (image, video, 3D, speech, sound and audio) and Talk (realtime voice) used to be pages of LocalAI. They are add-ons now, and two things change at the upgrade: - **Nothing is installed for you.** No add-on is preinstalled. Studio and Talk appear only after an administrator installs them from the library (see [Add-ons that are not built in](#add-ons-that-are-not-built-in)). Until then the old links open a page that says the add-on is not available. - **Users need the `addons` feature.** With authentication on, every add-on page is behind the `addons` feature, which is off by default for a user. A user who had the image, speech or realtime features but not `addons` loses Studio and Talk at the upgrade. The upgrade does not grant `addons` to anyone. An administrator always has it. To restore access: 1. Install Studio and Talk: **Add-ons**, **Find add-ons**, then **Install and turn on** for each. A machine with no access to the network installs them from a private library that mirrors the packages (see [Private catalog service](#private-catalog-service) and [Library sources](#library-sources)). 2. Grant the `addons` feature to each user who should keep them, in **Settings → Users and keys** (see [Chats permissions](/features/chats/#permissions) for the same screen). The calls the pages make still need the user's own features: `images` for an image, `audio_speech` for speech, and so on. With authentication off there are no user features: the one person is an administrator and sees every installed add-on. **Release notes.** The paragraph below is ready to copy into the release notes: > **Studio and Talk are add-ons.** Studio (image, video, 3D, speech, sound and > audio) and Talk (realtime voice) moved out of LocalAI into add-ons. Nothing is > preinstalled: after the upgrade an administrator installs them from **Add-ons → > Find add-ons** (an air-gapped host uses a private library that mirrors the > packages). With authentication on, the add-on pages need the `addons` feature, > which is off by default and is not granted at the upgrade, so users who used > Studio or Talk lose access until an administrator grants `addons` to them in > **Settings → Users and keys**. ## Library sources A library source is a place LocalAI reads entries from. There are three kinds: | Kind | What it provides | Read by | |---|---|---| | `addon` | Add-ons, as a library index | The Add-ons catalog | | `backend` | Backends, as a library index or a backend gallery index | The Backends page, and the add-on catalog for a library | | `model` | Models, as a library index or a model gallery index | The model gallery | All sources of all kinds are in one store, `addons/sources.json` under the data path. `local-ai library` (see the [CLI reference](/reference/cli-reference/#library-sources)), the HTTP routes below and the Sources tab work on the same store with the same code, so a change made in one place shows in the others. A source can be a default: one that LocalAI ships (the official add-on library, the model library and gallery index, and the backend library and gallery index). Of the two model sources and the two backend sources only one is on by default, the one the build reads (see [Which source a build reads]({{% relref "features/backends#which-source-a-build-reads" %}})). A default is marked `default`. If you remove a default, LocalAI keeps a record of the removal (a tombstone, `removed: true`), so the default does not come back at the next start. Turn the source on again to bring it back. A source you added is deleted when you remove it. ### The Sources tab The Sources tab of the Add-ons page lists the sources of all three kinds. Use the kind filter to show only add-ons, backends or models. Each source shows its kind, a Default badge for a shipped default, and its state: enabled, disabled, removed or, while the official libraries are off, off. Administrators can add a source (kind, format, name and address), turn a source on or off, and remove it. A removed default stays in the list with a Restore button. An add-on source can also be edited, to set its signature policy and credential reference, and refreshed. Backend and model sources have no signature policy of their own. ### Conflicts When two libraries list the same entry, LocalAI does not choose silently: - A source you added wins over a default. The default is ignored for that entry. - Two sources you added that list the same entry are a conflict. LocalAI refuses to install or resolve that entry until you turn one of the sources off or remove it. The list shows the message next to each source that clashes. Only sources that LocalAI fetches as a library index can clash, so gallery-format sources never show a conflict. ### Use official libraries The setting `official_libraries` (default on) switches all default sources of all three kinds at once. In the Settings page it is the toggle **Use official libraries**, in the group Backends and galleries. On the command line it is `--official-libraries` (`LOCALAI_OFFICIAL_LIBRARIES`). When it is off, LocalAI does not read the default sources. The default add-on library, model gallery index and backend gallery index are not used for search, install or update checks. They stay in the store, still marked `default`, and the Sources tab shows them as off (`suspended: true` in the routes). Each source keeps its own enabled state, so turning the setting back on restores exactly what was there. Sources you added are not affected. The change applies at once, with no restart. On a frontend the switch is read-only: the owner's value applies, and a change there reaches the frontends within 60 seconds. ### Source routes The routes are for administrators. A caller who is not an administrator gets `403`, and a caller who is not signed in gets `401` when authentication is on. They are not add-on routes, so they do not need the add-ons owner to run. On a frontend they go to the owner (see [Frontend limits](#frontend-limits)). | Method and path | What it does | |---|---| | `GET /api/library/sources` | List every source of all kinds, tombstones included | | `POST /api/library/sources` | Add a source: `{"kind": "addon\|backend\|model", "url": "...", "id": "...", "name": "...", "format": "library\|gallery"}`. `id`, `name` and `format` are optional. The same kind and URL twice, or a taken id, is `409`. An add-on library at an `http` address of this machine is `400`: the owner refuses packages that do not come over `https`, so such a library would never list anything | | `POST /api/library/sources/{id}/enable` | Turn a source on. This also brings back a removed default | | `POST /api/library/sources/{id}/disable` | Turn a source off without removing it | | `DELETE /api/library/sources/{id}` | Remove a source. A default leaves a tombstone | The list answers `{"sources": [...], "officialLibraries": true}`. Each source has `kind`, `id`, `name`, `url`, `format`, `enabled` (what LocalAI does with it), `default`, `removed`, `suspended`, `conflict` (the message, when there is one), `policy` and, for an add-on source, `refresh` (the last fetch). A source that does not exist is `404`. Changes to model and backend sources apply to the running server. No MCP tool maps to these routes. ## Backends in the library A library lists more than add-ons. Every entry has one schema and an optional `kind` field. The kinds are: | Kind | What the entry installs | State | |---|---|---| | `addon` | A supervised add-on package. It is the default: an entry without `kind` is an add-on, so every index written before kinds existed stays valid. | Supported | | `backend` | A backend image for the backends directory. | Supported. The backend library is the source of the backends. | | `model` | A model. | Reserved. No reader installs it yet. | The backend library (`gallery/backends/index.yaml`) is the source of truth of the backends, the way the model library is for the models. The backend gallery (`backend/index.yaml`) is a generated copy of it. A tagged build before the cutover reads the gallery by default; a build from master reads the library. See [The backend library](#the-backend-library). A library entry of kind `backend` can also be installed by digest from the Find add-ons tab, as described below. ### How a reader treats an unknown kind A reader keeps the entries of the kinds it knows and skips every other entry. It does not read the fields of a skipped entry, so a newer kind can carry fields this version has never seen. The index is not refused. A skipped entry is reported in two places: - the refresh result and the state of the library have `skipped` (a count) and `skippedKinds`. The Sources tab shows them; - the refresh writes a warning to the log. A kind that is not a plain lowercase name, or that is not text, is a malformed entry and the index is refused. An unknown field on an entry of a known kind is still refused. Search lists add-ons unless the caller asks for a kind: `catalog-search` takes an optional `kind` argument (`addon`, `backend` or `all`). ### A backend entry ```yaml - kind: backend id: cpu-local-store # the backend name, also its directory name alias: local-store # optional: the name a model uses name: cpu-local-store description: Local Store is a local-first vector database. license: MIT icon: https://example.com/local-store.png urls: - https://github.com/mudler/LocalAI tags: [vector-database, CPU] version: 0.0.0-latest.0a1056fd8633 channel: latest schemaVersion: 1 artifact: oci://quay.io/go-skynet/local-ai-backends:latest-cpu-local-store digest: sha256:0a1056fd863328adf9d1097808aa2b52f68c4ca2e5385100a754755b3ca6218f mirrors: - oci://localai/localai-backends:latest-cpu-local-store capabilities: default: cpu-local-store metal: metal-local-store os: linux arch: amd64 ``` One entry is one image for one platform, so a backend built for several platforms has several entries with the same `id`. The reader is strict: an unknown field on an entry of kind `backend` is refused. These fields are for a backend only and an add-on entry that sets one is refused: | Field | What it holds | |---|---| | `schemaVersion` | The version of the shared entry header. Absent means 1. A host refuses a higher one. | | `license`, `urls`, `tags` | Display text, as in the gallery. `urls` are `http` or `https`. | | `icon` | The URL of the icon, `https` only. The add-on `ui.icon` stays a path in the package. LocalAI never fetches the icon: the browser loads it. | | `mirrors` | Fallback images of the same backend, tried in order. Each is an `oci://` image. The install pins a mirror with the digest of the artifact. | | `capabilities` | A map from a host capability (`default`, `nvidia`, `metal`, ...) to the `id` of the concrete backend that serves it. | | `mutable` | The artifact is a moving tag, such as a development image. The entry has no `digest`. | The `digest` is the digest of the image for the platform. The install uses the image by digest and never by tag. A backend entry must use an `oci://` artifact. It has no main page or `ui.icon`, and only a backend entry can set `alias`. The backend name is a directory name: it has no `/` and no `..`. **Meta backends.** An entry with `capabilities` and no `artifact` is a meta backend, such as `llama-cpp`. It has no `digest`, `os`, `arch` or `mirrors`, and it is listed for every platform. Installing it installs the backend its capability names for this host, in the same library and the same channel, pinned and verified like any other. A meta backend that names nothing for the host, or names a backend the library does not have, is refused. **Development images.** A `master-` image is rebuilt all the time, so the library cannot pin it. Its entry keeps the tag, has `channel: development` and `mutable: true`, and has no `digest`. The install resolves the tag to a digest, verifies that image, and records the digest it installed in `metadata.json`. ### Signer identity pattern The signature policy belongs to the library, as for add-ons. A backend library can name the signer with `identityRegex` instead of an exact `identity`, so images that the merge workflow signed on master and on a tag both verify: ```json { "requireSignature": true, "issuer": "https://token.actions.githubusercontent.com", "identityRegex": "^https://github\\.com/mudler/LocalAI/\\.github/workflows/backend_merge\\.yml@refs/(heads/master|tags/.+)$" } ``` Set `identity` or `identityRegex`, not both. The pattern must be one expression that starts with `^` and ends with `$`. A pattern with a top-level alternation, such as `^a|b$`, is refused, because one branch would match a part of the identity only. An add-on install refuses a library policy that has a pattern: add-on libraries name the signer exactly. ### Install a backend from a library An administrator installs a backend from the Find add-ons tab of the Add-ons page. The entry shows its kind. The page sends the install to the backend route, not to the add-on lifecycle. The page installs an add-on and turns it on in one action (Install and turn on); the install itself leaves an add-on disabled. A backend has no on or off: it is ready to use when the install ends, and its button says Install. The route is `POST /backends/library/install`. It is for administrators and exists where the libraries are: on the instance that runs the add-on owner, and on a frontend, which asks the owner to resolve the entry and installs the backend on its own node: ```bash curl -X POST http://localhost:8080/backends/library/install \ -H 'Content-Type: application/json' \ -d '{"sourceId":"official-backends","name":"cpu-local-store","version":"latest"}' ``` The request names the entry and nothing else. The library resolves the image, the digest and the policy, so a request cannot change them. The install: 1. resolves the entry of kind `backend` for this platform; 2. refuses it when `--require-backend-integrity` is on and the library does not require a signature; 3. pins the image by digest, verifies the signature before it fetches any layer and refuses an image over the size limit (the layers are summed from the manifest); 4. installs with the same code as the gallery, so the backend lands in the same directory with the same `metadata.json`, plus `library`, the ID of the library it came from. The request answers at once with a job, the same kind of job the backend gallery uses: `{"id": "...", "status_url": ".../backends/jobs/"}`. Read `GET /backends/jobs/` to follow it. The `phase` of the job is the step it is in, in this order: `resolving`, `verifying` (only when the library requires a signature), `size`, `fetching` (with `progress` and the sizes of the fetch) and `writing`. A job that ends has `processed` set. It ends with the phase `done` and `message` `completed`, or with `error` set and the `phase` of the step that failed. The Find add-ons tab shows these steps. Refusals that depend on the entry arrive this way: an unknown entry, a name installed from another origin (a gallery, another library or a manual install; nothing changes), an image over the size limit, a library that strict mode refuses. The request itself answers `400` when it does not name an entry and `409` when the same backend is already being installed. Installing again from the same library is a reinstall. ### Library backends on the Backends page The Backends page marks a backend that came from a library with the library ID, and the detail view shows the digest that `metadata.json` pinned. A backend of a gallery has no mark, and the gallery upgrade check and the review of updates are unchanged. When the cached index of the library lists a different build than the one installed, the backend shows "Update available" and an Update button. The check is `GET /backends/library/updates`. It reads the index the last refresh cached, so it does not use the network, and it installs nothing. The build to compare is the one in the channel of the installed version. If the library no longer lists that version and has more than one channel, the check says nothing instead of guessing. The Update button starts the same install as above, with the same steps, for the version the check names. There is no automatic upgrade. The LocalAI Assistant and `local-ai mcp-server` expose this check as the read-only `list_backend_library_updates` tool. Taking an update stays a confirmed `install_library_backend` call. A library install touches only the node that serves the request. In a distributed deployment the backend is installed on that node only; the per-node routes of the backend gallery are unchanged. ### Size limits The limit is on the compressed size of the package or image, by kind: | Kind | Default | Configure | |---|---|---| | `addon` | 512 MiB | No | | `backend` | 8 GiB | `--addons-backend-max-bytes` | ### The backend library `gallery/backends/index.yaml` is the backend library. It lists every backend of the backend gallery: 1088 backends (934 concrete backends and 154 meta backends) in 1240 entries, because a backend built for several platforms has one entry for each. LocalAI registers it on the first start as the source `official-backends`, next to the backend gallery `localai-backends`, and a source you remove stays removed. To add it again later, use the Sources tab with this URL and the policy above: ``` https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/backends/index.yaml ``` The library is the source of truth. To add or change a backend, change the library; see [Backends]({{% relref "features/backends#the-backend-library-and-the-generated-gallery" %}}). The digests are never edited by hand. Pin them again after the backend images are rebuilt; this is the only command that reads the registry: ```bash go run ./scripts/backends/cmd/backendcatalog \ --library gallery/backends/index.yaml --repin ``` A release image that the registry has no image for when the library is rendered is marked `mutable` and has no digest, and the command lists it. It installs by its tag until it is pinned. ## Manifest `ParseManifest` accepts a single JSON object, at most 64 KiB, and rejects unknown fields, duplicate keys, trailing values, and nesting beyond 64 levels. `ValidateManifest` additionally checks the supplied host compatibility tuple. See `tests/addons/fixtures/contracts` for service-only, headless service/tool, page/navigation, chat-action-only, and incompatible-host fixtures. The required fields are `schemaVersion` (1), `id`, `version`, `executable`, `os`, `arch`, `host`, `api`, `protocol`, `dataFormat`, and `capabilities`. Compatibility ranges are inclusive positive integer `{min,max}` pairs, not semver expressions. The package version uses immutable `major.minor.patch`, optionally a lowercase prerelease starting with a letter; moving aliases and build metadata are not package versions. Registry channels are separate from package identity. Namespace IDs match `[a-z][a-z0-9-]{0,63}`. IDs are never paths. An add-on ID must not be one of the first path segments the host serves under `/api/addons/`: `apps`, `proposals`, `artifacts`, `resources`, `conversations`, `dispatch`, `relay`, `catalog`, `icon`, `assets`, `installed` and `sources`. Such a manifest is refused at parse, at install and when a catalog library is refreshed, because the asset URL `/api/addons//assets/...` would be answered by the host route of the same name. A test derives the registered segments from the real routes and fails when one is missing from this list. Executable and UI asset paths are normalized package-relative ASCII paths: no absolute paths, parent traversal, backslashes, percent escapes or URL schemes. These checks do not substitute for safe extraction, symlink checks, executable mode validation, or checking declared files exist, which belong to package installation. Capabilities are independent: `service` is a boolean, `tools` is a unique list of namespace names, and optional `ui` contains required `entry` and `sdk` range, optional `css`, `pages`, `navigation`, and `chatActions` lists. Navigation names must refer to declared pages. UI assets must not duplicate each other. Absent UI requires neither assets nor SDK compatibility. Declared UI must be consistent; an empty object does not count as a valid declaration. A chat action is either a bare identifier (inert: it has no placement and is never drawn) or a full declaration, and `ui.slots` and `ui.renderers` place components in closed host slots (see [Extension points and slots](#extension-points-and-slots)). They are host-controlled entries, not DOM hooks or permission to read chat history. Platform tuple equality is compatibility only, not evidence that any release platform has passed native runtime tests. Four optional fields describe how an addon appears as an app (see [Apps](#apps)). Every one is optional, so earlier manifests stay valid and a headless manifest needs none of them. | Field | Meaning and bounds | |---|---| | `launchMode` (top level) | Absent or `always`: the owner runs the addon while it is enabled. `on-demand` is reserved and, like any other value, is rejected at install. | | `ui.mainPage` | The page the Open action navigates to. It must be one of `ui.pages`. Without it the addon has no Open action. `ui.entry` keeps its meaning: the packaged ES module. | | `ui.icon` | A packaged image: `.png`, `.webp`, `.jpg` or `.jpeg`, a normalized package-relative path of at most 256 bytes, at most 256 KiB. A remote URL, a `data:` URL, an absolute path and a traversal are rejected. It cannot also be the module or a stylesheet. | | `ui.title`, `ui.description` | Plain text: one string, or a map of BCP 47 language tag to string (at most 16 tags). At most 80 and 280 characters. Markup characters (`<`, `>`), control characters and direction overrides are rejected. | ### Skills An add-on can ship skills, the procedures a chat model reads before a task (see [Skills]({{%relref "features/chats#skills" %}})). The model loads one by its name with the chat's `load_skill` tool, which also names the files of the skill's folder, such as `references/`, for `read_file`. The manifest declares them in `capabilities.skillDefinitions` and the package holds the files: ```json "skillDefinitions": [ { "name": "office" }, { "name": "office-quickstart" } ] ``` ``` skills//SKILL.md skills//references/ (up to 3 folders below the skill folder) ``` The description is not in the manifest: the `SKILL.md` frontmatter is its only source. The manifest is checked at parse, at install and when a library is refreshed: | Rule | Limit | |---|---| | Skills per add-on | 16 | | Name | `[a-z][a-z0-9-]{0,63}`, unique in the manifest | | A plain name | the add-on ID, or the ID and `-` and more (`office-quickstart`), so two add-ons never ship one name | | A built-in skill name | refused for a plain skill (`writing-skills`, `localai-workspace`, `localai-apps` and the office skills) | | `shadows` | optional; must repeat `name` and be a replaceable built-in skill: `xlsx`, `docx`, `pptx` or `pdf` (`contract.ReplaceableSkills`) | Staging checks the files against the manifest. The folders under `skills/` must be the declared names, and a package that declares no skills must have no `skills/` folder. Every entry is a plain file or a folder: links, special files and executable files are refused. A path segment uses lowercase ASCII letters, digits, `.`, `_` and `-`, does not start with a dot, and a path is at most 128 bytes. A file is a `.md`, `.txt`, `.json`, `.csv`, `.yaml` or `.yml` file of UTF-8 text with no NUL byte. **There are no scripts**: `.py`, `.sh`, `.js` and other files are refused. A skill that needs to run code is a built-in skill with `requires: [exec]`. `SKILL.md` is at most 64 KiB and must parse with a `name` equal to its folder and a `description`; a reference file is at most 256 KiB; a skill has at most 64 files and 1 MiB, and a package at most 4 MiB of skill files. The receipt records each file with its size and SHA-256 digest. The add-on owner copies the skills into the chats' add-on skill layer, in the workspace at `/addon-skills//`, when it installs or upgrades the add-on and every time it starts, and deletes them when the add-on is uninstalled. Each copied file is checked against the digest in the receipt, so a package folder changed after install never reaches a chat. A copy that fails is logged; the add-on itself still installs and runs. The files stay while the add-on is stopped. A chat offers them only while the add-on is installed, running, usable by the user and on for that chat, and for a chat with a saved agent only when the agent grants the add-on. #### Skills that replace a built-in skill An add-on skill can replace a built-in skill of the same name, as a tool replaces a built-in tool (see [Tools that replace a built-in tool](#tools-that-replace-a-built-in-tool)). Only the office skills `xlsx`, `docx`, `pptx` and `pdf` can be replaced; `writing-skills`, `localai-workspace` and `localai-apps` cannot. The manifest says so with `shadows`, which repeats the name: ```json "skillDefinitions": [ { "name": "xlsx", "shadows": "xlsx" }, { "name": "docx", "shadows": "docx" } ] ``` - **In a chat.** While the add-on can serve (installed, running, usable by the user and on for the chat), the model sees one `xlsx` skill, the add-on's, and the built-in one is hidden: `load_skill xlsx` returns the add-on's. The index grows by nothing: one line replaces another. A skill a person writes (folder, personal or shared) still wins over the add-on's. - **The built-in comes back.** When the add-on is stopped, off for the chat or removed, or the chat switches the add-on's skill off, the built-in skill serves again. The turn tells the model in one line, and the Skills menu says so on the built-in skill. - **Checked twice.** The install refuses a `shadows` that is not on the list, and the chats refuse it again at each turn: an add-on skill with the name of a built-in skill that cannot be replaced is skipped as invalid. - **Several add-ons.** Only one add-on serves a name. An administrator picks it in Settings, **Tools**, in the same runtime setting as the tools (`chats_tool_providers`, for example `{"xlsx": "office"}`; tool and skill names never overlap). With no choice, the add-on installed first serves, by the same rule as for the tools (see [Install time](#tools-that-replace-a-built-in-tool)). A choice of an add-on that is not running leaves the name to the built-in skill. The Skills page marks the skill of an add-on that is not chosen "Not active: provides it", and the built-in skill "Replaced by ". `GET /api/chats/admin/tools` lists the replaced skills in `skills`, with who serves each one, the candidates oldest install first, `default` (the one that serves with no choice) and `installed_at` (the install time of each). - **Before install.** The library entry and the installed view list the skills an add-on replaces in `skillShadows`, and the install dialog names each one: "This add-on replaces the built-in skill xlsx." When another installed add-on already replaces it, the dialog says that only one can be active and that the choice is in Settings > Tools. The install is still allowed. - **On the Add-ons page.** The details of an add-on list its chat skills and the built-in skills it replaces. For an administrator, a skill that several add-ons replace has a switch, **Use for xlsx in the chats**: on, this add-on serves it; off, the next add-on that replaces it does. The switch writes the same choice as Settings > Tools. In distributed mode the state is **shared**: the files are in the workspace store that every frontend reads, and only the owner writes them, because only the owner holds the verified package. Which add-ons are usable is read from the broker at each turn, as for the tools. With the owner down the add-ons are down, their skills are not offered and the built-in skills serve. ### SDK versions This host provides SDK version 2. An add-on that has a UI declares the SDK versions it works with in `ui.sdk` as a range, for example `"sdk": {"min": 1, "max": 2}`. The host installs and loads the add-on only when its own SDK version is inside that range. If it is not, the install is refused with a message that names the range the add-on needs and the SDK version the host provides: ```text the add-on needs SDK 3 to 3 and this host provides SDK 2 ``` The dispatch response for that refusal has status 403 and error type `sdk_incompatible`, with the message above. A package that does not match its library entry, or is not a valid add-on package, is 403 with error type `package_unverified` and a message that says nothing was installed. A failed install or upgrade can also name its step in the error type: `library_unavailable` (the library could not be read), `library_conflict` (two enabled libraries list the add-on), `release_not_found` (the library has no such add-on or version for this platform), `package_fetch_failed` (the package could not be downloaded) and `install_storage_failed` (the package could not be written to the add-on storage). The owner writes the underlying error to its log as `Addon operation failed`, with the action, the add-on ID and the operation ID. Every other failed install keeps the generic `Addon request denied` message. The owner reads the library once on demand when its startup refresh left nothing in the cache, so an install does not depend on that refresh having finished. If the administrator sends the same operation ID again, the owner returns the stored failure, so the repeat gets the generic message and not the refusal text. Use a new operation ID to see the refusal again. ### The SDK 2 surface An add-on page gets these from the SDK object. The page component also receives the query string of its address as `search`, next to `page` and `path`. Props pass through unchanged, and the components are the ones the core pages use, so an add-on page and a core page draw the same thing. - `sdk.ui`: `Button`, `Text`, `Stack`, `Badge`, `Spinner`, `EmptyState`, and the shared `PageHeader`, `ConfirmDialog`, `ErrorWithTraceLink`, `ModelSelector`, `MediaInput`, `WaveformPlayer`, `DistributionBars`, `EmbeddingInspector`, `TabSwitch`, `LoadingSpinner`, `Modal`, `Lightbox` and `HomeModelPicker`. A `ref` reaches the host component, so `HomeModelPicker` keeps its handle. - `sdk.ui` also has the settings kit, for a settings section or any form: see [The settings kit](#the-settings-kit). - `sdk.theme.tokens`: the host's design tokens as CSS values (`var(--dk-...)`), for the add-on's own scoped style sheet: `text`, `muted`, `surface`, `inset`, `edge`, `hover`, `accent`, `accentText`, `accentWash`, `onAccent`, `okText`, `okWash`, `warnText`, `warnWash`, `errorText`, `errorWash`, `space1` to `space6`, `radius`, `radiusSmall`, `radiusMedium`, `textMeta`, `textSmall`, `textBody`, `fontBody` and `fontMono`. Every value is a custom property the host theme defines, in both themes. The same custom properties can be written in the style sheet directly. - `sdk.hooks`: `useMediaCapture`, `useObjectUrl`, `useDelayedAction`, `audioBufferToWavBlob`, `useModels`, `useResources`, `useVoiceProfiles`, `useOperations` and `useMCPClient`. They run in the host React, so call them like any hook. A host without them gives an empty object. - `sdk.navigation.openApp(address, { replace, state })`: opens a page of the web app, a core page or a page of another add-on. Only addresses under `/app` pass; any other address, scheme or API route is refused. The only `state` a page may pass is the way back, `{ from, fromLabel }`, which the Model Editor reads. - `sdk.portal(children, node)`: draws children into a node outside the add-on's own tree, for a full-screen sheet. - `sdk.viewer`: `isAdmin()` and `hasFeature(name)`. They answer for the signed-in user and are read-only. The server still decides every call. - `sdk.iconNames`: the icon names `sdk.Icon` can draw. Any other name draws a neutral glyph. SDK 2 adds `arrow-left`, `arrow-right`, `chart-bar`, `circle-dot`, `clipboard`, `code`, `cube`, `filter-off`, `gender`, `globe`, `headphones`, `help-circle`, `mic`, `play`, `plug-off`, `save`, `shield`, `smile`, `waveform`, `camera`, `stop`, `circle`, `image`, `spinner`, `user-shield` and `mask`. The Studio and Talk add-ons add `boxes`, `compass`, `gauge`, `ghost`, `git-branch`, `images`, `key`, `layers`, `maximize`, `memory`, `music`, `pencil`, `repeat`, `server`, `sliders`, `square-dashed`, `store`, `undo`, `video`, `volume`, `palette`, `phone`, `columns` and `chevron-left`. `sdk.Icon` passes a `style` object. SDK 2 is a superset of SDK 1, but a range is checked as written. A manifest that declares only `"max": 1` is refused by a version 2 host. Before you raise `max` to 2, check the add-on against the SDK 2 surface. A range that is malformed (for example `min` above `max`) keeps the generic `invalid or incompatible UI` error. ## Runtime wire version 1 JSON field names and ordering follow the exported DTO declarations and `encoding/json`; `envelope.json` fixes the canonical envelope representation. Opaque `arguments`, `payload`, and preview `summary` are JSON, not executable SQL or authority. This is not a cross-language cryptographic canonicalization scheme; forwarding must bind the actual transmitted bytes. Unknown/duplicate fields are rejected at manifest and envelope/result/preview/receipt boundaries. An envelope consists of `request` and host-issued `authority`. Request carries `addonId`, optional `resourceId` (empty for addon-level actions), `operationId`, nonzero lifecycle `generation`, `action`, and object `arguments`. Authority carries `actor` (`userId`, `credentialId`, `mode`), `scope`, and RFC3339 `expiresAt`. Modes are `named-user` and explicit `local-owner`; scopes are `read`, `preview`, `apply`, `output` and `act`. The scope `act` and the action `act` go together: an envelope has one only with the other. Principal IDs are opaque nonempty strings up to 256 bytes. Syntactic validity is **not authentication**: the owner must create authority, verify current identity/credential/ACL and mode, and bind scope to the actual method. Never accept browser/model-provided approval. Call `Validate(now, limits)` at admission; unmarshalling checks structure and nonzero expiry but intentionally does not use the wall clock. `Runtime` provides Read, Preview, Apply, Receipt and OpenOutput. A runtime that serves action tools also implements `ActRuntime` (`Act`), described below. Preview is `{operationId,revision:{schema,data},expiresAt,summary}`. Schema revision is nonzero; data revision can start at zero. Apply receives that exact revision separately and must reject stale schema or data. Receipt is `{operationId,state,revision:{schema,data},result}`. States are `pending`, `committed`, `rejected`, `unknown`; unknown is not success or rejection and must be reconciled before another mutation. Committed receipts require a nonzero schema revision. Result has optional `payload` and `outputs`; each output has opaque `handle`, safe basename `name`, MIME `mediaType`, and nonnegative `size`. Handles are not URLs or filesystem paths. OpenOutput returns an authenticated stream; callers close it. DTOs do not create artifacts or authorize downloads. ### The settings kit `sdk.ui` carries the host components a settings section, or any form of an add-on, is made of. They use only the host's classes and tokens, so an add-on draws its settings the way the host draws its own, in the light and the dark theme and at phone width, without a style sheet of its own. Use them instead of plain ``, `