feat(addons): installable add-ons foundation and the Tables add-on (#12702)

* feat(addons): add the add-on runtime, navigation and first-party add-ons

Add the add-on runtime to LocalAI: manifests, the host operation broker,
admin gating by account role, the add-on UI bundle builder and the SDK
for add-on pages. Move the navigation to Chat, Models, Add-ons and
Settings, with pinned add-ons in the sidebar and an Add-ons drawer.

Ship the first-party add-ons (voice, face, fine-tuning, quantization) as
packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS
(darwin/amd64) is enabled in the platform files.

Squashed from the review branch. The tree is unchanged; the pre-squash
history is kept on a local backup branch.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(gosec): tighten file modes and annotate false positives

Write job state, configs and imported models with mode 0600, clean
the paths that are read from disk, and mark the route tables, the
header name and the retry jitter as reviewed false positives.

Assisted-by: Claude Code:claude-sonnet-5-5 gosec
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep API keys off recovery mutations and same-site cookies

POST /api/addons/dispatch forwarded the recovery and update
mutations to the owner for any administrator key, which skipped the
browser-session rule of their own routes. Refuse those actions
there. Also apply the same-origin rule to the legacy token cookie,
not only the session cookie, because the global CSRF check lets
same-site requests through.

Do not fail the start of LocalAI when an add-on library cannot be
refreshed; log it and keep the installed add-ons up.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): refuse a reinstall that would migrate retained data unbacked

An upgrade takes a verified backup before the new package migrates
the data. A reinstall over retained data has no running generation
to stop, so the new package would migrate it without one. Refuse a
reinstall whose data format differs from the retained one.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): release the manager lock while a release is fetched

Submit held the state lock across the catalog lookup and the package
download, so the apps list and status reads stalled for the whole
install. Serialize Submit and Reconcile with their own lock and drop
the state lock around the fetch only.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): kill an add-on only after three missed health probes

One slow 100 ms probe killed a busy add-on and dropped the work it
was serving. Require three misses in a row.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): move settings under the operate hub

The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): accept same-host browser requests that carry no fetch metadata

Browsers do not send Sec-Fetch-* headers over plain HTTP on an address
that is not localhost, so every add-on request from a LAN address was
refused as cross-site. Keep refusing a Sec-Fetch-Site value other than
same-origin. When the header is absent, accept the request only if its
Origin, or failing that its Referer, names the same host and port as
the request.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): show the server's reason when an add-on request is refused

A 403 or 400 answer showed a generic message and hid the reason in the
response body, so a refusal by the browser guard could not be told from
a missing permission. Show error.message from the body as the server
sent it, and keep the generic text when the body has none.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): run the add-on owner in the normal binary

The owner was only enabled when an embedding host set the add-on config,
so a normal run answered every add-on request as unavailable.

Add --addons-enabled (default true) and --addons-ownership-mode
(default named-user, or local-owner; anything else stops startup).
The node is its own owner: state lives under the data path, and the
carrier uses loopback keys made at each start and kept in memory.
--addons-enabled=false turns the owner off completely.

Named-user mode needs the account database. With authentication off
the owner stays off and a warning says how to use local-owner.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): keep the loopback owner address stable across restarts

The single-node loopback transport took a new ephemeral port at each
start. The owner stores its address in its identity file and refuses to
start when the address differs, so every start after the first failed
with "invalid addon owner configuration".

Choose the port once, keep it in the add-on state root (loopback-port,
mode 0600) and reuse it. If the port is taken at a later start, stop
with an error that names the port and --addons-enabled=false, because
another port would break the identity check. The keys and certificate
stay in memory.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): let add-on pages keep the host modules they were written against

A page written for the web UI imports React, the router, i18next and a
few shared components. The add-on builder refuses a bundle that holds a
host file, so moving such a page into an add-on meant rewriting it.

Add a compat mode to the shared builder. It answers those imports from a
directory of stand-ins that read the SDK, bundles the dynamic import of
the page code into the one output file, and collects the style sheets the
page imports. A relative import that is not in the add-on's own source
resolves to the file at the same path under the compat directory.

Widen the SDK for these pages, without a version change: more host
components (spinner, modal, lightbox, model picker) and hooks (models,
resources, voice profiles, operations, MCP client), a style on icons,
the icon names the pages draw, sdk.navigation.openApp to open a page of
the app (only addresses under /app, and the way back as the only state)
and sdk.portal. The shell passes the query string to the page.

The stand-ins cover the router (the tab in the path, the model in the
query), the translator over the host locale files, the core API calls
through sdk.core.fetch, and byte copies of the pure helpers, with tests
that keep the copies equal to the originals.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): ship Studio and Talk as add-ons instead of built-in pages

Studio and Talk were compiled into the web bundle and always listed in
the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning
and quantization: a person installs and removes them, the drawer lists
them only while they are installed, and they are packaged the same way.

Move the pages, their components, hooks and helpers into addons/studio
and addons/talk. Each has a manifest, a UI bundle, a release platform
matrix and a catalog template, and is built by the compat mode of the
shared builder. The core API stays where it is: the pages call it with
the signed-in person's session. The history keeps its browser storage
keys. The unit tests of the moved helpers move with them.

In the core, drop the two pages, the Create group of the sidebar (Chat
joins Home and Models in the old layout), the built-in catalog entries
(the catalog is empty) and the talk locale namespace. The old addresses
(/app/studio, /app/talk and the generator links) redirect to the add-on
pages, with the model as ?model=. The Home /studio command is offered
only while the Studio add-on is installed and opens it.

The release workflow and its tests now list seven add-ons.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): run the Studio and Talk specs against the add-on bundles

The pages are add-ons now, so a spec that draws one installs the real
built bundle first: the apps list names it and its module and style
sheet are served from the build output, the way the add-on owner serves
them. studio-addon.js holds that helper, and the Studio and Talk
fixtures call it.

Update the specs for the new addresses (/app/addons/studio/studio, the
model as ?model=), the session permission the add-on route needs, the
legacy redirects, the Home /studio command that appears only while the
add-on is installed, and the sidebar without a Create group.

Add specs for the add-ons themselves: every tab draws with no script
error, the shell titles the page once, the old addresses say so when the
add-on is not installed, a button of the page reaches a core page with
the way back, and the drawer lists both only after they are installed.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): centre the icon of a built-in tile in its icon box

The drawer drew the icon of a built-in tile (Studio's palette was one)
in a bare 40px box. Only the placeholder box was laid out as a centred
grid with the 20px size, so the real icon sat in the top-left corner at
the size of the page text.

Give the built-in tile the same classes as the placeholder box, and
check in a browser that the icon is centred.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe Studio and Talk as add-ons

Replace the built-in list (now empty) with the Studio and Talk add-on
sections, the new targets of the old links, the Home command, and the
seven packaged add-ons. Describe the SDK additions the pages use:
components, hooks, icons, openApp and portal. Say on the Studio page
that it is installed from the drawer.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): click the shell title in the Studio slash-key spec

The add-on shell titles the page, and the Studio overview no longer draws
a second title, but the generator tabs keep theirs, so a bare h1 locator
matched two elements. Click the first, which is the shell's.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): read the loopback port file through os.Root

The persisted port file was read with a path built from the add-on
root, which gosec reports as a file inclusion through a variable. Open
it through os.Root so the name cannot leave the root, even by a
symbolic link, and require a regular file with no group or other
access. A looser file now fails closed instead of being trusted.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): expect Settings under the Operate hub, not the sidebar

Settings moved out of the sidebar into the Operate hub. The navigation
spec still expected a sidebar link. Assert that the sidebar has no
Settings entry, and that the Operate hub opens Settings as a tab.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): drop Settings from the 404 places

Settings is a tab of the Operate hub and not a destination of its own,
so the 404 page does not list it. Assert that it is absent.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): drive the media history and TTS specs through the Studio add-on

The image, video, sound and speech generators are pages of the Studio
add-on now, and the old addresses redirect to it. Install the add-on
with the Studio fixtures, open its pages and use its compose card, so
the specs run against the page that exists.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): count the first-run steps, not every list item

The recommended models are a list inside the second step, so counting
all list items read 3 only while the models were still loading. Count
the step elements, which does not depend on the load order.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): fail the node refresh by state, not by call count

The spec served the list to the first request and failed the rest. The
first request is not always the one from the page, so the page could
get the failure first and show no list. Serve the list until the page
shows it, then fail the refreshes.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): default to local-owner when auth is off

A server without authentication has no accounts, so named-user left
the add-on owner off. Resolve the unset mode in one place: local-owner
when authentication is off, named-user when it is on. A flag or
environment value still wins, and an unknown value is still refused.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): register an official add-on library by default

Add gallery/addons/index.yaml, a YAML index of the four first-party
add-ons (fine-tuning, quantization, voice, face) in the library format.
The entries come from addonpkg catalog over locally built linux/amd64
packages, so the digests pin those images.

On the first start, when the owner has no sources.json, register the
raw GitHub URL of that index on master as the "official" library and
write the file. The file is then the record: a library the admin
removes stays removed, and an existing file is never changed.
--addons-official-library=false skips the default.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): document the ownership default and the official library

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): list Studio and Talk in the first-party index

Add the Studio and Talk entries to gallery/addons/index.yaml next to
the four existing ones. They come from addonpkg catalog over locally
built linux/amd64 packages, so the digests pin those images.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): expect Studio and Talk in the official index

The official index now lists six add-ons. Update the test that
decodes it so it accepts Studio and Talk.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a real icon to each first-party add-on

Each icon is a 256 by 256 PNG: a rounded square in one colour of the
UI kit palette with a white outline glyph from the kit sprite. Fine-tuning
uses sliders, quantization layers, voice waveform, face user, studio
palette, talk phone and tables database. They were rendered with headless
Chrome from the sprite paths.

Assisted-by: Claude Code:claude-sonnet-5-5 chrome
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): ship a committed icon.png instead of the generated square

The shared UI builder and the Tables builder copy ui/icon.png from the
add-on source tree to the package unchanged when the file exists. They
write the generated placeholder only when it does not. Add a fixture with
an icon and a test for both cases.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): list the tables add-on in the first-party index

Add the Tables entry to gallery/addons/index.yaml next to the six
existing ones. It comes from addonpkg catalog over a locally built
linux/amd64 package, so the digest pins that image. The official index
test now expects seven add-ons.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): make the Voice add-on one page with tabs

The Voice add-on had four pages: recognition, library, create and
speech, and the drawer listed two of them. It now has one page, "voice",
and one drawer entry. Recognition, Library, Text to speech and From a
recording are tabs of that page, drawn with the same tab strip the
recognition page already used. The tab is the first path segment
(voice/library), as in the Studio add-on, and the form that adds a voice
is a view of the Library tab (voice/library/new).

The old page names stay declared in the manifest. Each one redirects to
its tab and keeps the query string, so saved links still open.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): send the old voice routes to the Voice tabs

/app/voice, /app/voice-library and /app/voice-library/new now open the
matching tab of the Voice add-on page while the add-on is installed and
running, and the core pages otherwise. The moved-page helper compares
the first segment of the target with the declared pages, so a target
can name a tab. The docs and the navigation spec list the new addresses.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the Voice tabs and the old page redirects

The add-on unit tests render the tabs instead of the four pages. The
Playwright spec checks the tab strip, the address of each tab, the
missing Library tab for a member, and that the old page addresses of the
add-on redirect to their tab with the query string kept. The migration
table test follows the new targets.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(studio): keep the model menu inside a narrow screen

The Home model picker sizes its menu from the viewport, and assumes the
picker sits at the page gutter. In the Studio composer card the field is
further in, so on a 390px screen the menu of the Sound expansion picker
ended 7px past the right edge. Size the menu from the field it opens
under instead.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): build the add-on bundles once for the whole run

Each worker built the Studio and Talk bundles into the same dist
directory, and the build empties that directory first. A worker that
started, or restarted after a failure, removed the files another worker
was serving, so a Studio spec saw "the style sheet of the add-on could
not be loaded". Build once in a global setup before the workers start.
A run without the setup still builds per worker.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): register the worker when the add-node spec says so

The mocked roster added the worker on the third read of /api/nodes. The
shell and the page both read it, and which one asks first depends on
load, so the page could take the new worker as part of the roster it
opened with and never show it as found. The worker now joins after the
page shows it is listening.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): hold the gallery answer until the skeleton is seen

The spec delayed the answer by a fixed 800 ms and then looked for the
skeleton. The assertion polls at growing intervals, so on a slow machine
it could step over that window. The answer now waits until the skeleton
has been seen, and the spec then checks that the skeleton goes away.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): give the PDF tag the time the other PDF specs have

pdf.js and its worker load on first use. The other specs in this file
wait up to ten seconds for the result of that load. The first one waited
five, and missed the tag on a busy machine.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): build the Face and Fine-tuning bundles in the global setup

The navigation spec serves the built Fine-tuning bundle, but nothing
built it, so two specs failed on a fresh checkout when the dist folder
was missing. The spec also built the Face bundle in a hook that every
worker runs, and a build rewrites the folder the other workers serve.

The global setup now builds Studio, Talk, Face and Fine-tuning once,
before the workers start. The spec builds the two bundles itself only
when it runs without the global setup.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): fix the Home answers in the stale pin spec

The spec asserts that the page shows no alert. The Home page raises its
own alert when the model list fails or a staging operation has failed,
and both answers come from the shared test server. A slow reply or a
failed install left there counted as an alert about the pin.

The spec now fixes both answers, so the alert count tells only about the
pin. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): read the server address in the add-node spec from the page

The spec expected the register address to carry port 8089, so it failed
on any other port. It now expects the origin the page was opened from.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): give the navigation routes spec its own catalog

No page is built in today, so the catalog is empty and the three specs
that expect the studio and talk entries failed on the list and on the
pin seed. They passed only while Studio and Talk were built in.

The spec now sets a catalog of two ungated entries for each example and
restores the old one afterwards. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cli): report a failed write of the MLX hostfile

The tunnel callback ignored the error of os.WriteFile and set the
hostfile variable even when the file was not written. It now logs the
error and leaves the variable alone.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): discard the close error of the held listener

errcheck flags the deferred Close of the listener that keeps the port
busy. The test has no use for that error, so the defer drops it
explicitly.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the add-on tool tests to Ginkgo

The fine-tuning, quantization and add-on host library tests used
t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now
use Describe, It and Gomega, with a suite bootstrap in each package. The
cases and the assertions are the same.

The tables in the host client and the tool schema tests keep one entry
per case.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the host operation tests to Ginkgo

The hostops tests used the testing package calls that forbidigo rejects.
They now run as Ginkgo specs under one suite bootstrap. Every check and
every per-operation case is kept.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the pin store tests to Ginkgo

The pin service, file store, sweep and shared store contract tests used
the testing package calls that forbidigo rejects. The store contract is
now a function that registers its specs for each store, so the file
store, the memory-only store and the database store run the same cases.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): move the remaining add-on tests to Ginkgo

The built-in catalog, tool arguments, loopback carrier, host operation
contract, packaging, application, config, CLI and endpoint tests used
the testing package calls that forbidigo rejects. They now run as Ginkgo
specs. The endpoint tests also drop the unchecked Close errors that
errcheck reports when the auth tag is on.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): wait for the targeted Reinstall button before the install click

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then clicks Reinstall with
a script click. The click can run before the targeted page has rendered
or while the button is still disabled, so no install request is sent and
the poll on the install count times out. It failed 1 run in 10 with two
workers and on every retry in CI.

The spec now waits for the button to be enabled first. The assertions
are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore(addons): refresh the voice package digest in the first-party index

The Voice add-on became one page with tabs and got a new icon, so the
digest in gallery/addons/index.yaml no longer matched the build. Render
the entry again with addonpkg catalog over a locally built linux/amd64
package; the main page is now voice. The other entries are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): wait for the target node's Reinstall button after the popstate

The "generic deletion blocks targeted install at confirmation" spec
dispatches a popstate to set the target node, then waits for a button
named Reinstall and clicks it. Until React renders the targeted page,
the page still draws the generic Reinstall button. It is enabled and has
the same name, so the wait passed on it, the click started a global
install, and no node install request was sent. The poll on the install
count timed out with the confirm dialog still open.

With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the
button it clicked had the title "Reinstall". The spec now waits for the
button titled "Reinstall on <node>". With the throttle it passes 30 of
30 runs. The assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): give the Studio hand-off spec a budget for the cold bundle load

The "a result made through a hand-off records which result it came from"
spec opens the Studio add-on straight from an address, twice. Each is a
cold load: the page shows "Loading" until the add-on module has run, and
the next assertion had the default five seconds. The load takes about
two seconds on an idle run and seven to ten under CPU load, so the spec
failed on a loaded machine with the hand-off note not yet drawn.

With the CPU throttled to 6x and eight busy loops running, it failed on
21 of 30 runs. The first assertion after each load now waits up to 20
seconds, and the spec then passed 30 of 30 under the same load. The
assertions are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): carry the identity page styles in the Face add-on

The Face page is drawn with the shared identity views, but its style sheet
held only the clip and error rules. The page, card, model list, known-faces
table, enrol sheet and distance scale classes had no rules. They looked
right only when the host had already loaded the style sheet of its own
Faces page, which a visit through the add-on drawer does not do. Opened
that way, the cards had no gap, the model list showed bullets, and the
install buttons ran into the names.

The Face sheet now holds the same identity rules as the core sheet, so the
page looks the same whichever way it is opened.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): scope the narrow-screen identity rules of the Voice add-on

The rules for narrow screens (single-column pairs, smaller tool padding,
compact rank rows) were copied into the Voice style sheet without their
media query, so they applied at every width. They now sit in a
max-width 720px query, and the reduced-motion rule in its own query, as in
the core sheet.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drop the repeated title from the Voice and Face pages

The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): replace the Pinned sidebar section with an Apps entry

The sidebar no longer lists pinned add-ons. It shows one Apps entry that
opens the add-ons drawer, with a Show all link below it that opens the
same drawer. Pins are used from the drawer, where a pinned tile keeps
its pin and move buttons. The server pins API is unchanged.

Remove the PinnedNav component and its locale title, rename the
items.addons label to Apps, and update the e2e specs and the add-ons
documentation to match.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): assert the single shell title on the Voice and Face pages

The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles.

Assisted-by: Claude Code:claude-sonnet-5-5 playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): drop the repeated title from the Voice Library and Text to speech pages

The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): list pinned apps under Apps in the sidebar

A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): say that pinned apps are listed under Apps in the sidebar

The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): remove the Show all link under Apps in the sidebar

The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show a first-run card on Apps when nothing is installed

An administrator who opens Apps with no add-on installed now sees a
short card with three steps: choose a library, browse and install,
start the add-on. Its button opens the Browse tab. The card is hidden
once an add-on is installed and for anyone who cannot install.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): hide the pinned apps in the icon rail

When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): stop the backend redirect test racing the installed list

The test reads the redirected URL right after the path changes. The
Backends page then loads the installed list and drops a selected backend
that is not installed, so the backend parameter was sometimes already gone
when the test read it. The mock server has no llama-cpp, and about 15 of
40 repeated runs failed on the backend assertion. The redirect itself was
correct, including show_all.

Serve the selected backend in the installed list, so the page keeps the
selection and the assertions stay as strict as before.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): accept a same-host Referer or Origin for human confirmation

Browsers send no Sec-Fetch-* headers over plain HTTP on an address that
is not localhost, so creating a resource from a LAN address failed with
"Creating a resource requires your own browser session".

When Sec-Fetch-Site is present it must still be same-origin with an
empty destination, as before. When it is absent, Origin or, failing
that, Referer must name the request host and port. A destination or
mode that is present must still be the expected one. This matches the
rule in BrowserProtection. The credential, token cookie, API key and
tool-context checks are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): keep hub entries off the Apps route in the sidebar

The Build hub still owned /app/addons through its legacy Add-ons tab,
so the sidebar lit both Apps and Build on the Apps page. A tab whose
gate is closed now owns no route when the sidebar asks, so only the
Apps entry is active there. The hubs keep their own routes, and the
legacy navigation still gives /app/addons to the Build hub.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): label the pin control on app tiles and in the detail view

The pin was a small icon-only button and hard to find. Each app tile now
shows a button reading Pin, or Pinned with a filled pin, and the detail
view has the same control in its header. Both toggle the same stored pin.

The Apps grid lists pinned apps first, in pin order, then the others.
The sidebar list and the server pins API are unchanged.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): check the Voice From a recording tab has one title

The Library and Text to speech tabs already assert that the add-on shell
title is the only h1. Do the same for the From a recording tab, so a
heading added there later fails the navigation spec.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): stop the add-on shell drawing a title over add-on pages

The app decides what its page shows. The shell drew the add-on name as a
header above every add-on page, so an add-on could not choose its own
title. The shell now draws no header on a live, loading or frozen
add-on page; each add-on titles its own pages. The unavailable and
missing-page states keep the name, because no add-on page is drawn there.

The runtime and shell specs now check that the shell adds no page title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): let each add-on draw its own page title

The shell no longer titles add-on pages, so each add-on draws one h1 per
page. Voice titles its tabs "Voices" (Recognition and From a recording),
"Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech
voice" already had its own. Face draws "Faces", Studio "Studio" and Talk
"Talk". Tables promotes the database list and database name from h2 to h1.
Fine-tuning and Quantization already drew a title.

The add-on unit tests and the navigation and Studio specs now assert
exactly one h1 per page, with the page's own title.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add a kind to library entries and read unknown kinds tolerantly

A library entry now has an optional kind: addon (the default when it is
absent), backend or model. Every index written before kinds existed stays
valid and its entries are add-ons.

A reader keeps the entries of the kinds it knows and skips the others
without reading their fields, so a newer kind never makes an older host
refuse the whole index. The refresh result and the library state report
how many entries were skipped and of which kinds, and the refresh logs a
warning. A kind that is not a plain kind name still makes the index
malformed. Search lists add-ons unless the caller asks for a kind, and
Resolve returns add-ons only, so an add-on install cannot be pointed at
a backend image.

A library policy may name the signer with an anchored identityRegex
instead of an exact identity, so builds from master and from tags both
verify. An add-on install refuses a policy that carries one.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(backends): install a backend from a library entry

A library can now list backends. An entry of kind backend names the
image of the backend gallery by digest, and POST /backends/library/install
installs it on the local node. The gallery stays the source of truth and
its install path is the one used: the library resolves the entry, takes
the signature policy from the library, pins the image by digest and calls
the same install as the gallery. Installing a name that came from a
gallery, another library or by hand is refused and changes nothing.

The signature policy of a backend library names the signer with an
anchored identityRegex, so images signed by the merge workflow on master
and on tags both verify. A pattern that is not anchored is refused.

An install is bounded by a size limit per kind: add-ons keep 512 MiB and
a backend image defaults to 8 GiB, set with --addons-backend-max-bytes.
The limit reads the manifest after the signature check and before any
layer is fetched. The metadata of an installed backend records the
library it came from.

scripts/backends/cmd/backendcatalog renders entries from
backend/index.yaml and pins them against the registry. The pilot is the
CPU build of local-store, in gallery/backends/index.yaml. The library is
registered on the first start only with --addons-backend-library, which
is off by default.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): show the kind of library entries and install backends from them

The Browse tab lists the entries of every kind this host reads and marks
each one as an add-on or a backend. A backend installs through the
backend route and not through the add-on lifecycle: the confirmation
says that the backend gallery is not changed and that a backend runs
native code, and a refusal of the host, such as a name installed from
another origin, is shown with its own reason. The details give the kind,
the alias and the signer pattern.

The library editor can name the signer with a pattern. Without this a
save would drop the pattern of a backend library. The Libraries tab
reports the entries a refresh skipped because their kind is not read.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe backends in the library

Document the entry kinds, how a reader skips a kind it does not know,
the backend entry format, the signer identity pattern, the install
route, the size limit of each kind, the pilot library and the command
that renders it. The backend page links to it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(mcp): install a backend from a library entry

The backend library route installs a backend from an add-on library
entry, and the admin REST surface and the MCP catalog must stay in step.
Add the install_library_backend tool with an in-process and an HTTP
client, the route map entry, the safety-rule listing and a skill recipe.

Listing library entries stays on the add-on routes, which the assistant
does not expose, so no list tool is added.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): carry the add-on library keys in every locale

The add-on library and backend library strings existed only in English,
so the other locales had no value for them. Add the missing keys of the
addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are
the English text until a translation is contributed.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(mcp): install a backend from a library entry"

This reverts commit 7493cff727.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "docs(addons): describe backends in the library"

This reverts commit 57816ed7e6.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(ui): show the kind of library entries and install backends from them"

This reverts commit 49b2b151b7.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(backends): install a backend from a library entry"

This reverts commit 1d8d3e3421.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "feat(addons): add a kind to library entries and read unknown kinds tolerantly"

This reverts commit 484813f856.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(ui): drop the backend library keys from the locales

Revert the part of 39f77ae29 that added strings for backend libraries:
the kind labels, the backend install texts, the signer identity pattern
and the skipped entries notice. The add-on library keys stay.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add an add-ons control to the chat header

Add-on tools were on or off only through the /addons command, with a toast
and no state a person could see. The chat header now has an Add-ons button
in the style of the MCP dropdown. It lists each running add-on that ships
tools with a switch, and a badge shows how many are on.

The chat keeps the ids that are on (addonToolIds) next to the older
boolean, and both are saved with the chat. A chat that has only the boolean
means every add-on. The request sends addon_tools alone when every add-on is
on, and adds addon_tool_ids when only some are. /addons turns all on or all
off and uses the same state.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): let a chat request name the add-ons whose tools it uses

addon_tools=true still offers the tools of every add-on the caller may use.
A new addon_tool_ids metadata key, a comma-separated list of add-on ids,
narrows that to some add-ons. The list counts only with the opt-in, and a
list that names nothing offers no tool rather than all of them.

The executor drops the tools of add-ons that are not listed and refuses a
call to one as an unknown tool, so a model cannot reach a tool the person
turned off. Both keys are consumed so they do not reach the backend.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the per-add-on tool list and the chat header control

Go tests cover the list form (the list filters the tools offered and the
calls allowed) and the unchanged boolean form. Unit tests cover the chat
add-on rules. Playwright specs cover the header control: listing only
add-ons that ship tools, toggling each one, the count, /addons in step with
the switches, and the request metadata.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): check each add-on page has one title when not enabled

The shell names an add-on only where the add-on page cannot mount (not
enabled, failed, no UI, bundle not loaded, no such page). Once the page
mounts it draws its own title, including when it says it is not enabled
on this server, and the shell draws none. The shell code already works
this way; this change states the rule beside the unavailable state and
pins it in the specs.

The shell spec now expects exactly one title in the unavailable states.
The navigation spec checks the Face page without a face model (the
add-on title only) and the Face page of a stopped add-on (the shell
title only).

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): upgrade add-ons on their own when the setting is on

A new auto_upgrade_addons setting (flag LOCALAI_AUTO_UPGRADE_ADDONS,
default off) lets the owner upgrade an installed add-on when its library
lists a newer version. It follows the backend setting: a startup flag, a
runtime setting, and the same schedule as the backend update check (a
first pass shortly after start, then every six hours).

Each pass fetches the library of every installed add-on, and for each
available update builds the same pinned upgrade command an administrator
sends and runs it through the same lifecycle path. The channel rules, the
signer identity, the digest checks, the SDK compatibility check and the
backup before the upgrade all apply unchanged. The only difference is the
gate: the owner acts on its own setting, so no administrator is asked.
A refused or failed upgrade changes nothing and is logged; a candidate
that failed is not retried until the library lists another digest.

With the setting off, nothing runs and upgrades stay manual.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add the Upgrade add-ons automatically toggle to Settings

The toggle sits beside Upgrade backends automatically and is off by
default. It saves the auto_upgrade_addons runtime setting. The English
locale gets the label key, and the Settings test fixture lists the new
field.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): cover the automatic upgrade setting and document it

The setting defaults to off in the config, the runtime settings and the
flag, and is read back from the saved settings file. With it on, a real
owner upgrades an installed add-on through the manager; with it off,
nothing changes, and a change of the setting at run time takes effect on
the next pass. The manager specs show the upgrade goes through the same
pinned path as the manual one, and that a corrupted package, a failed
signature policy, an incompatible release, a missing update checker and
a caller who is not an administrator each leave the installed version in
place. The new specs are added to the lifecycle case inventory.

The add-ons page describes the setting, its schedule and its default.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactor(addons): name the library entry identifier id

The library entry of an add-on used addonId while the shared envelope
and the model entries use id. Release now has the field ID, written as
id, and every writer emits it: the index encoder, the package workflow
templates, the catalog generator and gallery/addons/index.yaml. The
digests in the index did not change.

DecodeIndex still reads addonId as a legacy alias for one release, so
an existing library and a library served by an older tool keep
working. An entry that holds both names is refused. The Apps UI reads
id and falls back to addonId.

Assisted-by: Claude Code:claude-sonnet-5-5 go node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): use the id field in the auto-upgrade test release

The auto-upgrade test arrived after the identifier rename and still
named the release field AddonID, so the application tests did not
build.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): annotate the auto-upgrade actor label for gosec

The actor label looks like a credential to gosec G101. It is a fixed name for upgrades the owner starts itself.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(schema): persist the per-chat add-on tool fields

The chat header persists addonTools and addonToolIds, but Conversation had no fields for them, so the server dropped them on save. Add both and update the generated swagger files by hand because swag is not installed here.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): count the add-ons auto-upgrade setting in the settings groups

The setting sits in the Backends and galleries group, once. The page now lists 54 settings plus the three branding images, so the spec expects 57.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): pause the fake clock ahead of now in the clear-all spec

The fake clock runs from install, so pausing at the current time failed with 'Cannot fast-forward to the past' on a slow runner. The save itself is synchronous and was not lost.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): hold the Studio capabilities reply longer in the skeleton spec

800 ms was shorter than a slow runner needs to render the page, so the skeleton was gone before the check. The product shows it.

Assisted-by: Claude Code:claude-sonnet-5-5 node playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(i18n): add the French add-ons locale

Master added the fr locale after this branch forked. The add-ons unit test requires every locale to carry the add-ons namespace, so fr needs its own addons.json.

Assisted-by: Claude Code:claude-sonnet-5-5 node
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-22 17:57:19 +02:00
1 parent 099d6c3dd0
commit e45dc5e9f7
964 files changed
+159560 -515

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
+++
title = "Focused addon testing"
toc = true
description = "Prepare and verify the toolchain for focused addon tests."
+++
The addon verification scripts separate explicit dependency setup from offline checks.
Run all commands from the repository root. Only the `prerequisites` suite is currently available.
Unknown suites, empty selections, skipped required tests, and failed commands return a nonzero status.
## Required tools
Provide these tools before running setup:
- Go 1.26.x, with `GOTOOLCHAIN=local`. Other minor versions are rejected.
- Node 22.12 or later within Node 22, with npm. The validated setup uses Node 22.22.0.
- `protoc` 26.1. The general Makefile downloader uses a different version.
- A native C compiler (`cc`, or the executable specified by `CC`) with `CGO_ENABLED=1`.
- Python 3, POSIX shell tools, and the runtime libraries required by Chromium.
Setup and integration preparation require at least 20 GiB of free disk space before each major step.
They do not install system packages or fetch Go, Node, or `protoc` automatically.
Place locally provisioned executables in `.cache/addons/bin`, or put them on `PATH`.
For Linux x86-64, the validated tool archives have these SHA-256 checksums:
| Archive | Source | SHA-256 |
| --- | --- | --- |
| `node-v22.22.0-linux-x64.tar.xz` | [Node 22.22.0 distribution](https://nodejs.org/dist/v22.22.0/) | `9aa8e9d2298ab68c600bd6fb86a6c13bce11a4eca1ba9b39d79fa021755d7c37` |
| `protoc-26.1-linux-x86_64.zip` | [Protocol Buffers 26.1 release](https://github.com/protocolbuffers/protobuf/releases/tag/v26.1) | `a7be2928c0454f132c599e25b79b7ad1b57663f2337d7f7e468a1d59b98ec1b0` |
Validate archive checksums before extraction. These archives do not apply to other architectures.
## Explicit setup
Setup downloads Go dependencies, installs the pinned protobuf plugins, and runs `npm ci` against the React lockfile.
It then installs Chromium through the local Playwright CLI, unless you supply an existing browser.
```sh
sh scripts/addons/prepare.sh --prepare
```
The protobuf plugin pins match the Makefile:
- `google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2`
- `google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af`
Go resolves the second pin to module version `v1.4.0`.
The check validates each plugin's embedded module version and checksum, not its version label alone.
Go downloads use module checksum authentication. npm uses the lockfile's package integrity records.
The lockfile currently selects Playwright 1.58.2 and Chromium revision 1208 (Chrome 145.0.7632.6).
The installed Playwright package selects the browser revision; the scripts never invoke `npx`.
To provision only the browser after npm setup, run:
```sh
export PATH="$PWD/.cache/addons/bin:$PATH"
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.cache/addons/browsers"
core/http/react-ui/node_modules/.bin/playwright install chromium
```
Keep `PLAYWRIGHT_BROWSERS_PATH` set to this worktree-local path for manual installation.
The preparation and verification scripts set it automatically.
A browser installed in Playwright's default user cache does not satisfy this setup.
Alternatively, set `PLAYWRIGHT_CHROMIUM_PATH` to an absolute executable path before setup and verification.
Setup then skips the browser download. Verification still requires a successful browser launch and rendered UI.
Neither option installs OS libraries. If launch fails, inspect the reported library names and the browser executable with `ldd` on Linux.
Provide a compatible runtime environment or report the missing libraries as a blocker; do not skip the browser probe.
## Prepare generated inputs
After explicit setup, generate only the inputs needed by the focused tests:
```sh
sh scripts/addons/prepare.sh --integration
```
This command generates `pkg/grpc/proto`, builds the React distribution, and builds two auth-enabled test binaries:
- `tests/e2e/mock-backend/mock-backend`
- `tests/e2e-ui/ui-test-server`
It does not build inference backends or run a full repository build.
It disables Go dependency downloads and requires the populated setup cache.
Repeat this step when the proto, UI, or test-server sources change.
## Check and verify
Inspect the toolchain, installed dependencies, and generated inputs without downloading or rebuilding them:
```sh
sh scripts/addons/prepare.sh --check
```
This read-only dependency check is not a browser or SQLite runtime test.
Run the complete readiness gate separately:
```sh
sh scripts/addons/verify.sh prerequisites
```
Verification performs these checks:
1. Run isolated shell fixtures for missing tools, incompatible versions, failed children, invalid reports, and empty or skipped selections.
2. Inspect the real toolchain and generated inputs.
3. Compile and execute a SQLite transaction with CGO, Ginkgo v2, and Gomega.
4. Start the real UI test server with the mock backend on an isolated loopback port.
5. Wait for server health, launch Chromium, and require a rendered React root.
The SQLite probe uses `go test -count=1 -tags auth` and a machine-readable Ginkgo report.
Verification rejects missing, empty, failed, skipped, or unexpected test cases.
It records child commands, output, and status in `.cache/addons/logs/verify-*.jsonl`.
Ginkgo reports use separate unique files in the same directory.
Server processes stop after the probe, including when the browser check fails.
Checks and verification never install dependencies or browsers.
They set `GOTOOLCHAIN=local`, `GOPROXY=off`, `GOSUMDB=off`, and `GOFLAGS=-mod=readonly` for verification builds.
The npm dependency check uses `--offline`.
Missing cached modules or browser files are failures, not permission to download them.
## Cache layout and test inventory
The scripts keep caches and transient results under `.cache/addons`:
| Path | Purpose |
| --- | --- |
| `bin` | Local tools and protobuf plugins; prepended to `PATH` |
| `go`, `go/pkg/mod` | `GOPATH` and `GOMODCACHE` |
| `go-build` | `GOCACHE` |
| `npm` | npm cache |
| `browsers` | Playwright browser installations |
| `tmp` | Setup, fixture, and verification scratch data |
| `logs` | Command results and Ginkgo reports |
| `downloads` | Optional archives from explicit local provisioning |
Do not commit caches, generated distributions, or test binaries.
Keep the command and required-case inventory in `scripts/addons/suites.json` synchronized with the runner.
Add future suites only when their actual tests and commands exist.
A successful prerequisite gate demonstrates readiness, not acceptance of addon functionality that has no tests yet.
## Gateway verification
Run `sh scripts/addons/verify.sh gateway` for the owner carrier and real-auth HTTP suites.
The inventory lists each report separately and requires their exact combined case count.
Missing reports, skipped cases, empty reports, and unsuccessful child commands fail the gate.
The tests use local certificate authorities, pinned mTLS peers, two receiving frontends,
and a real SQLite identity database. No external identity service or addon port is exposed.
The disconnect fixture consumes the POST envelope before waiting for cancellation.
Leaving that body unread prevents the HTTP/1 server from observing disconnect while
its handler waits, which can strand test-server cleanup. Production owner handlers decode
the bounded envelope before starting a relay.
## Data recovery suite
```sh
sh scripts/addons/verify.sh recovery
```
The `recovery` suite checks backup, upgrade with a migration, restore and purge
of add-on data. `scripts/addons/recovery.sh` runs, in this order: the node unit
tests of the panel rules, the typed client and the locale keys; the Go specs of
`core/services/addons/recovery` (every journal phase of an upgrade and of a
restore, with failures injected through test-only seams, cancellation, a
corrupt backup, too little storage, retention, grant resurrection and purge),
the specs of the policy hold and restore filter, the specs of the Tables format
migration, and `tests/addons/recovery`, which runs the real owner, real
accounts and the real Tables executable over HTTP; and a Playwright spec against
a UI test server. Set `RECOVERY_SHOTS` to a directory to keep the screenshots of
the Playwright run. The suite is part of the foundation gate. Run
`sh scripts/addons/prepare.sh --integration` first: the runner refuses a
server older than the recovery code or a UI build older than the panel.
## Foundation acceptance gate
The foundation gate is the end-to-end check of everything below the first
add-on: the owner, the carrier, the lifecycle, the policy, the proposals, the
file results, the Apps surface and the UI asset route. It is one integration
fixture, not a set of fakes at the seams.
```sh
sh scripts/addons/verify.sh foundation-acceptance # the end-to-end suite
sh scripts/addons/foundation_gate.sh # every foundation suite
```
Run `sh scripts/addons/prepare.sh --integration` first. The gate builds the
server, the protocol fixture and the cluster command itself, so it never tests a
stale binary.
### The fixture
`tests/addons/foundation/cluster` starts three server processes of
`tests/e2e-ui`: the owner and two frontends (`front-a`, `front-b`). Each has its
own listener, data directory and carrier identity. They share one account
database, and they speak the real pinned mTLS carrier. The frontends open no
add-on storage and start no process. The add-on process is the protocol fixture
(`tests/addons/fixture/cmd`), packaged three ways from a catalog served on
loopback after the host was built:
| Package | Declares |
| --- | --- |
| `found-ui` | a service, the tools `echo` and `export`, and a UI module |
| `found-tool` | a service and the same tools, no UI |
| `found-svc` | a service only: no tool and no UI |
The fixture add-on reads `fixture.json` from its package. A mode changes how it
fails: `exit-start`, `never-ready`, `lose-reply` (commit, then drop the reply)
and `orphan` (ignore the closing of the startup channel). Its commits and every
apply request are written to a durable log, so a spec counts side effects across
restarts. Specs use real API keys and real browser sessions; the only route that
is not product code is the owner's loopback control route that registers a
resource, because the fixture has no add-on that creates one.
### What the gate proves
| Case group | Proves |
| --- | --- |
| install, activate, alternate | An install and an enable through a frontend reach the owner, start one process per add-on under the owner only, and reads alternate through the owner and both frontends |
| host unchanged | The host binary and the shipped UI build have the same digest after the whole lifecycle |
| actor propagation | The add-on sees the actor of the credential; forged actor and authority headers change nothing, and a forged administrator cannot reach lifecycle |
| JSON, progress, files, assets | JSON and file relays and lifecycle progress work through a non-owner frontend, a private file downloads through another frontend, and UI assets are served by digest, immutable and only for declared files |
| denials | A missing feature, a stranger, a stale generation and an undeclared tool give the same typed denial on every node |
| exactly once | A confirmed change reaches the add-on once, even confirmed again through another frontend; a lost reply is `outcome_unknown` with the original operation ID and is settled by receipt lookup with no second apply |
| owner down | Every frontend answers 503 with the same body, starts nothing and creates no storage; a restart brings back the last good package and the retained data |
| no duplicate owner | A second owner on the same storage does not keep running, and each add-on keeps one process |
| orphans | A process that outlived its owner is stopped before the next one starts |
| failed upgrade and enable | A new generation that cannot start leaves the last good one running; killing the owner in the middle of an upgrade or an enable keeps the last good code, the retained data and a usable lifecycle |
| disable, remove, reinstall | Data and grants outlive the removal, and a stale generation and its assets are denied after an upgrade |
| sessions and keys | Ending a session or revoking a key on one frontend refuses it on every node |
| browser | A member sees the Apps list only and the server refuses every management request; Open works through a non-owner frontend; a proxy path prefix works for deep links; a session ended on one frontend loses the assets on the others |
| service layer | An upgrade that cannot start keeps the last good generation running and the data; a request canceled during the start puts the last good generation back, keeps the operation open and the same operation ID finishes it; one broken add-on does not stop the owner |
| regression | The existing model and backend gallery specs (`core/gallery`), the gallery operation specs (`core/services/galleryop`) and the process adapter specs (`pkg/model`) pass |
The regression packages are the narrowest ones that run offline in under two
minutes each and that cover the code the add-on lifecycle sits beside. The
`core/gallery` model install specs run with `FIXTURES=tests/fixtures`. The only
skips the gate accepts are the specs that state they need macOS.
One `pkg/model` spec, "includes the exit code and final stderr line for an
unexpected exit", replaces the process-wide logger to capture a warning. It is
a test of the upstream package and the gate does not change it. Run together
with the other specs of the package, goroutines of those specs can log while
the logger is swapped and fail it at random. The gate therefore runs the
package three times: a dry run that fixes the inventory, a run that skips that
one spec, and a run that focuses on it alone. The case passes only when the
dry run lists exactly one such spec, the second run passes every other spec
(or skips it for macOS) and the third run passes that spec once. A spec that is
not accounted for in exactly one of the runs fails the case. A real failure of
the spec still fails the gate, with no retry.
### Reading the result
`foundation_gate.sh` runs every suite in the gate set, in order, and prints the
inventory: the passed count and the skipped count of each suite, and the total.
It exits nonzero when a suite fails, reports a skipped case, or is missing from
the set. A suite that is added to `suites.json` must be named in the script.
The report of the parent review is this inventory plus the case list of
`foundation-acceptance` in `scripts/addons/suites.json`.
## Packaging suite
`sh scripts/addons/verify.sh packaging` checks the first-party OCI package, its release
channels and its workflow. It runs `scripts/addons/package_test.sh`, which prints one
JSON line for each case. It needs no registry, no Docker and no network.
- Go specs of `pkg/addons/packaging`. They build real layouts and then rewrite one part
of them to prove each rule: missing entrypoint, missing declared UI, missing runtime
library, manifest or platform mismatch, unexpected layers or media types, digest
mismatch, invalid permissions, traversal paths, a bundled React runtime and a partial
platform set. They cover the event and channel table (empty, Unicode and long refs,
`latest`, `development`, semver-like names and two refs that normalize alike). They
publish to an in-process registry and check immutable tags, digest-identical retries,
stale promotions and a failed promotion that leaves aliases unchanged. They rewrite a
branch so that the commit an alias names is gone from the checkout, and check that
only a push to the channel's own ref takes it over, that `development` refuses, and
that other git errors fail. The workflow policy has one mutated copy of the workflow
for each rule, including the `validate` chain (`publish` must need `validate`,
`validate` must not use `continue-on-error`, `always()` or `failure()`).
- `tests/addons/packaging`. It builds the Tables executable, runs `package.sh`, publishes
to a loopback distribution, signs the platform image with the fixture authority under
the identity of the rendered catalog entry, installs it with the real installer and
starts the executable under the real supervisor. A signature of another workflow
identity, an owner policy that names the backend workflow and a wrong pinned digest
are each refused.
- Shell cases for `package.sh`, `channel.sh` and `addonpkg policy`, and static checks
that the packaging scripts never push, tag, sign or log in and never name a backend
registry.
The suite never signs, pushes or creates a tag against a real registry. The workflow
itself (registry login, keyless signing, the verification command) is covered by the
policy assertions and by review, not by a live run: running it needs the protected
environment of the repository.
## Tables acceptance
`sh scripts/addons/verify.sh tables-acceptance` is the end-to-end acceptance of the
first-party Tables add-on. `sh scripts/addons/tables_gate.sh` runs every `tables-*`
suite and then this one, in order, and prints the inventory like the foundation gate.
It fails for a suite that is skipped, that leaves a process behind or that is added to
`suites.json` and not named in the script.
What it runs, and what is real:
- **The cluster** is the foundation cluster of `tests/addons/foundation/cluster`: one
owner and two forwarding frontends, each a separate server process with its own
listener, one shared account database and the real pinned mTLS carrier. The accounts
are real: an administrator, the owner of a database, an editor, a viewer, a stranger
and an account without the add-ons feature, each with a stored session and an API key.
- **The package** is the real Tables. `scripts/addons/package.sh` builds the OCI layout
of this platform (twice, for two versions). The suite checks every layer against its
digest, joins the layers into the archive a catalog on loopback serves, and installs
it through a frontend as an administrator does. Nothing is pushed, tagged or signed,
and a receipt that says `publishable` is refused.
- **Calls** use the public HTTP routes only. There is no route into the add-on that a
user could not use, and no seam in the add-on: crashes are `SIGKILL`, a stopped
process is `SIGSTOP`, an owner outage is a killed owner process.
- **Reads of the database** by the test are read-only SQLite opens of the add-on's own
file, to count the receipts it committed.
The specs, by what they prove:
| Area | Specs |
|---|---|
| Roles | Owner, editor, viewer, administrator, stranger and an account without the feature, over the browser module route, the REST routes, the typed proposal route and the chat tool consumer, each through a different node. Blocked consumers (`/v1/messages`, `/v1/responses`, `/v1/mcp/chat/completions`) fail with the typed `addon_tools_unsupported`. |
| Credentials | A paused key, a withdrawn feature, a lowered role (and the proposals it held), a revoked grant and a disabled account are denied on the next call on every node. |
| Authority | Forged actor and forwarding headers, authority, role and approval fields, and every way to confirm a proposal without the proposer's own tab (a key with the right headers, the owner's tab, an administrator, a missing header, a tool-call marker, a wrong digest). |
| Consistency | Schema and data staleness from two tabs, replay of an operation ID from every node, a conflicting request under the same ID, a burst from three nodes with exactly one receipt for each acknowledged ID, and a revocation that races a confirmation. |
| Crash | The add-on process killed in the middle of a batch and the owner killed and restarted in the middle of a batch: every acknowledged change survives, no change is applied twice, and a worker repeats only the same ID. A stopped and killed process leaves an unresolved confirmation that blocks other writes until the owner role resolves it. |
| Lifecycle | Disable, enable, uninstall and install: stale tabs and proposals of the older generation do nothing, and data and grants remain. |
| Files | CSV through a frontend, downloads from every node by the requester only, a revoked viewer, a disabled add-on. |
| Outage | With the owner down, a JSON read, the JSON relay, progress, status, file status, file download, an asset, the apps list, a proposal, the pending list, a confirmation, an apply and a chat tool call all fail closed (503 with the same typed answer from both frontends), and the frontends run nothing and create no storage. |
| Recovery | Backup, verify and restore by the administrator's tab; a corrupt backup, a running add-on, a wrong confirmation, an unknown backup. |
| Upgrade | An explicit check, a redacted status for a member, a key refused, a stale or changed pin refused, one pinned upgrade, the data and grants kept, a backup made first, and a replay of the same operation from another node. |
| Browser | Approval cards from chat on one frontend and the result on the other, prefix deep links, keyboard, 400 pixels, both themes, the unavailable page while the owner is down and the reload after it is back, and nothing left after logout. |
### The evidence manifest
Each run writes `.cache/addons/tables-acceptance-evidence.json`: the commands the run
executed, the case inventory with the state of every case, the native platform
(operating system and architecture), the digests of both packaged versions (the
archive the catalog serves and the image of the OCI layout), a statement that nothing
was published, and, for a case that failed, the path of the retained trace (the logs of
the three servers). A Go test (`TestEvidenceManifest`) checks it against the inventory
of `scripts/addons/suites.json`: the platform is native, the digests are well formed and
distinct, every case passed, and a failed case has a trace that exists.
### Faults the real executable cannot show on its own
Three cases need a fault that the product executable does not have, and the product
executable gets no flag or variable for them. The suite builds a second, test-only
executable from `addons/tables/cmd/tables-faults`, packages it with the same
`scripts/addons/package.sh` and installs it on one cluster. It serves exactly what the
product serves. A spec arms a fault by creating a file in the `fault` directory of the
add-on's data:
- `crash-after-commit` ends the process after the next change committed and before the
answer is sent. The spec checks that the host reports the original operation ID as
unknown from the frontend, that the receipt lookup finds it committed, and that the same
operation again returns the same receipt: one row and one receipt in total.
- `hold-output` keeps a stream of the add-on from being served, so a transfer stays in
flight. The spec disables the add-on and checks that the artifact ends without being
downloadable on any node, that no byte stays on disk and that a new export works.
The expiry spec needs a short artifact lifetime. The fixture's cluster configuration has
an `artifactExpiryMillis` field, which the test launcher passes to the owner as the
programmatic `AddonConfig.ArtifactExpiry` option. No flag sets it and the default stays
one hour. The spec checks that every node gives the same denial for the expired file
and that the database and the backups are untouched.
### What it does not prove
- **Other platforms.** The evidence is of the platform it ran on. Advertising another
platform needs a native run on that platform; the platform matrix keeps it disabled.
- **The free space seam** is a storage bound that needs a seam the real add-on does not
have. The `recovery` suite covers it with an in-process seam. The expiry and the
withdrawal of an artifact are covered here (see below).
- **A needs-recovery upgrade** needs a data format change that fails to migrate. The
`recovery` suite covers it; this suite covers the successful upgrade and the restore.
- **Which interleaving a race takes.** The race and the crash specs accept whatever
order happens and check the invariants. The committed-and-lost-reply case is not left
to chance: see the fault specs below.
- **A refused request is a typed `400`.** A request that the add-on refuses as invalid
(an unknown table, an unknown field) is `400` `invalid_request` on every route: the
runtime client types the add-on's HTTP 400 and 422 as a refusal, and the host answers
it the same way from the apply, read, proposal, chat and artifact paths, and from the
owner and every frontend. The authorization specs pin the exact status.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -398,7 +398,7 @@ default only fires when the field is omitted.
## The WebUI page
**Build → Faces** follows the same layout as the Voices page. **Who is this** looks up a photo against the people you enrolled (`POST /v1/face/identify`, cut-off 0.35 by default, adjustable on the scale). **Same person?** compares two photos (`POST /v1/face/verify`), optionally with the liveness check, and draws the face the model found in each photo. **Enrol a person** takes a photo, a name, optional labels and a permission tick; a copy of the photo stays in the browser only if you tick it.
The page is the **Recognition** page of the Face add-on (**Add-ons → Face**, `/app/addons/face/recognition`). Install the add-on from **Browse add-ons** and start it; the old `/app/face` link opens the same page in the core web UI until then. The page calls the `/v1/face/*` routes described on this page with your own session, and the Build hub has no Faces tab. It follows the same layout as the Voice add-on's Recognition page. **Who is this** looks up a photo against the people you enrolled (`POST /v1/face/identify`, cut-off 0.35 by default, adjustable on the scale). **Same person?** compares two photos (`POST /v1/face/verify`), optionally with the liveness check, and draws the face the model found in each photo. **Enrol a person** takes a photo, a name, optional labels and a permission tick; a copy of the photo stays in the browser only if you tick it.
The people list is kept in the browser because the server has no list call. After a search, a saved person the server did not return is marked "not on the server", and a server restart empties the server's index. Removing a person waits ten seconds behind an Undo toast before `POST /v1/face/forget` is sent. The server matches one face per photo. Detecting faces, attribute guesses (off by default, often wrong) and the raw embedding are under **More tools**. With no face model installed the page says what is missing and offers gallery models to install.
+10
View File
@@ -214,6 +214,16 @@ Earlier jobs are listed below the form. **Reuse** puts a job's setup back in the
A user needs the fine-tuning permission. Without it, the page says the account cannot fine-tune and who can change that.
### Fine-tuning is an add-on
The Fine-Tune page is the `fine-tuning` add-on. It does not ship with LocalAI. An administrator installs it from **Browse add-ons** in the drawer and starts it. Once it runs, it opens from the drawer at `/app/addons/fine-tuning/jobs` and can be pinned. The Build hub no longer has a Fine-Tune tab.
The old link `/app/fine-tune` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise.
The API does not change. The page calls `/api/fine-tuning/*` with the signed-in user's own session, and the server checks the fine-tuning permission on every call, so the add-on never widens what a user may do.
The add-on also offers tools to the chat: list jobs, propose a job, store a small dataset, read the status and the checkpoints of a job, stop a job and export a checkpoint as a model. A tool can only control a job that a tool started. A job started in the page is listed, but a tool cannot stop or export it. A tool never starts, stops or exports anything by itself: it proposes the action and a person confirms it in an approval card. A model or a dataset given as a local path is refused. The tools call the engine through the host operations (see [Host operations](../addons/#host-operations)), which the host setting `LOCALAI_ADDON_HOST_OPS` grants to this add-on by the `finetune.` prefix, and they run with the permissions of the person in the chat.
## Dataset Formats
Datasets should follow standard HuggingFace formats:
+10
View File
@@ -153,6 +153,16 @@ Earlier jobs are listed below the form, with **Reuse** and **Delete** (after a c
A user needs the quantization permission. Without it, the page says the account cannot quantize.
### Quantization is an add-on
The Quantize page is the `quantization` add-on. It does not ship with LocalAI. An administrator installs it from **Browse add-ons** in the drawer and starts it. Once it runs, it opens from the drawer at `/app/addons/quantization/jobs` and can be pinned. The Build hub no longer has a Quantize tab.
The old link `/app/quantize` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise.
The API does not change. The page calls `/api/quantization/*` with the signed-in user's own session, and the server checks the quantization permission on every call.
The add-on also offers tools to the chat: list jobs, propose a job, read the status of a job, stop a job and import the result as a model. A tool can only control a job that a tool started. A job started in the page is listed, but a tool cannot stop or import it. A tool never starts, stops or imports anything by itself: it proposes the action and a person confirms it in an approval card. A model given as a local path is refused. The tools call the engine through the host operations (see [Host operations](../addons/#host-operations)), which the host setting `LOCALAI_ADDON_HOST_OPS` grants to this add-on by the `quantize.` prefix, and they run with the permissions of the person in the chat.
## Architecture
Quantization uses the same gRPC backend architecture as fine-tuning:
+4
View File
@@ -5,6 +5,10 @@ weight = 49
url = "/features/studio/"
+++
{{% notice note %}}
Studio is an add-on. It is not part of the web bundle: install it from **Browse add-ons** in the [Add-ons](../addons/#the-studio-add-on) drawer and start it. Once it is usable, it is in the drawer and can be pinned, and it opens at `/app/addons/studio/studio`. The old links (`/app/studio`, `/app/image` and the other generator links) open it there.
{{% /notice %}}
Studio is the part of the web UI where you make images, video, 3D objects, speech, sound and transformed audio, and find what you made before. It opens on a front page with a prompt box and your own work. Each type still has its own workspace page (Images, Video, 3D, TTS, Sound, Transform, Diarization) where a run happens.
## Make something
+765
View File
@@ -0,0 +1,765 @@
---
title: Tables
---
## Status
Tables is a first-party addon for LocalAI. This page describes the database
lifecycle, the typed schema operations, and structured row queries and
confirmed row changes that exist today, how a chat model uses them as tools,
and the host side of managing databases from the UI: creating, renaming,
sharing and deleting a database, and choosing the database of a conversation.
A table, with the filter and sort of a view, exports as a private CSV file
(see [CSV export](#csv-export)). Import and public file links are not
available. The Tables pages (the native UI) are described in
[The Tables pages](#the-tables-pages).
Tables is a separate executable under `addons/tables`. It is not linked into
the LocalAI server, and nothing of it runs unless an administrator installs
and enables the addon. It reuses the root Go module and needs CGO for SQLite.
```
go build ./addons/tables/...
```
## Deployment, trust and limits
- **One owner.** A database is a SQLite file on the disk of the one LocalAI instance
that owns the add-on. Other frontends forward to it; while the owner is down every
Tables call fails with `503` on every frontend, and nothing is applied later. See
[Singleton owner and its limits](../addons/#singleton-owner-and-its-limits).
- **Trust.** The Tables executable is trusted code that runs as the owner's operating
system user. Tables itself never learns who a caller is: the host gives it the
database ID and the action, and the host decides the role and the approval.
- **Storage and backup.** The data is under the data directory of the add-on, and
backups are made by the administrator through the recovery routes (see
[Backup, upgrade and restore](#backup-upgrade-and-restore)). A copy of the data
directory made while the add-on runs is not a consistent backup.
- **Ownership mode.** Named-user mode keeps databases private to their owners and
shareable. Local-owner mode has one owner and no sharing. The mode is stored with the
data and is changed only while the host is stopped, with an explicit mapping (see
[Moving between the modes](../addons/#moving-between-the-modes)).
- **CSV.** An export is a private, temporary file with the formula policy described in
[Spreadsheet formulas](#spreadsheet-formulas): it protects a spreadsheet, not a
program that trusts CSV.
- **Updates.** The host never upgrades Tables by itself. An administrator checks for an
update and applies one named candidate (see
[Update detection and upgrade](../addons/#update-detection-and-upgrade)); the data is
backed up first.
- **Optional contributions.** The package declares its UI pages and its tools. A host
that has no UI still runs the tools, and a UI never gains a tool the manifest does not
declare. The chat actions it declares are placed and drawn by the host; the page cannot
add a button to the chat of its own.
## What a database is
Each database is one SQLite file with its own metadata. The file is named by an
opaque resource ID that LocalAI assigns. Tables does not choose the ID, does not
store who owns the database and does not decide who may open it. LocalAI keeps
owners and grants and checks them before every call.
| Property | Value |
|---|---|
| Location | `<addon data directory>/tables/databases/<resource-id>.db` |
| Modes | directory `0700`, files `0600` |
| Journal | WAL, foreign keys on |
| Name | 1 to 80 characters, no control characters, no leading or trailing space |
| Size | at most 1 GiB per file, enforced on every connection; a write past the cap fails with a capacity error |
| Locks | busy timeout 3 s, 10 s limit for one operation |
A resource ID must match `^[a-z][a-z0-9-]{0,63}$`. Any other value is refused
before it reaches the file system. Symbolic links are never followed, in the
data directory or as a database file.
Databases are private by default. A new database belongs to the person who
created it and nobody else can list or open it until the owner shares it with
a grant. Administrators who install the addon get no access to databases.
## Tools
The manifest declares these tools. LocalAI rejects any other tool name.
| Tool | Kind | Effect |
|---|---|---|
| `databases-create` | change (schema) | Create the database for a reserved resource ID. |
| `databases-rename` | change (schema) | Change the stored name. Owner role only. |
| `databases-inspect` | read | Name, revision, table names and the tables with their columns. |
| `tables-create` | change (schema) | Create a table. Argument `table`. |
| `tables-rename` | change (schema) | Rename a table. Arguments `table`, `newName`. |
| `tables-drop` | change (schema) | Drop a table and all its rows. Argument `table`. |
| `columns-add` | change (schema) | Add a column. Arguments `table`, `column`, `type`, optional `required`, `unique`, `default`. |
| `columns-rename` | change (schema) | Rename a column. Arguments `table`, `column`, `newName`. |
| `columns-drop` | change (schema) | Drop a column and its values. Arguments `table`, `column`. |
| `rows-query` | read | Select rows of a table with a filter, sort and page. See [Row queries](#row-queries). |
| `rows-aggregate` | read | Count, sum, avg, min and max with an optional group-by column. |
| `rows-export` | read | Export the rows of a table, with an optional filter and sort, as a private CSV file. See [CSV export](#csv-export). |
| `rows-insert` | change (write) | Insert up to 100 rows. Arguments `table`, `rows`. |
| `rows-update` | change (write) | Set columns on rows named by `ids` or a `filter`. Arguments `table`, `set`. |
| `rows-delete` | change (write) | Delete rows named by `ids` or a `filter`. |
| `resource-exists` | read | `{"exists": bool}`; used by LocalAI to settle interrupted creations. |
Tool names use hyphens because LocalAI manifests accept only
`[a-z][a-z0-9-]*` identifiers.
There is no tool that lists every database. The addon only knows the IDs that
LocalAI sends it. LocalAI lists the databases a person may see from its own
resource records, so a listing can never show a database the person has no
grant for.
Changes made from chat are proposals: the model asks, LocalAI previews the
change, and a person confirms it in the host confirmation card. A rename uses
the standard proposal path. Creating a database from chat is a proposal too
(`databases-create` with a `name`, no resource ID). LocalAI reserves a pending
resource for the person who asked, shows the requested name in the card, and
creates the database only when that person confirms it. Cancelling or letting
the proposal expire removes the reservation. Sharing and deleting a database
are not tools.
## Model tools and approval cards
A chat can offer the tools of Tables to its model. The person turns it on per
chat with the add-ons control in the chat header, which lists each addon that
offers tools with a switch and shows how many are on. The `/addons` command of
the composer turns all of them on or off, and it appears only when an enabled
addon offers tools. The chat then sends `metadata.addon_tools` with each
request, and the server decides what this person may call: the opt-in grants
nothing by itself.
```
POST /v1/chat/completions
{"model": "...", "messages": [...], "metadata": {"addon_tools": "true"}}
```
When only some addons are on, the request adds `metadata.addon_tool_ids`, a
comma-separated list of addon ids. The model then sees and can call only the
tools of those addons; a call to any other addon is refused as an unknown tool.
Without the list, every addon the person may use is offered.
```
{"metadata": {"addon_tools": "true", "addon_tool_ids": "tables,notes"}}
```
### Names and schemas
A tool is named `addon__<addonId>__<tool>`, for example
`addon__tables__rows-insert`. The name is valid for OpenAI function names and
fits in 64 characters. The namespace is reserved: an MCP server of a model
cannot publish a tool under it, and an MCP session never runs one.
The schema of each tool is in `capabilities.toolDefinitions` of the verified
manifest (`addons/tables/manifest.json`). The host serves it from the
manifest, not from the addon process, and rejects a manifest whose definitions
declare a keyword outside the closed set, an identity or approval property
(`actor`, `userId`, `role`, `approved`, `confirm`, `digest`, `proposalId`,
`operationId`, `tool` and similar), more than 32 properties, or more than 8 KiB
for one schema. A declared tool without a definition is not offered to a
model: `resource-exists` is one.
Every tool that works on a database takes `resourceId`. The host takes it out
of the arguments and binds it to the request. A model cannot name a database
it has no grant for: the same denial answers every tool.
### What a call does
| Tool kind | Host action | Result |
|---|---|---|
| Read (`databases-inspect`, `rows-query`, `rows-aggregate`, `rows-export`) | `read` | `{"kind":"result","result":...}`. A result over 128 KiB is refused, never cut. A file result is `{"kind":"artifact"}` with private artifact references and no bytes. |
| Row change (`rows-insert`, `rows-update`, `rows-delete`) | `write` proposal | `{"kind":"proposal","proposal":{id,digest,count,...}}` |
| Schema change (`tables-*`, `columns-*`, `databases-rename`) | `schema` proposal | the same proposal reference |
| Creation (`databases-create`) | `create` proposal | the same, with the reserved resource ID |
A tool call never applies a change. It stores a proposal that only the
proposer's own browser session can confirm. The tool adapter has no confirm,
cancel or resolve operation, and no tool of any consumer maps to those routes.
The model is told that nothing changed and that a person must confirm.
These names do not exist and are refused with a typed error: `confirm`,
`approve`, `apply_proposal`, `yes`, any sharing, grant, database deletion or
retained-data purge name, and any name that is not defined by the manifest of
an enabled addon. Arguments that carry identity, role, approval or operation
fields are refused before they reach the owner, so a model cannot forge the
actor, the role or an approval, and a repeated or oversized argument object is
refused as well.
### The approval card
When a tool answers with a proposal, the chat draws the host's card inline.
The card loads the stored proposal from the host by its ID. It shows the
add-on, the database (the requested name for a creation), the operation, the
exact count of effects, a bounded sample, the expiry and the state. The words
of the model are never the preview. Confirm and Cancel are the host's buttons:
Confirm sends the digest that was displayed with the `X-LocalAI-Confirm`
header from the person's own session.
The card is disabled for an expired, blocked, applied, cancelled, rejected or
unknown-outcome proposal and says why. It forgets what it loaded when the
person logs out, another person signs in, the addons feature is withdrawn or
the host stops answering for the proposal. The stored conversation keeps the
proposal ID only: no rows, no digest, no sample.
`GET /api/addons/proposals?state=pending` lists the caller's own pending
proposals, newest first, at most 32. It is where a change proposed by an agent
or a scheduled job waits: those callers can read and propose within their
permissions and can never confirm, because a key has no browser session.
### Consumers
The consumer inventory is executable. The tests scan the sources for code
that discovers or executes tools and fail for a file that has no decision
here, so a new consumer cannot ship without one. A routed consumer takes an
immutable actor from the authenticated request and goes through the broker.
A rejecting consumer never serves add-on tools: the namespace is refused or
filtered there.
| Consumer | Decision | What it is and why |
|---|---|---|
| `chat-completions` | route | `POST /v1/chat/completions` with `metadata.addon_tools`. The actor is the named user or key of the request. |
| `direct-api` | route | The `/api/addons` routes called by the UI, scripts and agents. The actor is the named user or key of the request, revalidated on every call. |
| `addon-tool-executor` | route | The executor that serves the namespace inside a chat request. It holds one actor value and has no setter. |
| `localai-assistant` | reject | The in-process admin MCP server. It has no per-call principal and its catalog holds no addon tool. |
| `model-mcp-sessions` | reject | MCP sessions built from model configuration and shared by every user of the model. The namespace is dropped from discovery and refused at execution. |
| `anthropic-messages` | reject | `POST /v1/messages`. A request with `addon_tools` gets `addon_tools_unsupported` (400). |
| `open-responses` | reject | `POST /v1/responses`. A request with `addon_tools` gets `addon_tools_unsupported` (400). |
| `realtime` | reject | The realtime WebSocket and WebRTC sessions. Long-lived sessions cannot be revalidated between utterances. |
| `legacy-mcp-chat` | reject | `POST /v1/mcp/chat/completions` passes no broker, so `addon_tools` gets the same typed answer. |
| `mcp-discovery-endpoints` | reject | Listing of servers, prompts and resources. They execute no tool. |
| `agent-jobs` | reject | Agent jobs and scheduled tasks. They run unattended and detached from a request principal. Use the API with a key to read and propose. |
| `agent-runs` | reject | Agent runs of the agent pool and their scheduler. Their tool filter drops the namespace. |
| `distributed-agent-worker` | reject | Workers that execute MCP tools for a frontend. The tool and discovery calls refuse the namespace, and the MCP job handler installs the session tool filter, so the agent library never offers or runs it. |
A shared model MCP session is a rejection on purpose. One session answers
every user of a model and keeps no current-user state, so it could not carry
an actor. A deployment that needs add-on tools for an agent uses the
`/api/addons` routes with that agent's own key.
### Several frontends
The tool adapter keeps no runtime state, so it is stateless in the sense of
the distributed-state rule. Each frontend builds the tool list from the apps
list its owner answers for the caller, and sends every call, proposal and
pending list through the same carrier as the other addon routes. A frontend
that is not the owner never holds a package, a proposal or a policy record.
The apps list carries the tool definitions of each enabled addon, at most 32
KiB per addon, so the model-facing schema is the one in the verified manifest
of the installed generation. If the owner cannot answer, discovery fails and
the chat request answers 503 rather than offering stale tools.
### Admin lifecycle tools
The LocalAI Assistant catalog has no add-on lifecycle tool (install, enable,
disable, upgrade, uninstall, library changes). This is deliberate. The
Assistant is one process-wide server with no per-call principal, and its
mutating tools rely on the model asking the person for confirmation in prose.
Add-on lifecycle changes are administration with persistent effects on data
and processes, so they are not exposed that way. Listing and status are read
with `GET /api/addons/apps` and the `addons-installed` management read, which
need a named administrator. A conversational lifecycle tool needs a per-call
principal and a host approval card first; it is not part of this release.
REST-to-MCP parity for `/api/addons` is intentionally partial for that reason,
and `TestToolHTTPRouteMappingComplete` stays unchanged. The UI-only resource
routes (create, rename, share, delete, selection) are likewise not tools:
sharing and deletion are never tools, and the consumer matrix test scans the
catalog for any `/api/addons` route.
## How creation survives a crash
Creation has three steps, in this order:
1. LocalAI records a pending resource for the creator. A pending resource is
invisible to everyone, its creator included.
2. The addon creates the database file. It builds the file under a temporary
name and links it into place only when it is complete.
3. LocalAI commits the resource, which makes it usable.
The addon operation ID is derived from the reserved resource ID, so repeating
a step is one operation. If LocalAI stops between steps, it settles every
pending resource at the next start by calling `resource-exists`:
- No database: the pending record is dropped.
- Database present: the resource is activated for the creator.
- The addon is installed but cannot answer: LocalAI stops its startup with a
recovery-required error. It never guesses.
Deleting a database follows the same rule from the other side. LocalAI removes
the record of a deleting database without calling `databases-delete` only when
`resource-exists` answers that the database is gone. If the addon cannot
answer, the deletion stays pending and is retried.
A database file without a record cannot be reached by anyone. A creation whose
outcome is uncertain keeps its reservation, and that reservation counts against
the per-user limit of pending resources until its receipt is found, the person
who asked stops waiting, or the next start. Stopping to wait drops the
reservation only when `resource-exists` says there is no database. A pending
chat proposal keeps its reservation, so a restart does not remove a database
you can still confirm.
## Managing databases from the UI
Creating, renaming, sharing and deleting a database are the person's own
actions in the Tables pages, through the routes described in
[UI-only resource management](../addons/#ui-only-resource-management). They
are not tools: a model never sees them and sharing and deletion are never
proposals.
- `databases-create` and `databases-rename` are the same tools that chat uses,
run by the person's explicit request with no approval card.
- `databases-delete` is an admin tool. The manifest lists it in
`capabilities.adminTools` and not in `tools`, so it has no model definition and
the declared-tool rule refuses it on every other path. The addon runs it only
under the host action `delete` and only for the revision the owner confirmed.
It removes the file and its journal files and records the operation in the
ledger; repeating the operation, also after a crash between the removal and the
receipt, gives the same receipt.
- The manifest `capabilities.resources` maps `create`, `rename`, `inspect` and
`delete` to these tools, with the noun `database`.
A database is shared whole, as `viewer` or `editor`, with named users picked by
account. An owner and an editor see the same rows; only the owner changes the
schema, shares, renames and deletes. Deleting asks the owner to type the name,
removes the data and every grant, and never uninstalls the addon.
### The database of a chat
The owner keeps which database each conversation works on, per person. A tool
call that names no `resourceId` uses it, so a person can say "add a row" after
choosing a database once. The model never sets it, and the owner authorizes it
for the person on every call: a database whose grant was withdrawn or that was
deleted is refused like an unknown one. With none selected the tool answers a
`select_resource` result and runs nothing.
## The Tables pages
Tables ships its own UI as a standalone ES module in the package. It is loaded by
the host after the host was built, so installing or updating Tables changes the
pages without a rebuild of LocalAI. The Add-ons drawer shows a **Tables** tile.
| Where | What |
|---|---|
| `Databases` list | Your databases and the ones shared with you, each with your role, who shared it and, for the owner, how many people have access. Create, rename and delete (owner). |
| A database | The tables as tabs, the typed grid of the selected table, a structured filter, sort by column, cursor paging and the row actions of your role. |
| Schema (owner) | Create, rename and drop tables and columns. A drop says what is lost and asks for the name to be typed. |
| Share (owner) | The whole database is shared as `viewer` or `editor` with named accounts found by a picker. Local-owner mode says that sharing is not available and makes no sharing call. |
| `Select database` | A chat action in the composer menu: the databases you may use with your role, the one selected for this conversation, and a way to clear it. |
What each role sees:
| Role | Rows | Schema | Share, rename, delete |
|---|---|---|---|
| Owner | read, add, edit, delete | yes | yes |
| Editor | read, add, edit, delete | read only | no |
| Viewer | read | read only | no |
Hiding a control is never the control. The server checks the role of every call
again, and a crafted write of a viewer, a schema change of an editor or a share
of anybody who is not the owner is refused with the answer of an unknown
database.
### Editing rows
- **Typed editors.** Text, whole number, number, yes or no, and date and time
(entered and shown in UTC). A value of the wrong type is refused in the form
and nothing is sent. Integers beyond 2^53 are kept exact. An optional column
has an *Empty* switch that sends `null`.
- **Filters.** A list of conditions over the table's own columns, joined by
all or any, with the operators the column type supports. They become the
`filter` of `rows-query`; there is no free text and no expression. The row
ID is filterable as `_id`.
- **Paging.** The add-on's cursor goes forward and the page keeps the cursors it
used to go back. A cursor that the add-on no longer accepts (the data
changed) starts again at the first page and says so.
- **Batches.** *Add rows* takes up to 100 rows. A selection is changed or
deleted in steps of at most 1000 row IDs, each step saved on its own with its
own operation ID. If a later step fails, the page says how many rows were
changed before it.
- **Revisions.** Every change sends the revision the page read. If another
person changed the database, the save is refused as stale, the form keeps what
you typed and offers *Reload and apply again*, which reads the revision and
sends the same change once more under a new operation. An unknown outcome
keeps the operation ID, so repeating is the same change and never applies
twice.
A row editor sends only the fields you changed. A datetime keeps its stored
fraction of a second (up to nine digits) when you edit another field, and two
spellings of one instant, such as another UTC offset, are no change.
Row and schema changes made in these pages are the person's own action. They
need no confirmation card and are not proposals, and they still go through the
host's `POST /api/addons/resources/{resourceId}/apply`: the role of the action,
the declared tool definition, the admission of the resource, the revision and
the receipt (see [Changing data and schema from the UI](../addons/#changing-data-and-schema-from-the-ui)).
### The database of a chat
Choosing a database in the `Select database` action stores it for you, the
conversation and the add-on. A chat tool call that names no database uses it,
and the chat sends the conversation ID in the `X-LocalAI-Conversation-Id` header
only when the conversation has add-on tools enabled. Two people in the same
conversation ID, or one person in two conversations, never share a selection. A
database that is no longer shared with you is refused like an unknown one.
### Package and build
The UI source is `addons/tables/ui`. `node addons/tables/ui/build.mjs` writes
`dist/ui/tables.js`, `dist/ui/tables.css` and `dist/ui/icon.png`, which the
manifest names under `capabilities.ui` (`entry`, `css`, `icon`, `pages`,
`navigation`, `mainPage`, `chatActions`). The package declares no
dependencies, so it has no lockfile: the build uses the bundler that the host UI
already has installed (`core/http/react-ui/node_modules`) and needs nothing to
download. `node addons/tables/ui/test/run.mjs` runs its unit tests. The module imports
nothing: React, the components, the typed calls and the navigation arrive through
the host SDK, and every class of the style sheet starts with `tbl-`. The UI is
translated to the languages of the host; a missing key reads the English text.
## Schema operations
Only the owner of a database changes its schema. LocalAI refuses the schema
action for every other role before it calls the addon, and the addon refuses
a schema tool that does not arrive as a schema action with an actor and the
preview or apply scope, so a caller of the runtime transport cannot use a row
action to change a schema.
Tables and columns have a label and a stable internal identifier. The label is
what people see. It is 1 to 64 characters, with no control, format or
bidirectional characters, no leading or trailing space, and it is unique in its
scope ignoring case. The internal identifier (`t_...`, `c_...`) is random and
never changes. Labels are never part of an SQL statement, so a rename touches
only the catalog and no label can inject SQL.
| Logical type | Stored as | Default value |
|---|---|---|
| `text` | TEXT | a string up to 1024 bytes, no control characters |
| `integer` | INTEGER | a 64-bit integer |
| `real` | REAL | a finite number that SQLite reads back exactly |
| `boolean` | INTEGER, 0 or 1 | `true` or `false` |
| `datetime` | TEXT, UTC | an RFC 3339 time, stored in UTC |
A default is validated for its type and written through a strict literal
encoder, never copied from the request. There is no type change, trigger,
function, expression, computed column, view or raw SQL.
- A required column needs a default when the table has rows. On an empty table
it needs none.
- A unique column is a unique index. A default that would repeat in more than
one row of a unique column is refused. Several NULL values are allowed.
- Dropping a column rebuilds the table inside the same transaction: create the
new table, copy the rows, drop the old one. A failure leaves the original.
- Limits: 50 tables per database, 64 columns per table.
## Revisions, receipts and previews
Each database has a revision pair, `schema` and `data`. Every schema change
raises `schema` by one. The preview returns the revision it was made against,
and apply must send that revision back. If the database moved, apply is
rejected with `{"error":"stale","revision":{...}}` and nothing changes. Both
numbers must match, because the data a drop destroys depends on the rows.
The change, the new revision and the operation receipt commit in one SQLite
transaction. After a crash the receipt is either in the database or the change
did not happen. The same operation ID with the same request returns the same
receipt and never applies twice. The same ID with another request is a
conflict. An operation that was refused leaves no receipt, so its ID can be
used again. If a reply is lost, LocalAI asks for the receipt, which the addon
finds in the database even when its own ledger was not updated.
A preview runs the change in a transaction that it rolls back, so it accepts
only what apply would accept. Its summary has this shape:
```json
{"count": 1,
"sample": [{"action": "drop column", "table": "Orders", "column": "Total", "rowsLost": 42}],
"dataLoss": {"rows": 42, "columns": 1, "tables": 0}}
```
`rowsLost` of a column counts the rows that hold a value in it. For a table it
counts all rows and `columnsLost` its columns. LocalAI shows `count` and
`sample` in the confirmation card.
## Row queries
`rows-query` and `rows-aggregate` run under the read action, so viewers can use
them. The addon builds each statement from internal identifiers and binds every
value as a parameter. A column or table label is only looked up in the catalog;
it never becomes SQL text. There are no joins, raw SQL, expressions, functions
or subqueries.
```json
{"tool": "rows-query", "table": "Orders", "columns": ["Item", "Qty"],
"filter": {"and": [{"column": "Qty", "op": "gte", "value": 2},
{"or": [{"column": "Item", "op": "starts-with", "value": "pen"},
{"column": "Item", "op": "is-null"}]}]},
"sort": [{"column": "Qty", "desc": true}], "limit": 50}
```
- **Rows** come back as `{"id": n, "values": [...]}` in the order of the
`columns` of the reply. `id` is the stable row ID of the table. It never
changes and is never reused after a delete. Filters and sorts may name it with
the label `_id`; no column may use that label. Omit `columns` to select all.
- **Filter** is a tree of `and`, `or` and conditions: at most 8 levels and 64
nodes. Operators are `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in` (1 to 100
`values`), `is-null`, `is-not-null`, and for text columns `contains`,
`starts-with` and `ends-with`. A boolean column supports `eq`, `ne` and the
null tests only. A node is exactly one of `and`, `or` or a condition; unknown
fields in the arguments are refused.
- **Text matching.** `eq`, `ne` and the order operators on text are
case-sensitive and compare bytes. `contains`, `starts-with` and `ends-with`
are ASCII case-insensitive and treat `%`, `_` and `\` in the value as plain
characters. The value is at most 1024 bytes.
- **NULL.** A comparison (`eq`, `ne`, `lt`, ..., `in`) never matches a NULL
value, so `ne` does not return rows where the column is empty. Use
`is-null` for those, for example `or` of `ne` and `is-null`. A comparison
with a `null` value is refused. In a sort, NULL comes first ascending and last
descending. Aggregates ignore NULL values; `count` without a column counts
rows.
- **Sort** takes at most 4 columns. Rows with equal keys are ordered by row ID,
so a page boundary is stable.
- **Pages.** `limit` is 1 to 500 and defaults to 50. When more rows match,
`hasMore` is true and `cursor` is set; send it back with the same query to get
the next page. A cursor is valid only for the same query and the same database
revision; after any change the addon answers that the cursor is no longer
valid and the caller starts again. There is no total count. Offsets beyond
100000 rows are refused, so add a filter. A page is also cut at 512 KiB of
JSON, and `hasMore` is set.
- **Values in JSON.** text is a string, integer a JSON integer, real a finite
number, boolean `true` or `false`, datetime an RFC 3339 string. Datetimes are
stored in UTC with nine fractional digits and returned in UTC. Integers can be
sent as a number or a decimal string; integers beyond +/- 2^53 are returned
as strings so no precision is lost.
- **Aggregates.** `aggregates` holds 1 to 8 of `{"fn": "count" | "sum" | "avg" |
"min" | "max", "column": "..."}`. `sum` and `avg` need an integer or real
column; `min` and `max` work on integer, real, text and datetime. `groupBy`
names one column; groups are ordered by the group value and limited to 500,
with `truncated` set when more exist. A sum that overflows 64 bits is an
error, not a wrapped number.
- **Cancellation.** A query is bound to the request context and to a per
operation deadline; a cancelled or slow query is interrupted and returns an
error. Each read sees one snapshot of one revision.
## Row changes
`rows-insert`, `rows-update` and `rows-delete` are changes under the host
`write` action: Editors and Owners may propose them and the proposer confirms
them in the host card. The addon refuses a row tool that arrives under another
action and a schema tool that arrives under the `write` action.
- **Insert** takes 1 to 100 rows; each row maps column labels to values. A
column that is left out gets its default, or NULL. A required column without a
default must be given. An explicit `null` for a required column is refused
even when it has a default. The whole batch commits or none of it.
- **Update and delete** name their rows by exactly one of `ids` (distinct
positive row IDs, all of which must exist) or `filter`. There is no way to
touch every row without a filter that selects them, and no truncate. One
operation changes at most 1000 rows; a broader filter is refused with a
request to narrow it. An operation that matches no row is refused.
- **Constraints.** Type, required and unique rules are enforced by the
database. A violation rejects the whole operation, names the column by its
label and leaves the data unchanged.
- **Preview.** The preview runs the same code in a transaction that it rolls
back. It reports the exact `count`, a sample of the first 10 rows (for update,
the values before and after of the changed columns; for delete, the full
rows), an `affectedHash` of the exact row ID set, and `dataLoss.rows` for a
delete. A change that would break a unique rule fails at preview, so the card
never offers it.
- **Receipt.** The payload holds `count`, the affected `ids` and `affectedHash`.
The receipt commits in the same transaction as the rows and the data revision
step. LocalAI reports success only from the receipt.
### Revisions and staleness
The revision is conservative and per database. Every committed row change
raises `data` by one in the same transaction; a schema change raises `schema`.
Apply compares both numbers with the revision of the preview inside the
transaction. If either moved, the change is rejected with
`{"error":"stale", ...}`, and a new proposal and a new preview are required.
Because the revision is unchanged, the set of rows a filter selects is the set
that was previewed: a filter is never evaluated against rows that arrived after
the preview. A write to any table of the database makes a pending row proposal
stale, even when it touched unrelated rows. Two proposals made on the same
revision cannot both apply: exactly one wins and the other is stale.
Row changes use the receipt rules above: the same operation ID with the same
request replays one receipt, another request under the same ID is a conflict,
and a rejected operation does not use up its ID.
## CSV export
`rows-export` writes the rows a table, a filter and a sort select to a CSV
file. It is a read: it runs under the host `read` action, so an Owner, an
Editor and a Viewer may export what they can read, and it needs no approval
card. A chat tool call and the **Export CSV** button of a table page run the
same tool.
```json
{"tool": "rows-export", "table": "Orders", "columns": ["_id", "Item", "Qty"],
"filter": {"column": "Qty", "op": "gte", "value": 2},
"sort": [{"column": "Qty", "desc": true}], "maxRows": 5000}
```
`columns`, `filter` and `sort` follow the rules of [Row queries](#row-queries).
`columns` may name `_id` to write the row ID as a column; empty exports every
column in schema order. `maxRows` is optional. There is no `limit` and no
`cursor`: an export is the whole result, not a page.
### What the file holds
- One snapshot. The export reads the database in one read transaction, so every
row comes from the same revision. A change that commits while the export
runs is not in the file, and the export never blocks it. The file is written
row by row through a small buffer; its memory use does not grow with the
number of rows.
- RFC 4180 records with a header row of the column labels and CRLF line ends.
A field is quoted when it holds a comma, a quote, a CR or LF, or starts or
ends with a space or tab. A quote inside a field is doubled.
- UTF-8, with no byte order mark. Text is written as it is stored. A
spreadsheet that guesses the encoding from the file may need to be told to
read it as UTF-8.
- NULL is an empty field with no quotes. An empty text value is `""`. A reader
that does not tell the two apart reads both as an empty value.
- Booleans are `true` or `false`. Integers are decimal and exact to 64 bits.
Reals are the shortest decimal that reads back as the same number; very large
or very small values use an exponent. Date-times are RFC 3339 in UTC.
### Spreadsheet formulas
A spreadsheet runs a cell that starts with `=`, `+`, `-`, `@`, a tab or a
carriage return as a formula. The export therefore writes a single quote
before every **text** cell, and every column label, that starts with one of
those characters, as the OWASP guidance recommends: `=1+1` is written as
`'=1+1`. A spreadsheet shows the quote as the text marker, not as part of the
value.
Limits of this policy:
- Cells of integer, real, boolean and date-time columns are never prefixed. A
negative number stays a number (`-4`).
- The file now holds `'=1+1`, not the stored `=1+1`. A program that reads the
CSV back must remove that quote itself; the export does not mark which cells
it changed. A text value that really starts with `'` and a formula are
therefore not told apart after the round trip.
- A text value such as `-5 apples` also gets the prefix.
- The policy protects the file against being run as a formula in a spreadsheet.
It is not a defence for a program that treats CSV as trusted input.
### Backup, upgrade and restore
The databases are SQLite files under the data directory of the add-on, with one
ledger. The host backs them up with `VACUUM INTO` while the add-on is stopped, so the
copy is consistent and holds no write-ahead log (see "Data recovery" in the add-ons
reference). Backup, restore and purge are not tools and not part of the manifest:
the administrator's page uses the recovery routes, and no model can reach them.
Tables records the format of its data. `store.FormatVersion` is the format this
build writes; it is kept in the `format` key of each database and, for the whole store,
in the ledger. At start the executable calls `Upgrade`, which compares them, runs
`Migrate(ctx, fromVersion, toVersion)` when the data is older and writes the progress
to `migration.json`. Each database migrates in one transaction, so a step that fails
leaves that database exactly as it was, and the store version is recorded last, so a
run that stopped resumes. Data in a newer format than the executable reads stops the
start with exit status 9 and is left as it is: the host then keeps the add-on
unavailable instead of running it on data it cannot read. No step exists yet because
the first format is the only one.
## Limits
| Limit | Value |
|---|---|
| Rows in one export | 1,000,000 |
| Size of one file | 64 MiB, and never more than the host's file size bound |
| Time for one export, snapshot to last byte | 25 seconds |
| Exports staged at once in the addon | 16, at most 4 for one person |
A result past a bound makes the tool answer `{"exported": false, "error":
"too-large"}` and creates no file; add a filter to export less. A `maxRows`
above the row bound is refused the same way, not clipped. A staged export that
the owner did not fetch within ten minutes expires; the addon removes expired
files when it handles the next export or fetch, and removes every staged file
when it starts.
### How the file reaches the person
There is no separate asynchronous export job in the addon. The CSV is written
inside the read call, within the 25 second bound, because the owner needs the
exact size before it moves a byte. A result that does not fit those bounds
fails with `too-large` instead of running longer. The transfer from the addon
to the owner's private store is the background job: the read returns at once
with the artifact in state `transferring`, and the page and the chat ask the
host for its state until it is final. Cancelling the transfer, a revoked share
and a stopped addon end it and remove the partial bytes.
The addon writes the CSV into its private directory and answers with one
output description: a name (`<table>.csv`), the media type `text/csv`, the exact
size and an opaque handle bound to the operation, the person and the database.
LocalAI streams the bytes from the addon, checks the size and replaces the
handle by its own artifact identifier. The page, the chat and the model get
that identifier, the name, the size and the expiry. They never get the addon's
handle, a path or a URL. The transfer runs in the background; the artifact
shows `transferring`, then `complete`, `failed` or `cancelled`, and an export
that did not complete never becomes downloadable.
- `GET /api/addons/artifacts/{id}` is the transfer state, name, size and
expiry. `GET /api/addons/artifacts/{id}/download` is the file, as an
attachment with `X-Content-Type-Options: nosniff` and
`Cache-Control: private, no-store`. Both need the person's own session or
API key.
- The file is private to the person who exported it. Sharing the database does
not share a file. A person who is not the principal, an unknown identifier
and an expired file all get the same 403.
- Access to the database is checked again when the transfer completes and at
every download, and a revocation stops a download in progress. After a
viewer's share is withdrawn the viewer's earlier files stop downloading.
A file already downloaded cannot be recalled.
- A file expires one hour after it was made, and the person's quota is
100 files. Expiry removes the file only. The database and any backup are
not touched.
- A disabled addon does not serve downloads; enabling it again serves the
files that have not expired.
- In single-owner mode there is one local owner and no separation between
people, as for every other operation.
### In the pages and in chat
A table page has an **Export CSV** button for every role. It exports the table
with the filter and sort of the view, optionally with the row ID, and then
shows the host's file card: name, size, expiry and the download button. In
chat, an export tool call is answered with the same card. The chat stores only
the artifact identifier; after expiry the card says the file expired.
## Limits
- One name per database, no uniqueness rule across databases.
- Rename needs the schema revision from the preview. A changed schema makes
the rename rejected, and a new preview is required.
- The data directory holds the databases and a small operation ledger. Both
are kept when the addon is disabled or uninstalled.
## How to try it
The addon is not published in a catalog yet. Acceptance builds the package locally and
installs it from a catalog on loopback; it never pushes, tags or signs. Build the executable, put it in a
package with `manifest.json` from `addons/tables`, and install the package from
a local source as described in [Addon contracts](../addons/). The suite
`sh scripts/addons/verify.sh tables-databases` runs the store and the real
executable under the real supervisor. The suite
`sh scripts/addons/verify.sh tables-schema` covers the schema operations,
revisions and receipts.
The suite `sh scripts/addons/verify.sh tables-rows` covers queries, filters,
aggregates and confirmed row changes with the real executable.
The suite `sh scripts/addons/verify.sh tables-tools` covers the model tools,
the consumer inventory, the pending proposal list and the approval card in a
real browser against the real owner.
The suite `sh scripts/addons/verify.sh tables-csv` covers the CSV export: the
snapshot and encoding rules, the output handles, the private artifacts of the
real owner and the download from the owner and two frontends.
The suite `sh scripts/addons/verify.sh tables-ui` covers the pages: the node
tests of the field codecs, the filter builder, the batch bounds and the build,
the UI-intent change route against the real owner, and a browser spec with
real sessions for the roles, private defaults, sharing and a concurrent revoke,
destructive deletion, a stale tab after an upgrade or a disable, the database of
a chat for two conversations and two people, keyboard, 400 pixels, both themes,
clearing on sign-out and local-owner mode.
The suite `sh scripts/addons/verify.sh tables-acceptance` is the end-to-end
acceptance (see [Acceptance](../../development/addon-testing/#tables-acceptance)).
The suite `sh scripts/addons/verify.sh tables-admin` covers the UI-only
management of databases with the real owner, real accounts and the real
executable: private defaults, the owner, editor and viewer matrix, sharing and
the user picker, deletion and its crash reconciliation, the database of a
conversation, and the answers of a forwarding frontend.
+1 -1
View File
@@ -80,7 +80,7 @@ this control may ignore it.
## Voice Library
Administrators can manage reusable voice-cloning references from **Build → Voices → Speech voices** in the LocalAI WebUI. (Speakers, the first tab of the same page, is a separate feature: voiceprints for recognising who is speaking. See [Voice recognition](/features/voice-recognition/).) The library replaces per-model filesystem and YAML setup for supported cloning backends:
Administrators can manage reusable voice-cloning references from the **Library** tab of the Voice add-on (**Add-ons → Voice**) in the LocalAI WebUI. (Recognition, the first tab of that page, is a separate feature: voiceprints for recognising who is speaking. See [Voice recognition](/features/voice-recognition/).) The library replaces per-model filesystem and YAML setup for supported cloning backends:
1. Select **Add a speech voice** and upload or record a clear reference clip. A speech voice can also be made while enrolling a speaker, from the enrol sheet.
2. Enter the exact words spoken in the clip. Add more audio/transcript pairs when the personality needs more examples.
+2 -2
View File
@@ -559,9 +559,9 @@ default only applies when omitted.
## The WebUI page
**Build → Voices** has three tabs. **Speakers** is this feature. **Speech voices** is the [Voice Library](/features/text-to-audio/#voice-library) for text-to-speech, a different store with its own recordings and transcripts. **From a recording** links to the diarization workspace, where a speaker can be named and remembered.
The page is the **Recognition** tab of the Voice add-on (**Add-ons → Voice**, `/app/addons/voice/voice/recognition`). Install the add-on from **Browse add-ons** and start it; the old `/app/voice` link opens the same page in the core web UI until then. The page calls the `/v1/voice/*` routes described on this page with your own session. The Voice page has four tabs. **Recognition** is this feature. **Library** is the [Voice Library](/features/text-to-audio/#voice-library) for text-to-speech, a different store with its own recordings and transcripts. **Text to speech** speaks text with a voice model. **From a recording** links to the diarization workspace, where a speaker can be named and remembered.
On Speakers:
On the Recognition tab:
- **Who is this** matches a clip against the people you enrolled (`POST /v1/voice/identify`). The answer is a sentence with the real distance and the cut-off, a word for how far inside the cut-off it sits (strong, likely, close call), and a scale with the cut-off drawn on it. The cut-off slider re-reads the answer in the browser; it does not call the server again. The page sends the cut-off in the request, 0.25 by default.
- **Same person?** compares two clips (`POST /v1/voice/verify`) and uses the threshold the model returns. The word is not a probability, and the page says so.
+1
View File
@@ -63,6 +63,7 @@ Application shutdown has no context parameter; work that ignores cancellation ca
| `--localai-config-dir-poll-interval` | | Time duration to poll the LocalAI Config Dir if your system has broken fsnotify events (example: `1m`) | `$LOCALAI_CONFIG_DIR_POLL_INTERVAL` |
| `--models-config-file` | | YAML file containing a list of model backend configs (alias: `--config-file`) | `$LOCALAI_MODELS_CONFIG_FILE`, `$CONFIG_FILE` |
| `--addon-host-ops` | `fine-tuning=finetune.,quantization=quantize.` | Add-ons that may call host operations, as a comma list of `addon-id=operation-prefix`. An add-on that is not listed gets no host operation channel; an empty value lists none. See [Host operations]({{% relref "features/addons" %}}) | `$LOCALAI_ADDON_HOST_OPS` |
| `--artifact-download-concurrency` | `1` | How many files of a model artifact to download at once. `1` downloads sequentially. Raising it helps artifacts split into many files on a fast link, at the cost of more concurrent load on the models volume. Whole files only — a single file is never split, so resume and per-file checksum verification are unaffected | `$LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY` |
Backend processes receive a private scratch directory through `TMPDIR`, `TMP`,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,534 @@
# Add-ons navigation design
## Status
- Status: approved for implementation planning. One question stays open (see "Open questions and decisions"). Questions (a) and (b) and the rulings (d) to (j) were decided on 2026-10-16. The changelog at the end lists every change of that day.
- Date: 2026-10-16.
- Owners: Ettore Di Giacinto (maintainer) and the AI agent that drafted this spec.
- Branch: `feat/addons-tables` (PR 12702).
- Scope: documents only. This change adds no source code.
### Approved decisions
1. Top-level navigation has four items: Chat, Models, Add-ons, Settings.
2. The Add-ons item opens a drawer page. It shows the installed add-ons as a grid of tiles. Each tile has an icon, a name and a state (running or stopped). The page has a search box, a Browse add-ons button and an "Add a new add-on" entry.
3. Each tile has a pin control. Pinned add-ons appear in the sidebar under a Pinned section. Pinning is per user. The sidebar shows a short pinned list. The drawer shows all installed add-ons.
4. Studio is an add-on. It contains Images, Audio and Video. Talk is an add-on. Both open from the drawer and can be pinned. Neither is a top-level item.
5. Fine-tuning and quantization move out of core into two add-ons, `fine-tuning` and `quantization`, in this change. The API routes (`/api/fine-tuning/*`, `/api/quantization/*`) and the job managers stay in core unless a later spec moves them. The add-ons get the UI and a tool surface. Where an add-on cannot yet reach a backend, a model or the models folder, this change adds host operations; they are new contract surface (see "Host operations for the engine").
6. Faces and voices become two add-ons with their own lifecycle. The Voice add-on holds the voice library management (Voice Library, Voice Profile Create), voice recognition (Voice Recognition) and a new text-to-speech generation page. The Face add-on holds Face Recognition and its library management (the known-faces list and the enrol sheet). Core keeps only the API and the stores behind it: `/v1/face/*`, `/v1/voice/*`, `/api/voice-profiles`, the face and voice registries and the voice profile store. Studio keeps its own audio generation. The Voice text-to-speech page is a separate page that calls the same core text-to-speech route. Voice and Face do not ship with LocalAI (decision 10).
7. Agents, Memory and Skills are candidates for later add-ons. They are out of scope here.
8. Add-ons call core routes directly, with the signed-in user's own session (question (a), decided).
9. Shared UI comes from the SDK, as primitives and per-component decisions (question (b), decided). Views and pages stay in the add-on that owns them.
10. Voice, Face, fine-tuning and quantization do not ship with LocalAI (question (e), decided). They are add-on packages, built for every supported operating system and architecture, listed in the Browse add-ons catalog and installed by an administrator. Studio and Talk are the only built-in add-ons.
11. Voice and Face get no tab in the Build hub (question (d), decided). A user finds them in Browse add-ons and, once installed, in the drawer.
12. Existing accounts get Studio and Talk pinned once, at upgrade. New accounts start with no pins (question (g), decided).
## Background
The React UI sidebar lists many destinations. `core/http/react-ui/src/utils/destinations.js` defines the inline items (Home, Models, Chat, Studio, Talk). `core/http/react-ui/src/components/hub/hubConfig.js` defines two hubs, Build and Operate, with their tabs. `core/http/react-ui/src/components/Sidebar.jsx` draws them (the `sections` array near line 24 and `topItems`).
Add-ons already exist. The Add-ons page is one tab of the Build hub (`hubConfig.js:67-75`). An add-on that declares navigation pages adds links to that tab (`hubConfig.js:233`, `registryState.js:63`).
Built-in features that could be add-ons sit beside the real add-ons. The sidebar has no single place for "extra tools". New tools need a new sidebar row, or a tab in a hub. This design gives them one place.
## Goals
- Keep the top level small and stable: four items.
- Put every optional tool behind one entry, the Add-ons drawer.
- Let each user keep the few add-ons they use daily one click away (pins).
- Reuse the add-on contract fields and the Apps permission rules. Add no new permission model.
- Keep every old URL working through a redirect.
## Non-goals
- Moving API routes. `/v1/*`, `/api/*` and their handlers stay where they are.
- Changing the add-on runtime, the process supervisor or the add-on contract beyond what navigation needs. This spec asks for no new manifest field (see "Manifest fields for navigation"). The one exception is the set of host operations for the fine-tuning and quantization tools (see "Host operations for the engine"), which is new contract surface and is marked as such.
- Moving Agents, Memory or Skills.
- Changing the add-on catalog, signing or install flow.
- Redesigning the pages that move. They keep their current behavior.
## Navigation model
### Top-level items
| Item | Opens | Visible to |
|---|---|---|
| Chat | `/app/chat` | everyone |
| Models | `/app/models` | administrators (as today, `destinations.js:9`) |
| Add-ons | `/app/addons` (the drawer page) | users with the `addons` feature |
| Settings | `/app/settings` | administrators (as today, `router.jsx:266`) |
Other current destinations (Home, Build hub tabs, Operate hub tabs) are not top-level items in this layout. Their placement stays as it is today unless a section below moves them. The sidebar change for those destinations is out of scope for this spec.
### The drawer page
The Add-ons item opens the drawer page at `/app/addons`. The route exists today (`router.jsx:243`). This spec changes what it shows.
The page has these parts:
- A grid of tiles, one per installed add-on the viewer may see.
- A search box. It filters tiles by title and description.
- A Browse add-ons button. It opens the catalog of installable add-ons.
- An "Add a new add-on" entry. It starts the install flow. Only administrators see it, because installing is an administrator action.
A tile shows:
- The icon. It comes from the `icon` URL of the app (`core/services/addons/apps/apps.go:71`). A tile without an icon shows a generic icon.
- The title. It is the localized `title`, or the add-on ID when the manifest has none.
- The state: running or stopped.
- A pin control.
Clicking a tile opens the main page of the add-on.
### Tile states
The Apps view derives the state of an add-on (`apps.go:76-81`, `state()` at `apps.go:236`). The raw process states are `running`, `starting`, `failed` and `stopped`. The tile maps them as follows.
| Tile state | Source | What the tile shows |
|---|---|---|
| Installed, running | installed, enabled, process `running` | Running badge. Click opens the main page. |
| Installed, stopped | installed, and disabled or process `stopped` | Stopped badge. Click opens a detail view. An administrator sees a Start action there. |
| Starting or failed | process `starting` or `failed` | Shown as stopped, with a short status line. Click opens the detail view. |
| Not installed | catalog entry only | Not a tile. It appears only in Browse add-ons. |
A tile never opens the main page of a stopped add-on. The main page needs a running process.
### Manifest fields for navigation
The contract already has the fields navigation needs. They live in `UI` in `pkg/addons/contract/manifest.go`:
| Field | Line | Use in this design |
|---|---|---|
| `ui.title` | `manifest.go:197` | Tile name and sidebar label. Plain text, at most 80 characters (`MaxTitleChars`, `manifest.go:22`). May be localized. |
| `ui.icon` | `manifest.go:194` | Tile and sidebar icon. A packaged image (`.png`, `.webp`, `.jpg`), at most 256 KiB (`manifest.go:25-36`). |
| `ui.mainPage` | `manifest.go:192` | The page a tile opens. It must be one of `ui.pages` (validated at `manifest.go:350`). |
| `ui.pages` | `manifest.go:181` | All pages of the add-on. |
| `ui.navigation` | `manifest.go:182` | Pages that also appear as extra links. Each must be a declared page (`manifest.go:353-356`). |
Rules:
- A tile opens `ui.mainPage`. The Apps view already exposes the page only for an enabled add-on with a valid main page (`apps.go:218`).
- An add-on without `ui.mainPage` has a tile with no Open action. This is the existing behavior.
- `ui.navigation` keeps its current meaning: extra entries. In the new layout they appear inside the add-on detail view and in the sidebar group of a pinned add-on. They do not appear as separate top-level items. The registry already builds their paths as `/app/addons/<id>/<page>` (`registryState.js:63-77`).
This spec adds no manifest field. If implementation shows that a field is needed, for example a "group" or "order", that is a contract change. It needs its own spec.
### Built-in add-ons and packaged add-ons
Studio and Talk are shipped with LocalAI and appear in the drawer like any installed add-on. Voice, Face, fine-tuning and quantization are not shipped with LocalAI (see "Distribution"); once installed they appear in the drawer too. The navigation contract is the same for all of them: a title, an icon, a main page and a state. There are two kinds.
- **Page add-ons** (Studio, Talk). Their pages are core React pages that stay in the core bundle. A server-side built-in catalog lists them and applies the gates in "Permissions". They have no process and no lifecycle of their own. Their tile state is "running" while the feature is enabled.
- **Packaged add-ons** (Voice, Face, fine-tuning, quantization). They are ordinary add-on packages under the existing contract: a manifest, a packaged UI module and an executable. The documented rule "every add-on requires an executable" stays. The executable of Voice and Face is a small protocol server that declares no tools and serves nothing. The executable of fine-tuning and quantization serves typed tools and reaches the job engine in core through host operations. They have their own version, their own signature, and install, enable, disable, update and removal through the existing add-on flow. Their tile state is the process state, as for any add-on.
This choice adds no manifest field. The host operations of the fine-tuning and quantization tools are the only change to the contract (see "Host operations for the engine").
### Distribution
Voice, Face, fine-tuning and quantization are add-on packages and are not part of the LocalAI image or binary. Each package is built for every supported operating system and architecture. The supported set is the one the packaging code accepts and the Tables package already lists:
| Platform | Runner that builds and checks it natively |
|---|---|
| `linux/amd64` | `ubuntu-24.04` |
| `linux/arm64` | `ubuntu-24.04-arm` |
| `darwin/arm64` | `macos-14` (Apple Silicon) |
| `darwin/amd64` | `macos-15-intel` (Intel macOS) |
Windows is not supported: the package format has no `windows` platform and LocalAI's own release builds do not target it. `darwin/amd64` is listed because the package format accepts it; LocalAI's own release builds ignore it, so the maintainers confirm before it is enabled.
The rule stays as it is for Tables: **a platform is enabled only in the change that shows its native check passing on a runner of that platform.** The check starts the packaged executable on that runner. A platform that is listed and not enabled is never built, and the release workflow fails closed when an add-on has no enabled platform.
Discovery. A user finds each package in the Browse add-ons catalog, through a catalog entry that the maintainers render from a template and publish. Once an administrator installs it, it appears in the drawer and can be pinned. Nothing in the sidebar or in a hub points to an add-on that is not installed. The old URLs keep opening the same content: while the add-on is not installed, an old route opens the core page, and once it is installed and running, the route opens the add-on (see "Redirects").
### The Pinned section
The sidebar gets a Pinned section below the four top-level items. It shows the add-ons the current user pinned, in the order the user chose. It has a cap of eight entries. When the user pins a ninth, the UI asks them to unpin one. The drawer has no cap.
Each pinned entry shows the icon and the title. It opens the main page of the add-on. A pinned add-on that is stopped shows a muted icon. Clicking it opens the detail view, like a tile.
An empty Pinned section is hidden.
## Pinning
### Where the state lives
Pin state is per user. It has two parts: the set of pinned add-on IDs and their order.
The pin list is stored on the server, keyed by user ID. Pins follow the user across browsers and devices. A new table holds one row per user and pin: user ID, add-on ID, position. A user can read and write only their own rows.
When authentication is off, LocalAI has one local owner (`auth.AddonLocalOwnerID`, `core/http/auth/addons.go:30-46`). Pins then belong to that owner.
Browser storage (`localStorage`) is not enough. The sidebar already uses it for layout state (`Sidebar.jsx:15-16`), which is fine for a per-browser convenience. A pin is a user setting and must survive a browser change.
The pin state must work with several frontends. The table lives in the shared database, so every frontend reads the same list. This follows the distributed-state guide (`.agents/distributed-state.md`). No per-process cache may hold the only copy.
### The one-time seed
In plain words. When LocalAI is upgraded to the version that has pins, every account that already exists gets Studio and Talk pinned, once. They keep the sidebar entries they had before the upgrade. After that nothing is pinned for them again, so if one of them unpins Studio, it stays unpinned. An account created after the upgrade starts with no pins at all; it finds Studio and Talk in the drawer and pins what it uses.
How it works. The seed runs once, when LocalAI starts the first version that has pins, not when a user reads the pin list. A marker in the shared database records that it ran, so a restart, a second frontend and a later release do not run it again; with several frontends that start together, exactly one runs it. It pins only for the accounts that exist at that moment. The pin routes never add a pin of their own. With authentication off there is one local owner, and that owner counts as an existing account: it gets the seed on the first start of that version, and the marker is kept in the pins file next to the pins.
### API
Two routes are needed. They are user-scoped and need no admin role:
- `GET /api/addons/pins` returns the ordered list of pinned add-on IDs for the caller.
- `PUT /api/addons/pins` replaces the list. The body is the ordered list of IDs. The server drops IDs the caller may not see (see "Permissions").
The route names are `GET /api/addons/pins`, `PUT /api/addons/pins` and, for the built-in tiles, `GET /api/addons/builtin`. `builtin` and `pins` become reserved add-on IDs.
Registration. The three routes are registered by their own function, `RegisterAddonNavigationRoutes`, which the server calls right after `RegisterAddonRoutes`. `RegisterAddonRoutes` and `RegisterAddonRoutesFor` are not changed. The reason: the tests of the existing add-on routes require every `/api/addons*` route that those functions register to hold the `addons` feature, to be in `RouteFeatureRegistry` and to be documented in the `addons` API guide. The navigation routes are user-scoped by design and gate themselves (the built-in catalog applies the gate of each entry, and the pins route filters what the caller may see), so they are not entries of the `addons` feature in the registry. This keeps "API routes are unchanged. Their tests pass without edits" true.
The rest of the checklist in `.agents/api-endpoints-and-auth.md` applies: a swagger tag (`AddonNavigation`), an `/api/instructions` guide (`addon-navigation`), the docs page and the route tests. No MCP tool is added: pins are a per-user UI preference, not an administrator action.
### Disabled or removed while pinned
The server does not delete pin rows when an add-on is disabled, stopped, updated or removed. The sidebar resolves each pinned ID against the Apps list at render time.
| Event | Sidebar | Drawer | Pin row |
|---|---|---|---|
| Add-on stopped or disabled | Entry stays, muted. | Tile shows stopped. | Kept. |
| Add-on removed (uninstalled) | Entry is hidden. | No tile. | Kept for 30 days, then deleted by a cleanup job. If the add-on is installed again, the pin returns. |
| Add-on updated to a new generation | Entry stays. | Tile updates. | Kept. The pin stores the add-on ID, not the generation. |
| Add-on hidden from this user (access change) | Entry is hidden. | No tile. | Kept. |
The 30-day window is a starting value. The implementation plan may change it. It must not be shorter than the time an administrator needs to reinstall after a mistake. The start of the window is stored with the pin, in the database and in the pins file alike, so a restart does not restart the window.
A hidden pin never shows an error. The user just does not see it.
### A user without access
A user who cannot see an add-on never sees its pin. The server filters the pin list the same way it filters the Apps list. The pins route returns only IDs the caller may see. The route does not reveal whether a hidden ID exists.
If an administrator disables an add-on, non-administrators lose the tile and the pin entry at once, because the Apps list stops returning it (`apps.go:182`). The administrator still sees the tile, stopped, so they can start it again.
## Permissions
The drawer reuses the Apps surface rules. It adds no new rule.
The rules live in `Build` in `core/services/addons/apps/apps.go:176-189`:
- An add-on that is not installed is never listed.
- A disabled add-on is listed only for an administrator.
- Every other installed add-on is listed for the caller. The list is sorted by ID.
The HTTP layer is `ListApps` in `core/http/endpoints/addons/apps.go:31`. It resolves the caller with the same actor checks as every other add-on route (`core/http/endpoints/addons/gateway.go:74`). Start and stop need an administrator (`gateway.go:87`).
The `addons` feature gates access to the surface. It is declared in `core/http/auth/permissions.go:37` and is a general feature (`permissions.go:75`). The UI route is wrapped in `<Feature feature="addons">` (`router.jsx:243`). A user without the feature sees no Add-ons item and no drawer.
Built-in add-ons keep their current feature gates. Their pages move behind the drawer, but the gate does not change:
| Page | Gate today | Gate after |
|---|---|---|
| Fine-tuning | `fine_tuning` (`permissions.go:38`) | `fine_tuning`, plus `addons` to see the tile (add-on `fine-tuning`; the page shows the off notice without `fine_tuning`) |
| Quantization | `quantization` (`permissions.go:39`) | `quantization`, plus `addons` to see the tile (add-on `quantization`; the page shows the off notice without `quantization`) |
| Face Recognition | `face_recognition` (`permissions.go:61`) | same, plus `addons` (Face add-on) |
| Voice Recognition | `voice_recognition` (`permissions.go:62`) | same, plus `addons` (Voice add-on) |
| Voice Library, Voice Profile Create | administrator (`router.jsx:239`, `router.jsx:298`) | administrator, plus `addons` (Voice add-on) |
| Voice text-to-speech page (new) | `audio_speech` on `/v1/audio/speech` (`features.go`) | same, plus `addons` (Voice add-on) |
| Studio, Talk | none in the router (`router.jsx:212-216`); each tab inside Studio has its own gate (`Studio.jsx:26-32`) | no `addons` gate; inner gates unchanged |
The four packaged add-ons (Voice, Face, fine-tuning, quantization) are listed by the Apps view, so the `addons` gate applies to them as to any packaged add-on. A tile is not hidden by the feature of its page, because the manifest has no field for that and this change adds none. Their pages are gated again inside the add-on by the feature of each page (a user without the feature sees the off notice), and the server checks every core call (see "Core routes from add-ons").
**Decision: the gate rule for Studio and Talk.** The Studio and Talk tiles ignore the `addons` gate. They are page add-ons, listed by the server-side built-in catalog, which applies only their own gates (none for Studio and Talk, the inner Studio gates unchanged). The Add-ons item and the drawer show for a viewer who holds the `addons` feature or who sees at least one built-in tile. A viewer without the `addons` feature sees the built-in tiles only: no packaged add-on tile, no Browse, no Add entry, and the Apps routes still answer 403. The rule that no user loses access to a page they can open today holds. This replaces the line "A user without the `addons` feature sees no Add-ons item" for the built-in tiles only.
### Core routes from add-ons
**Decision (question (a)).** An add-on UI may call core routes directly: `/v1/face/*`, `/v1/voice/*`, `/api/voice-profiles`, `/v1/audio/speech` and any other core route the add-on page needs.
- The call is made with the signed-in user's own session. It goes through the same middleware as any browser request: `auth.Middleware` resolves the session cookie (`core/http/auth/middleware.go:41`), `auth.RequireRouteFeature` applies `RouteFeatureRegistry` (`core/http/auth/features.go:109-135`, middleware at `middleware.go:248`), `auth.RequireAdmin` guards admin routes (`middleware.go:122`) and `auth.CSRFMiddleware` applies the fetch-metadata check (`core/http/auth/csrf.go:12`).
- The add-on gets no extra or elevated credential. It never sees a token, a cookie value or an `Authorization` header, and it cannot widen what the user may do. An admin-only route stays admin-only for that user: a non-administrator gets 403.
- The mechanism is an SDK helper, `sdk.core.fetch(path, init)`. It wraps the browser `fetch` with `credentials: 'same-origin'`, `mode: 'same-origin'` and `redirect: 'error'`. The browser, not the add-on, sets the `Sec-Fetch-Site` header that the existing checks read (`csrf.go:19-22`, `core/http/endpoints/addons/gateway.go:248-258`). The helper refuses an absolute URL, a path with `..`, the prefix `/api/addons/` (typed add-on calls have their own bound identity) and the prefix `/api/auth/` (account credentials). It compares the prefixes on the decoded path, so `/api/%61ddons/apps` is refused, and it refuses an encoded `/` or `\` in the first two segments, where it could hide a boundary (`/api/addons%2Fapps`). An encoded `/` in a later segment is an identifier and is kept (`/api/voice-profiles/a%2Fb`). It refuses every call after the add-on is withdrawn.
- No new permission model and no new contract field.
### Host operations for the engine
**New contract surface.** The UI of the fine-tuning and quantization add-ons needs nothing new: it calls `/api/fine-tuning/*` and `/api/quantization/*` with the user's session, as decided in question (a), including the SSE progress stream, the dataset upload and the download link. Their model-facing tools run in the add-on process, and today that process has no path to a backend, a model or the models folder, no credential for core routes, no progress or cancel operation for a long job, and no way to receive bytes beyond a 1 MiB payload. The job managers, the stores and the API stay in core, so the tools reach them through host operations.
| Operation | Scope | What it does |
|---|---|---|
| Backend job operation (`finetune.start`, `.get`, `.list`, `.checkpoints`; `quantize.start`, `.get`, `.list`) | read, apply | Starts and reads engine jobs as the calling user. The host validates the model reference, the backend and the argument fields. A result never carries a server path. |
| Progress and cancel (`finetune.progress`, `.stop`; `quantize.progress`, `.stop`) | read, apply | A bounded wait of at most 25 seconds for the next progress event, and a cancel. The contract has no progress stream, so a caller loops. |
| Model registration (`finetune.export`, `quantize.import`) | apply | The host registers the model from the job's own output through the existing export and import services and returns only the model name. The operation takes no path. |
| Inline dataset (`finetune.dataset`) | apply | Stores a small text dataset (up to the payload limit) and returns a reference that a start operation resolves for the same user only. Larger datasets use the UI upload. |
Rules for every host operation:
- An add-on process calls a host operation on a loopback endpoint of its own incarnation, with a credential of its own. Both arrive in the startup object as two optional fields. The protocol version stays 1, and an add-on that ignores the fields works as before.
- A call names the operation ID of a request the host is serving for that add-on at that moment. The host looks up the envelope it issued. No live envelope, an expired authority or a scope below the scope of the operation is refused. The operation runs as the actor of that envelope, never as a user named in the arguments, and the host refuses an argument with an identity or authority name.
- The host checks the feature of the operation (`fine_tuning` or `quantization`) for that actor, as the route middleware does for the same request today. The user key passed to a service is the one the route passes (empty with authentication off), so a job started by a tool appears in the UI list of the same user.
- Only add-ons that a host setting names get the endpoint. It is a host setting, not a manifest field and not a model argument. By default it names `fine-tuning` and `quantization`.
- Job state stays in the core stores, which every frontend shares. A frontend that does not run the add-on process offers no endpoint, so the operation fails closed.
- A model controls only a job that a tool of the add-on started. A job is a resource of the add-on (the `create` tool reserves it); jobs started in the UI are listed but not controllable by a tool.
Finding recorded for the maintainers: `UploadDatasetEndpoint` reads the whole upload without a size bound. This change does not alter that route; the host operation bounds itself.
## Shared UI in the SDK
**Decision (question (b)).** The SDK holds shared primitives. Views and pages always live in the add-on that owns them, never in the SDK. For a domain widget, the rule is: used by one add-on, it stays in that add-on; used by more than one add-on, it moves to the SDK. Core keeps a single copy: the host component files stay where they are and the SDK re-exports them, so core pages render what they rendered before.
The SDK surface is versioned with the add-on contract. This change raises the host SDK version from 1 to 2. An add-on declares the SDK range it needs in `ui.sdk`, which exists today (`manifest.go:180`). An add-on that needs SDK 2 and runs on a host that provides SDK 1 is refused at install with the error "the add-on needs SDK 2 to 2 and this host provides SDK 1". An add-on built for SDK 1 only (`max: 1`) is refused by a version 2 host, so the existing manifests in this repository (Tables and the test fixtures) declare `min: 1, max: 2`. The manifests of add-ons that use the SDK 2 primitives (Voice, Face, fine-tuning, quantization) declare `min: 2, max: 2`: they cannot run on an SDK 1 host, and the install error above says so. The existing UI tests that pin SDK 1 are updated in the same change.
### Classification of the components the four pages import
Counted from the code: `grep` for each import under `core/http/react-ui/src`. "Add-ons" are the add-ons that use the component after the move: Studio (page add-on), Voice and Face.
| Component | Importers | Add-ons that use it | Decision | Reason |
|---|---|---|---|---|
| `Icon` | 200 files | all | SDK, already exported (`sdk.Icon`) | Primitive. |
| `LoadingSpinner` | 46 | all | SDK, already exported (`Spinner`) | Primitive. |
| `EmptyState` | `VoiceLibrary.jsx`, `hostServices.js` | Voice | SDK, already exported (`EmptyState`) | Primitive. |
| `StatusPill` | 3 | all | SDK, already exported (`Badge`) | Primitive. |
| `SideSheet` | 9 | Voice | SDK, already available as `sdk.layers.openPanel` | Primitive; the host owns layers. |
| `PageHeader` | 17 | Voice, Face | SDK, new (`ui.PageHeader`) | Primitive used by two add-ons and 15 core pages. |
| `ConfirmDialog` | 25 | Voice | SDK, new (`ui.ConfirmDialog`) | Dialog primitive; focus and Escape handling must be one copy. |
| `ErrorWithTraceLink` | `MoreFaceTools`, `MoreVoiceTools` | Voice, Face | SDK, new (`ui.ErrorWithTraceLink`) | Primitive used by two add-ons. |
| `UnsavedChangesGuard` | 5 | Voice | not exported | The SDK already has `sdk.markDirty`. |
| `HomeUndoToast` | 13 | Face, Voice | not exported as a component | `sdk.notify` has no undo action. Each add-on draws a small undo bar of its own from `ui.Button` (see "Additions accepted on 2026-10-16"). |
| `ModelSelector` | `VoiceRecognition`, `FaceRecognition`, `AgentTaskDetails`, `studio/Workspace` | Studio, Voice, Face | SDK, new (`ui.ModelSelector`) | Form control used by three add-ons. |
| `biometrics/MediaInput` | `ThreeDGen`, `VideoGen`, `MoreFaceTools`, `MoreVoiceTools` | Studio, Face, Voice | SDK, new (`ui.MediaInput`) | More than one add-on. |
| `audio/WaveformPlayer` | `TTS`, `Sound`, `AudioTransform`, `VoiceLibrary`, `VoiceProfileCreate`, `ClipInput`, `BackendTraceDetail` | Studio, Voice | SDK, new (`ui.WaveformPlayer`) | More than one add-on, and core uses it. |
| `hooks/useMediaCapture` | `AudioTransform`, `MediaInput`, `ClipInput` | Studio, Voice, Face (through `MediaInput`) | SDK, new (`hooks.useMediaCapture`) | More than one add-on. |
| `hooks/useObjectUrl` | `ThreeDGen`, `Diarization`, `AudioTransform`, `ClipInput` | Studio, Voice | SDK, new (`hooks.useObjectUrl`) | More than one add-on. |
| `hooks/useDelayedAction` | `identity/useRegistry`, `users/ApiKeysPanel` | Voice, Face (through `useRegistry`) | SDK, new (`hooks.useDelayedAction`) | More than one add-on. |
| `biometrics/DistributionBars` | `MoreFaceTools`, `MoreVoiceTools` | Face, Voice | SDK, new (`ui.DistributionBars`) | More than one add-on. |
| `biometrics/EmbeddingInspector` | `MoreFaceTools`, `MoreVoiceTools` | Face, Voice | SDK, new (`ui.EmbeddingInspector`) | More than one add-on. |
| `biometrics/TabSwitch` | `MoreFaceTools`, `MoreVoiceTools` | Face, Voice | SDK, new (`ui.TabSwitch`) | More than one add-on. |
| `biometrics/BoundingBoxCanvas` | `MoreFaceTools`, `Workbench` | Face | stays in the Face add-on | One add-on: the voice side of `Workbench` never draws a box (`Workbench.jsx:310`). |
| `biometrics/WaveformStrip` | `MoreVoiceTools` | Voice | stays in the Voice add-on | One add-on. |
| `biometrics/MatchGauge` | none | none | stays in core, not exported | No importer. Not part of this change. |
| `audio/Spectrogram` | `AudioTransform` | Studio | stays in core | One add-on, and Studio is a page add-on in the core bundle. |
| `identity/ClipInput` | `Workbench`, `EnrolSheet`, `VoiceProfileCreate` | Face, Voice | stays in the add-ons, in one shared source directory | Two add-ons use it, but it depends on the identity helpers (`utils/identity`, `utils/referenceAudio`), which are not generic. It is built from `MediaInput`, `WaveformPlayer` and `useMediaCapture` from the SDK. |
| `identity/Workbench`, `KnownList`, `EnrolSheet`, `Verdict`, `DistanceScale`, `ErrorNote`, `useRegistry` | `Workbench` and its children | Face, Voice | stay in the add-ons, in the same shared source directory | These are views. A view never goes in the SDK. Both add-ons build from one source copy, not two. |
| `identity/MoreFaceTools`, `FeatureOff`, `ModelNeeded` | pages | Face (`MoreFaceTools`), both (`ModelNeeded`) | stay in the add-ons | Views. |
| `identity/MoreVoiceTools` | `VoiceRecognition` | Voice | stays in the Voice add-on | View. |
The shared source directory is `addons/internal/identity-ui/src`. Each add-on bundles what it imports from it. The SDK never contains it. The fine-tuning and quantization add-ons share the views of their job pages in the same way, in `addons/internal/tools-ui/src`.
### Additions accepted on 2026-10-16
The first draft of the SDK list left out four things that the add-on pages need. They are accepted and are part of SDK 2. Each is below with one line.
- **`sdk.viewer`**: `isAdmin()` and `hasFeature(name)`, a read-only view of the role and the feature flags the host already shows in its own UI, so a page can show an off notice. The server still decides every call.
- **`sdk.hooks.audioBufferToWavBlob`**: a pure encoder from decoded audio to a WAV blob, which the reference-audio helper of the Voice add-on needs. The SDK now has four hooks: `useMediaCapture`, `useObjectUrl`, `useDelayedAction`, `audioBufferToWavBlob`.
- **26 more icon names** in the icon allowlist: `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`, `mask`. All exist in the host icon sprite.
- **The undo bar**: a small bar with a text and an Undo button for a delayed action, which `sdk.notify` cannot draw. It is built by each add-on from `ui.Button` and `hooks.useDelayedAction`; it is not an SDK component, so the classification above does not change.
## Migration
### Current layout
Routes below come from `core/http/react-ui/src/utils/destinations.js`, `core/http/react-ui/src/components/hub/hubConfig.js` (both drawn by `Sidebar.jsx`) and `core/http/react-ui/src/router.jsx`. All paths are under `/app`.
### Mapping
| Old route | Today | New home |
|---|---|---|
| `/app/chat`, `/app/chat/:model` | Create group | Chat (top level). No change. |
| `/app/models` | top item | Models (top level). No change. |
| `/app/settings` | Operate hub | Settings (top level). No change. |
| `/app/studio`, `/app/studio/:tab` | Create group | Studio add-on, main page. Same path. |
| `/app/image`, `/app/image/:model` | page | Studio add-on, Images tab. |
| `/app/video`, `/app/video/:model` | page | Studio add-on, Video tab. |
| `/app/3d`, `/app/3d/:model` | page | Studio add-on, 3D tab. |
| `/app/tts`, `/app/tts/:model` | page | Studio add-on, Audio area. |
| `/app/sound`, `/app/sound/:model` | page | Studio add-on, Audio area. |
| `/app/diarization`, `/app/diarization/:model` | page | Studio add-on, Audio area. |
| `/app/transform`, `/app/transform/:model` | page | Studio add-on, Audio area. |
| `/app/talk` | Create group | Talk add-on, main page. Same path. |
| `/app/fine-tune` | Build hub | Fine-tuning add-on, page `jobs` (`/app/addons/fine-tuning/jobs`) while it is installed and running; the core page otherwise. |
| `/app/quantize` | Build hub | Quantization add-on, page `jobs` (`/app/addons/quantization/jobs`) while it is installed and running; the core page otherwise. |
| `/app/face`, `/app/face/:model` | Build hub | Face add-on, page `recognition` (`/app/addons/face/recognition`) while it is installed and running; the core page otherwise. The model travels as `?model=`. |
| `/app/voice`, `/app/voice/:model` | Build hub | Voice add-on, page `voice`, tab `recognition` (`/app/addons/voice/voice/recognition`) while it is installed and running; the core page otherwise. The model travels as `?model=`. |
| `/app/voice-library` | Build hub (match) | Voice add-on, page `voice`, tab `library` (`/app/addons/voice/voice/library`) while it is installed and running; the core page otherwise. Whether it also has a drawer tile of its own is open, see question (c). |
| `/app/voice-library/new` | page | Voice add-on, page `voice`, tab `library`, view `new` (`/app/addons/voice/voice/library/new`) while it is installed and running; the core page otherwise. |
| (new) | none | Voice add-on, page `voice`, tab `speech`: text-to-speech generation. No old route. The Voice add-on is one page with tabs; the older page names stay declared and redirect to their tab. |
| `/app/addons` | Build hub tab | Add-ons (top level), drawer page. |
| `/app/addons/:addonId/*` | add-on pages | unchanged |
| `/app/build`, `/app/agents`, `/app/skills`, `/app/collections`, `/app/agent-jobs`, `/app/import-model` | Build hub | unchanged; out of scope |
| `/app/operate` and its tabs | Operate hub | unchanged; out of scope |
The Studio route has a legacy `?tab=` redirect already (`Studio.jsx:78-81`). Keep it.
### Redirects
Every old route must keep opening the same content. There are two cases.
1. The path stays the same (`/app/studio`, `/app/talk`). No redirect. The page is now rendered by the add-on host, not by the core router.
2. The path changes (for example `/app/image` to a path under `/app/addons/<id>/`). The router keeps a redirect entry for the old path. It uses `<Navigate replace>` like the existing redirects (`router.jsx:174-183`). The redirect keeps the model segment, the query string and the hash.
Rules for redirects:
- Redirects are permanent in the app. They are not removed in the release that adds them. Removal needs a separate decision.
- A redirect to a path the viewer cannot open ends on the normal "no access" or "not found" page. It does not reveal the add-on.
- Deep links with a model (`/app/image/:model`) keep the model. A redirect into an add-on carries the model as the query parameter `?model=<id>` and not as a path segment: an add-on path segment refuses `/`, `:`, `%` and long identifiers, and a model identifier can hold them. The add-on reads `?model=` first and the first path segment second.
- A moved page opens in its add-on only while the add-on is installed, enabled and compatible. Otherwise the old route opens the core page, so a user who has not installed the add-on loses nothing.
- Bookmarks to `/app/browse/*` keep working through `BrowseRedirect` (`router.jsx:180-183`).
### Sidebar after migration
- The Create group is removed. Chat moves to the top level. Studio and Talk leave the sidebar unless the user pins them. Home and the Build and Operate hub entries stay where they are today.
- The Build and Operate hubs keep their tabs, except for the tabs that moved to add-ons. The fine-tuning, quantization, Face, Voices and Add-ons tabs leave the Build hub. Voice and Face get no hub entry of any kind (question (d)): the add-ons are not shipped with LocalAI, so a user finds them in Browse add-ons and, once installed, in the drawer. The Build overview page keeps its cards for fine-tuning and quantization; they link to the old routes, which open the add-on while it is usable and the core page otherwise. The hub's `addonNavigation` second row (`hubConfig.js:74`) goes away: its job moves to the drawer and to the pinned group.
- Users who had Studio or Talk in the sidebar see them in the drawer. At upgrade, the server pins Studio and Talk once for the accounts that exist then, so the layout does not change under them. The seed runs once for the deployment and never again, so a later unpin sticks, and an account created after the upgrade starts with no pins (see "The one-time seed").
### Documentation
The change that implements this spec must update the pages under `docs/content/` that describe the sidebar and the add-ons surface, including `docs/content/features/addons.md`. This follows the docs-with-code rule in `AGENTS.md`.
## Open questions and decisions
Question (c) is not decided and needs an answer before the work that depends on it is built. Questions (a) and (b) and the rulings (d) to (j) are decided.
### (a) Direct API calls from the face and voice add-ons
Decided on 2026-10-16. See "Core routes from add-ons".
### (b) Shared UI components
Decided on 2026-10-16. See "Shared UI in the SDK".
### (c) Where the Voice Library entry lives
Voice Library lists the enrolled speech voices. The approved placement puts it in the Voice add-on, as the page `library`. Open: whether it also has a drawer tile of its own, or is reached only from inside the Voice add-on. No decision yet. The implementation plan does not start this work.
### (d) Voice and Face in the Build hub
Decided on 2026-10-16. **Conflict record.** The first draft said two things that cannot both hold while the add-ons are not installed: the Face and Voices tabs leave the Build hub (Migration), and no user loses access to a page they can open today (Risks and test plan). The first draft also said Voice and Face are "shipped with LocalAI" without saying how. **Ruling:** Voice and Face have no hub tab and no other fixed entry. They are not shipped (question (e)). A user finds them in Browse add-ons and, once installed, in the drawer. No user loses access to a page: until the add-on is installed, `/app/voice`, `/app/face` and the other old routes open the core page, and afterwards they open the add-on. The same rule is applied to fine-tuning and quantization, which also leave the Build hub (the Build overview cards stay).
### (e) Distribution of Voice, Face, fine-tuning and quantization
Decided on 2026-10-16. They are add-on packages and do not ship with LocalAI. See "Distribution": the platform list, the runner of each platform, and the rule that a platform is enabled only after its native check passes.
### (f) Fine-tuning and quantization
Decided on 2026-10-16. They move in this change, as two add-ons. The API and the job managers stay in core; the add-ons get the UI and a tool surface; the host operations they need are new contract surface, each with its own tests. See "Host operations for the engine".
### (g) The pin seed
Decided on 2026-10-16. Existing accounts get Studio and Talk pinned once, at upgrade; new accounts start with no pins. See "The one-time seed".
### (h) SDK additions
Decided on 2026-10-16. The viewer, the WAV helper, the 26 icon names and the undo bar are accepted. See "Additions accepted on 2026-10-16".
### (i) Where the navigation routes are registered
Decided on 2026-10-16, taking the smallest change that keeps the existing route tests unedited: `RegisterAddonNavigationRoutes`, a function of its own. See "API". The maintainers may instead edit the three route tests and register the routes inside `RegisterAddonRoutes`; that is a larger change and is not planned.
### (j) What the legacy switch restores
Decided on 2026-10-16. The switch brings back the old sidebar and the old Add-ons hub tab. The redirects of the moved generator pages do not depend on it, and the old page components stay in the bundle (Studio uses them). See "Rollback".
## Test plan
### What must be tested
Route mapping:
- Every row of the migration table resolves. A table-driven test renders the router for each old path and asserts the final location and the rendered page.
- `/app/image/:model` and the other model routes keep the model after redirect.
- Query strings and hashes survive a redirect.
- The `?tab=` Studio redirect still works.
Pin storage:
- Pin, unpin and reorder round-trip through the server.
- A second user does not see the first user's pins.
- With authentication off, the local owner's pins persist across a reload.
- Two frontends on one database return the same list (use the existing fake-bus pattern from the distributed-state guide).
- The cap of eight is enforced in the UI. The server rejects a list over a hard limit.
- The upgrade seed pins Studio and Talk once, for the accounts that exist at the upgrade, and never again. An account created after the upgrade has no pins. A read or a save never adds a pin. With authentication off, the local owner is seeded once and an unpin sticks across a restart. Two frontends that start together seed once between them.
- The start of the 30-day window of a removed add-on survives a restart in both stores.
Permission filtering:
- A non-administrator does not see disabled add-ons, in the drawer or in the Pinned section.
- An administrator sees disabled add-ons as stopped, with a Start action.
- A user without the `addons` feature sees the built-in tiles (Studio and Talk) and no packaged add-on tile, no Browse and no Add entry. `GET /api/addons/apps` answers 403 for that user.
- A non-administrator who calls an admin-only core route from an add-on gets 403. A user without the feature of a core route gets 403 from it.
- A user without `face_recognition` sees the off notice in the Face add-on, and every core face call answers 403. A user without `fine_tuning` sees the off notice in the fine-tuning add-on, and every core fine-tuning call answers 403. The tile itself shows for anyone who holds the `addons` feature, because the manifest has no field to hide it.
- A host operation is refused without a live request of the add-on, with an expired authority, with a scope below its own, for an actor without the feature, and with an argument that names a user. A job started by a tool appears in the UI list of the same user. A result never carries a server path.
- No user loses access to a page they can open before the change.
- The pins route never returns an ID the caller cannot see.
Tile states:
- Running, stopped, starting and failed map to the states in the table.
- A stopped tile does not open the main page.
- Removing a pinned add-on hides the pin. Reinstalling inside the window restores it.
Redirects:
- Each redirect entry is covered. A test fails when a route in the old table is missing.
- A moved route opens the core page while its add-on is not installed and the add-on afterwards. A model identifier with `/` and `:` reaches the add-on through `?model=`.
- `sdk.core.fetch` refuses the denied prefixes written with percent-encoding.
Packaging:
- Each of the four packages lists every platform of the table in "Distribution" with its runner, and a platform is enabled exactly when its native check is recorded as passed. A test fails otherwise.
- The release workflow runs one set of legs for each add-on, builds no platform that is not enabled, and fails closed when an add-on has no enabled platform on a publishing event.
- Each package has a catalog template, so Browse add-ons can list it.
Quality gates:
- Existing UI tests and the coverage ratchet pass. Coverage is not lowered (`AGENTS.md`). The tests that pin SDK 1 are updated with the SDK change.
- The add-on inventory of `scripts/addons/suites.json` matches the specs of each suite.
- The drawer and the Pinned section pass the accessibility checks the UI already runs: keyboard focus, labels on the pin control, and a visible focus state.
- The layout works at phone width.
### Definition of done
- The sidebar shows Chat, Models, Add-ons, Settings and the Pinned section.
- The drawer lists installed add-ons with the states above, search, Browse and Add.
- Studio and Talk open from the drawer. Fine-tuning, quantization, Voice and Face are add-on packages that do not ship with LocalAI: they are listed in Browse add-ons, built for every platform of "Distribution" whose native check passed, and open from the drawer once installed. The only open item is whether Voice Library has a tile of its own (question (c)).
- Every old route in the table opens the same content.
- Pins are stored per user on the server and survive a browser change.
- No permission check is weaker than before.
- API routes are unchanged. Their tests pass without edits.
- The docs under `docs/content/` are updated in the same change.
## Risks and rollback
| Risk | Effect | Mitigation |
|---|---|---|
| A moved page loses a permission gate | A user sees a page they should not | Keep the gate table above. Test every row. The server still checks every API call. |
| A user loses access to Studio or Talk because they lack the `addons` feature | Users cannot reach a page they use | Decide the gate rule in the plan. Test that no user loses access. |
| Old bookmarks break | Dead links | Keep all redirects. Test all of them. |
| Pin table adds state to every frontend | Divergence between frontends | Store in the shared database. Test with two frontends. |
| The drawer hides tools that users used to reach in one click | Slower daily use | Pin Studio and Talk once, at upgrade, for the accounts that exist then. Allow eight pins. |
| The SDK version rises from 1 to 2 | An add-on built for SDK 1 only is refused | The manifests in this repository declare `min: 1, max: 2`. The refusal message names both ranges. |
| An add-on calls a core route with the user's session | The add-on code has the full API reach of the user | Gates stay on the server. The helper refuses `/api/addons/` and `/api/auth/`. Test the 403 paths. |
| Third-party add-ons expect `ui.navigation` to appear in the Build hub | Their links move | Keep the paths. Show the links in the add-on detail view and in the pinned group. |
| A user who has not installed Voice, Face, fine-tuning or quantization no longer has a hub tab for it | The user does not know the tool exists | The old URLs keep opening the core page; the Build overview keeps its cards for fine-tuning and quantization; each package is listed in Browse add-ons. |
| A platform is enabled before its native check passes | A broken package ships for that platform | A test fails when a platform is enabled without a recorded native check; the release workflow builds only enabled platforms. |
| An add-on process calls a host operation | The add-on reaches the job engine | A call needs a live request of that add-on, runs as that request's actor, passes the feature check, and is allowed only for add-ons the host setting names. |
| The pin seed pins for the wrong accounts | An account created after the upgrade gets pins it did not ask for | The seed runs once at upgrade, for the accounts that exist then, and the pin routes never seed. Tested for both cases. |
### Rollback
- Ship the new layout behind one UI flag, so the old sidebar can return without a rebuild. Remove the flag after one release.
- The old page components stay in the bundle during that release. The flag restores the old sidebar and the old Add-ons hub tab; it does not restore the old generator routes. The redirects of the moved generator pages (`/app/image` and its siblings) do not depend on the flag, because Studio draws the same pages and the redirects keep every old URL working. The old routes of Voice, Face, fine-tuning and quantization keep opening their core pages while the add-on is not installed. Rollback is a flag change for the navigation, and a code revert for the generator redirects.
- The pin table is additive. Rolling back leaves it unused. No down-migration is needed.
- If a redirect loops, remove only that redirect entry. The old page component is still present.
## Changelog
Changes of 2026-10-16, after the pre-flight review of the plan. One line each, with the review row it resolves ("scan C#"); a line that says "plan only" changed the plan and not this spec.
- Decisions 5, 6 and the Built-in add-ons section: fine-tuning and quantization are two packaged add-ons, moved in this change; the API and the job managers stay in core (scan C22, maintainer ruling).
- New section "Host operations for the engine": the four host operations, their rules, marked as new contract surface (scan C22, maintainer ruling).
- New decisions 10 to 12 and the section "Distribution": Voice, Face, fine-tuning and quantization are not shipped; platform list `linux/amd64`, `linux/arm64`, `darwin/arm64`, `darwin/amd64`, each with its runner; a platform is enabled only after its native check (scan C19, maintainer ruling).
- Question (d) and the Migration section: no hub tab for Voice or Face; discovery through Browse and the drawer; old routes open the core page until the add-on is installed (scan C18, maintainer ruling).
- New subsection "The one-time seed" and the Sidebar bullet: seed once at upgrade for existing accounts, new accounts start with no pins (scan C23, maintainer ruling).
- New subsection "Additions accepted on 2026-10-16" and the `HomeUndoToast` row: the viewer, the WAV helper, 26 icon names and the undo bar are accepted (scan C20, maintainer ruling).
- Section "API": the three routes are registered by `RegisterAddonNavigationRoutes`, so the existing route tests pass without edits (scan C1).
- Section "Disabled or removed while pinned": the start of the retention window is stored with the pin and survives a restart (scan C2).
- Section "Core routes from add-ons": the helper compares the denied prefixes on the decoded path and refuses an encoded separator in the first two segments (scan C16).
- Section "Redirects": the model of a moved route travels as `?model=`, and a moved page opens in its add-on only while the add-on is usable (scan C17).
- Section "Shared UI in the SDK": add-ons that use SDK 2 declare `min: 2, max: 2`; the tests that pin SDK 1 are updated with the SDK change (scan C14).
- Section "Rollback": the legacy switch restores the old sidebar and hub tab; the generator redirects do not depend on it (scan C13).
- Test plan and gate table: the tile of a packaged add-on shows for anyone with the `addons` feature and its page shows the off notice; tests added for the seed, the host operations, the platforms and the inventory (scan C19, C22, C15).
- Plan only: pin store keeps the owner and the missing mark in the file (scan C2); the two-frontends test moves to the service task (scan C3); `ClipInput` is built in the Voice library task (scan C8); the suite inventory is updated by Tasks 1, 4 and 9 (scan C15).
- Section "Extension points": the `app.nav` slot is drawn on the Add-ons drawer page below the tile grid, and in the Build hub row only under the legacy switch (Task 6 review, maintainer ruling).
- Section "Redirects" (Task 7 review, maintainer ruling): the redirect into Studio keeps the model as a path segment (`/app/studio/<tab>/<model>`), not `?model=`, because Studio is a core route. Revisit if Studio moves under the add-on host; cost if wrong: a redirect with a model needs a rewrite then.
- 2026-10-18: model registration through host operations (`finetune.export`, `quantize.import`) is administrator-only, on top of the feature (owner ruling). With an account database the role decides in every mode, so local-owner counts as administrator only when there is no account database; a feature-holding user who is not an administrator is refused with 403.
- 2026-10-18 (Task 24 review, maintainer ruling): `addons/internal/tools-ui/src/{tools.js,tools.css,format.js}` are byte copies of the core files (`core/http/react-ui/src/utils/tools.js`, `components/tools/tools.css`, and `formatBytes` of `utils/format.js`), an exception to one copy: the add-on builder refuses a bundle that imports a core UI file, so a re-export is not possible. A node test in tools-ui fails when a copy and its core file differ.