Files
LocalAI/docs/content/features/chats.md
T
mudler-agentandEttore Di Giacinto 4899d28e8a feat(chats): server-side chats with steering, sub-agents and compaction (#12918)
* Find the Sandbox guest image in the executable, not its build info

The Sandbox smoke checked that the packaged executable carries the
guest image by looking for -X main.guestImage in go version -m. The
package is built with -trimpath, and with -trimpath Go leaves -ldflags
out of the build info, so the check failed on every gvisor run,
although the image was linked in.

Look for the reference in the executable instead: the value of
SANDBOX_GUEST_IMAGE when it is set, any digest reference otherwise.
package.sh had the same check for an executable built elsewhere; it
now looks for its own reference the same way.

Assisted-by: Claude:claude-opus-5-5

* Answer a network card the limit refuses with one precise reason

A card that waited while the administrator's limit went down was
refused on the first click with 400, and every later click was a bare
409, so a stale card looked like a conflict loop. A refused card now
answers 400 egress_above_ceiling every time, the same answer sent twice
is one answer (204), and another answer to an answered card is 409
already_answered. The chat view marks a card the limit cannot admit
with above_ceiling.

When the limit is off, or below the chat's preset, the note of the turn
and the block line of the proxy now say that the server's setting
blocks the network and no approval changes it, so the model reports it
instead of waiting or retrying.

Assisted-by: Claude:claude-sonnet-5-5

* Explain a blocked Sandbox network instead of offering dead buttons

A card the limit cannot admit shows why network access is off or
limited, and who can change it, with a link to Settings > Tools >
Sandbox network for administrators. The buttons of a card disable while
an answer is sent, and a failed answer reads the card again. The
Network row says why its choices are capped. Settings opens a group
from ?group=. Strings are translated in all nine locales and the chat
and add-on docs describe the new behaviour.

Assisted-by: Claude:claude-sonnet-5-5

* Add an always-allowed hosts list to the Sandbox network

The extra registries setting only widens the package registries, so a
one-off host needs the approval card in every chat. The new admin
setting chats_egress_allowed_hosts lists exact hosts (optionally
host:port to pin a port) that a chat reaches whenever its effective
preset is registries or all.

The list sits in the same decision as the presets: the ceiling still
caps it, the ports setting still applies, and a private, loopback,
link-local or metadata address still needs the private switch and an
administrator's rule or answer. Each use is audited with the reason
allowed_host. Hosts are validated like the extra registries (lower
case, punycode, no scheme, path or pattern, at most 64) and an IP
literal is refused unless the private switch is on.

get_chat_egress and the chat egress view return the list; no MCP tool
writes it, as for the extra registries.

Assisted-by: Claude:claude-sonnet-5-5

* Let users delete from the Trash for good

A delete only set a tombstone, so the Trash grew without end and
nothing could free the space of a deleted file.

The workspace stores gain a Purger: it erases the deleted nodes of
some change sets, or of a whole folder, with their journal rows, the
change sets that nothing is left in, and the blobs that no row refers
to. Both stores implement it and pass the same contract specs. A blob
is removed after a grace period, and only if it is still unused then,
so an upload of the same bytes in flight keeps them.

DELETE /api/chats/files/trash empties the user's Trash and
DELETE /api/chats/files/trash/{rev} deletes one entry. Both are scoped
to the caller's home. The Trash listing now gives files, folders and
bytes per entry and for the whole Trash. The file tools of a chat
cannot reach any of this.

Assisted-by: Claude:claude-sonnet-5-5

* Add Empty Trash and Delete permanently to the Files page

The Trash tab shows what it holds, with an Empty Trash button that is
off while the Trash is empty and a Delete permanently button on each
item. Both ask in the page first and say how many files and folders and
how much space they remove. Texts are translated in all nine locales.

Assisted-by: Claude:claude-sonnet-5-5

* Fall back to Brave Search and Bing when DuckDuckGo asks for a captcha

DuckDuckGo answers some servers with HTTP 202 and a bot check (the
"anomaly" page) instead of results. The parser read that page as "no
results it could read", so web_search failed for every query.

The keyless search is now a chain: DuckDuckGo, then the result page of
Brave Search, then the result page of Bing. Each page has its own
tolerant parser (title, URL, snippet; Bing's /ck/a links and DDG's uddg
links are unwrapped) with saved fixtures.

A bot check is a typed error (ErrChallenged), and a 403 or 429 is
ErrBlocked. Both wrap ErrProvider. A Fallback with a cool-down skips a
challenged or blocked provider for a few minutes, so each search does
not wait for it first; when every provider is cooling down, all are
asked again. Fallback.SearchAttempts and TestEach report what each
provider did (ok, no_results, challenged, blocked, timeout, error,
skipped).

The error a model reads now lists what was tried and what each provider
did, then where an administrator fixes it.

safehttp keeps the status code of an answer (Response.StatusCode) and
of a refusal (StatusError, still ErrStatus for errors.Is).

Assisted-by: Claude:claude-opus-5-5

* Use the keyless search chain in the chats and test each provider

The built-in web_search now searches with DuckDuckGo, Brave Search and
Bing in turn when no SearXNG instance is set. A new runtime setting,
chats_web_search_providers, sets the order (searxng, duckduckgo,
brave_html, bing_html) and leaves a provider out. Empty keeps the old
choice: SearXNG alone when an instance is set, so its queries do not
reach the public engines unless the administrator lists them.

POST /api/chats/admin/web/test asks every provider of the order and
returns what each did in "providers" (ok, no_results, challenged,
blocked, timeout or error, with the reason), next to the first answer.
Settings > Tools has a Test search on the web_search row that shows it,
and the provider list is checked on the page and by the server.

The cool-down of a challenged provider is kept per frontend, like the
cache and the limits; web.md says why.

Docs: web.md (providers, chain, bot checks, privacy, how to fix it with
SearXNG or the Web add-on) and runtime-settings.md. Strings in the
nine locales. Swagger regenerated.

Assisted-by: Claude:claude-opus-5-5

* Try the mirror library alone in the reinstall recovery spec

The spec adds a second library with the same URL and expects an
install from it to keep the retained data apart. Since two enabled
libraries that list one add-on are a conflict, the reinstall from the
original library then failed with library_conflict, and the install
from the mirror failed for that reason instead of the trusted identity.

Switch the original library off while the mirror is tried, check that
the mirror is refused for its identity and not for a conflict, then
switch the mirror off and the original on for the reinstall.

Assisted-by: Claude:claude-opus-5-5

* Pause a chat turn at the step limit and offer Continue

A chat turn ran at most 10 rounds of tool calls, the default of the
harness, because nothing set a limit. A multi-step task (read some
files, run commands, write a result) stopped half way, and the person
saw an answer that looked final.

The limit is now the runtime setting chats_max_iterations (1 to 500,
default 40, Settings > Tools); a model's agent.max_iterations still
wins. When the tool loop reaches it, the harness sends a paused event
with the limit, and the turn ends with last_turn "paused". A paused
turn counts as finished: waiting messages start the next turn and a
sub-agent reports back. The status line says "Paused, after 40 steps"
and has a Continue button, which sends a message that asks the model
to go on.

A claim that found no waiting message released the chat as "done",
which hid a pause. It now keeps the status the chat had.

Assisted-by: Claude:claude-opus-5-5

* Edit the always-allowed Sandbox hosts in Settings > Tools

Add a list editor for chats_egress_allowed_hosts next to the extra
package registries: the hosts are tags with a remove button, an input
adds one on Enter, comma or Add (a pasted list adds them all), and the
reason an entry is refused shows under the input, with the count out
of 64. The same checks stop Apply on a bad entry, and the server still
has the last word.

The chat's Network panel lists the hosts read only and the row says
how many an administrator always allows while the chat's network is on.
The help text of both settings now says how they differ. Strings are
translated in all nine locales.

Assisted-by: Claude:claude-sonnet-5-5

* Document the always-allowed Sandbox hosts

Describe chats_egress_allowed_hosts in the runtime settings page, the
network section of the add-ons page (including the allowed_host audit
reason and how the list differs from extra registries) and the chats
page.

Assisted-by: Claude:claude-sonnet-5-5

* Let a chat edit, pause and delete the person's triggers

A chat could propose a trigger and list them, but not change one: the
Triggers page and the API could. list_triggers now gives each trigger a
stable ref (not its id) and shows the prompt of one trigger only when
asked. update_trigger and delete_trigger store a change record in the
trigger table: an edit or a delete waits for Apply or Delete on a card,
a pause or a resume applies at once with a receipt. An applied change
can be undone for a day; a delete restores the trigger with its id.

Change records are rows in the database, so every frontend shows and
answers the same card. The new routes under /api/chats/triggers/changes
serve the card and are gated by the triggers feature.

Assisted-by: Claude:claude-opus-5-5

* Show the trigger changes a chat asks for as cards

An edit lists each changed field before and after with Apply and
Dismiss, a delete shows the trigger and its last run with Delete and
Keep, and an applied change is a receipt with Undo. The cards sit below
the message text like the proposal card, and read their state from the
server so a reload shows the same answer.

Assisted-by: Claude:claude-opus-5-5

* Add a Test search button to the web tools settings

POST /api/chats/admin/web/test existed, but no page called it and the
docs showed only curl. The web_search row of Settings > Tools now has
a Test search button. It runs one search with the applied settings and
shows the provider with its first results, or the error a chat would
read.

Assisted-by: Claude:claude-opus-5-5

* Describe undo per tool call in the chats docs

The docs said the checkpoint line of a turn carries the change set of
the turn. A turn checkpoint is taken only by a chat with a VM sandbox,
which the service never sets up, so that rev is always 0. What ships
is one change set per file tool call, with a files_change card on its
tool line. The docs now say so and how to undo a whole turn.

Grouping a turn's changes into one change set in the file store is
left for later: it touches the file store's revisions and the undo of
shared folders.

Assisted-by: Claude:claude-opus-5-5

* Mount the skill folders read-only for the built-in bash

The built-in pdf skill ships scripts, but the commands saw only the
home and the shared folders, so the model could read the scripts and
not run them. The built-in bash now mounts the three skill layers
read-only, at the names the file tools already use: shared-skills,
shared-builtin-skills and shared-addon-skills. A shared folder named
skills moves to "shared-skills 2", so the two never collide.

A read-only mount with subfolders made every command report its
folders as changes the mount refused, because the host backend lists
every folder of its work tree. A folder alone is no change: both
stores now refuse only files, whiteouts and opaque folders there.

The Sandbox add-on mounts only the home; the docs say to copy a
script there first.

Assisted-by: Claude:claude-opus-5-5

* Find the add-on that runs commands by what it shadows

The chats knew the add-on that runs commands from a per-process cache,
filled with the Sandbox's own tool name the first time a chat listed
its tools. Another add-on that replaces bash did not count, so skills
that need commands showed as unavailable. The admin network view read
the same cache: after a restart it said no Sandbox serves until
someone chatted, each frontend of a cluster could answer differently,
and a removed add-on stayed listed.

The tool that runs commands is now the one that shadows bash, as the
spec asks. sandboxRunTool and the cache are gone. The stand-in of an
add-on that cannot work carries the built-in tools it replaces, and
the admin view asks the add-on host which add-on serves bash now: the
pinned one or the oldest running install, and none when the pinned one
is stopped.

Assisted-by: Claude:claude-opus-5-5

* Show international host names in punycode in the network checks

The card-spoofing defence of the sandbox network asks for host names
in their ASCII form, but CheckHost passed Unicode names through. A
look-alike name reached the consent card as text that reads like the
real site.

CheckHost now converts an international name with the IDNA lookup
profile and refuses one with no valid ASCII form. The conversion runs
before the address checks, so full-width digits that spell a loopback
address are refused as loopback.

Assisted-by: Claude:claude-opus-5-5

* Keep the seeded Studio and Talk pins until the add-ons are installed

The upgrade pins Studio and Talk for existing accounts, but both are
add-ons that an administrator installs later. The sweep marked a pin
whose add-on is not installed as missing and deleted it after 30
days, so an administrator who installed Studio late found the seeded
pins gone.

The sweep now records the first time a seeded add-on is present, in
the table of seed runs or the pins file. A seeded pin keeps no clock
until then, and a mark that an older sweep set is cleared. After the
first install the pin follows the retention rule as before.

Assisted-by: Claude:claude-opus-5-5

* Fix two small labels of the Skills and Backends pages

On the Skills page, the skill of an add-on that is not chosen said
"Replaced by <add-on>", the label of a built-in skill that an add-on
serves. It now says "Not active: <add-on> provides it", as the add-on
skills spec asks.

On the Backends page, Clear filters on an empty Installed search left
the library filter set, so the list stayed empty. It now clears the
library filter too.

Assisted-by: Claude:claude-opus-5-5

* Tell the model when no network card can appear

Each turn told the model that a blocked host asks the user. No card
appears when the ceiling is off or no wider than the chat's preset, in
an unattended run or in a sub-agent, so the model waited for an answer
that could not come.

The line now says that a blocked host is refused and nothing asks the
user in those cases, so the model names the host it needs in its
answer.

Assisted-by: Claude:claude-opus-5-5

* Translate six Backends and preview labels in every locale

check, reinstallBackend, deleteBackend and filterLabel of the Backends
page and the label and failed text of the file preview showed in
English in the eight other locales. They now have translations, and
the i18n baseline goes down to match.

Also fix a comment of the home composer that still named the removed
Canvas chip.

Assisted-by: Claude:claude-opus-5-5

* List the admin routes that have no assistant tool, with the reason

The admin oversight routes of saved agents, triggers and connectors
had no MCP tool and no recorded reason, and the other deliberate skips
lived only in code comments. coverage_test.go now keeps them in
adminRoutesWithoutTool with a reason each, and a spec checks that no
route there is also wrapped by a tool. The contributor checklist names
the map.

The tool catalog of the assistant docs said it matched the real one
but missed most tools added since. It now lists every tool, read-only
and mutating, and the actions that stay in the web interface.

Assisted-by: Claude:claude-opus-5-5

* Close the doc and registry gaps of the chats and add-ons

- /api/agent/* answers 404 with the chats_off reason when the chats
  are off, as /api/chats/* and /api/workspace/* do.
- The add-ons docs get an upgrade section: Studio and Talk are
  add-ons, nothing is preinstalled, and users need the addons feature,
  which the upgrade does not grant. It says how an administrator
  installs them (also from a private library) and grants the feature,
  and carries a paragraph for the release notes.
- The migration page no longer lists fork, edit, the chat settings and
  the token counters as removed.
- Runtime settings document chats_vision_model,
  chats_transcription_model and the skill keys of
  chats_tool_providers.
- The docs show POST /api/chats/files/folders,
  GET /api/chats/files/history and the allow route of a connector
  sender.
- The in-tree specs get amendments where the shipped design differs.
- A route test of the tagged suite still set the removed Runs field.

Assisted-by: Claude:claude-opus-5-5

* Look for the kit empty state in the backends regression spec

The Backends page now draws the shared EmptyState of the kit
(ui-empty), not the old dk-empty block, so the regression spec of the
gallery suite found no empty state after a search with no match.
Check the kit's no-results form and its title.

Assisted-by: Claude:claude-opus-5-5

* Ignore a features request that outlives its page in the add-on stubs

stubAddonRoutes answers /api/features by fetching the real flags and
turning add-ons on. A spec that ends while the page polls, like the
one that checks that a viewer without the feature never reads the
list, left that fetch in flight, and Playwright failed the spec with
"route.fetch: Target page, context or browser has been closed" after
its checks had passed. Abort the request when the fetch fails because
the page is gone.

Assisted-by: Claude:claude-opus-5-5

* Bring the gallery-ui case inventory up to date

The suite lists the node unit cases of addons.test.mjs and the
Playwright specs of addons-gallery.spec.js. The inventory missed the
unit cases added since it was written, and one spec was renamed when
the legacy switch came in ("hub: the Add-ons tab appears only for an
account that holds the feature" is now "hub: under the legacy switch
the Add-ons tab appears ..."). The list comes from a local run of
both parts against the UI test server.

Assisted-by: Claude:claude-opus-5-5

* Read spreadsheets, documents and slides in read_document

read_document said "file.xlsx has no text layer; it may be a scan" for
workbooks that some Python libraries write. Their workbook gives the
sheet as an absolute part name (/xl/worksheets/sheet1.xml) and the
cells as inline strings. The text extractor of the harness joined
"xl/" with that name, found no sheet, and returned no text; the tool
then took any empty result for a scanned PDF.

The new package core/services/studio/extract has one entry point,
Extract(ctx, name, data), that returns the text and the structure it
read, for read_document now and for a file indexer later. Office files
go through the files preview reader, which resolves both kinds of part
names and reads inline strings:

- a workbook comes sheet by sheet, rows as tab-separated lines, up to
  2000 rows and 50 columns a sheet, with a note for a cut sheet;
- a Word document gives headings, list items, paragraphs and tables;
- a presentation gives each slide with its title, text and notes.

PDF and EPUB keep the harness extractor. Only a PDF with no text is
reported as a probable scan; an empty Office file is reported as empty.
The preview reader now keeps a Word table as one block with its rows,
and the preview pane draws it as a table.

Assisted-by: Claude:claude-opus-5-5

* Add the missing explorer, library and tools translations

Eight locales had no explorer.json, library.json or tools.json. The
browser asks the server for every namespace of the language it shows,
so an Italian page that used the library answered 404 for
/locales/it/library.json on every load and fell back to English.

The 24 files now exist, translated, with every key of English. The
locale gate counted a missing file as missing keys, which the baseline
allowed. It now fails on a missing namespace file whatever the
baseline says, and the baseline is 24 entries shorter.

Assisted-by: Claude:claude-opus-5-5

* Back off while the add-on service is unavailable

A page that followed an add-on upgrade read the operation every two
seconds, up to 300 times. While the add-on service restarted, each read
waited for its timeout and answered 503, so the log of one session had
48 failed dispatches in a row, and the page only showed the upgrade as
running. The Sandbox state of a chat's Tools menu read once and, on a
503, said nothing more.

The reads of an operation now back off while the service does not
answer: 2, 4, 8, then up to 30 seconds. The upgrade entry says that it
waits for the add-on service and when it checks again, in Activity and
in the details, which also get a Check now button. The Tools menu says
that the Sandbox is starting and reads its state again with the same
back-off, kept for the page, so opening another chat does not ask
again before the wait is over.

Assisted-by: Claude:claude-opus-5-5

* Start a chat with its switches and list them before it exists

The composer of a new chat could not show the Tools and Skills menus,
because the lists and the switches belong to a chat. A chat started
from Home therefore always began with every tool and skill on.

- POST /api/chats takes switches: the add-ons, MCP servers and
  connectors, built-in groups and skills that are off, and the opt-in
  groups that are on. They are checked as the PUT endpoints check them
  and stored with the new record, so the chat never exists without
  them; a bad entry refuses the request and creates nothing. The SQL
  store now writes these lists on create too.
- GET /api/chats/draft/tools and /api/chats/draft/addons list what a
  chat on a model, with a saved agent, could use, every switch in its
  default state. They refuse what create refuses: an unknown or
  forbidden model, an agent without the agents feature, another
  user's agent.

Document both in the chats page and regenerate the swagger.

Assisted-by: Claude:claude-opus-5-5

* Give Home the composer of the chat page

Home drew a smaller message box than the chat page: no files, no
agent, no Tools or Skills menus, no paste to attach, no rich model
list, and its text was lost on a reload. A chat started there could
not be set up before its first message.

Move the composer wiring out of the chat page into one place that
both pages use:

- useChatComposer holds the draft (text and files), the model and the
  saved agent of a new chat, the chat models, the switches and what
  attaching and pasting do.
- ChatComposer draws the model list (fit, vision, memory bar), the
  agent picker, the Tools and Skills menus, the attach buttons,
  dictation, the add-on slot and the vision notice over HomeComposer.
- useChatSwitches has a draft mode: before the chat exists it lists
  from the draft endpoints, a switch changes only the screen, and the
  choices go with the create request. They outlive a change of model
  or agent.

Home now creates the chat with the model, the agent and the drafted
switches, sends the first message with its files and opens the chat.
When the message cannot go, the new chat is removed and the text and
files stay on Home. Its text is kept in the browser across a reload.
Home also gets the shortcuts row, the notices of the chat page and the
line that says whether the model is loaded. Its own slash actions,
widgets, examples and first-run steps stay.

The empty chat page gets the same draft menus. On a phone the chips
of both wrap to a second row, and the Tools and agent menus open as a
sheet from the bottom, as the skills picker does.

Assisted-by: Claude:claude-opus-5-5

* Move add-on settings to the add-on's own page

The settings.sections slot drew every add-on's settings at the end of the
global Settings page, and the Sandbox and Web sections were unstyled: the
add-on UIs used class names the host does not define (btn, btn-primary)
and bare inputs, the SDK offered no form primitives, its muted token
named a property that does not exist, and the slot root was an
inline-flex span that laid a form out in a row.

An add-on's settings sections now render on the Settings tab of its
details on the Add-ons page (/app/addons?detail=<id>&view=settings),
narrowed to that add-on, for administrators only. An add-on page gets a
host Settings link to that tab. The Settings page no longer draws the
slot: an Add-ons group, shown when an add-on has settings, lists those
add-ons with a link each. The old #ad-section-<addon>-<id> anchor
redirects to the tab once the apps list is read. Server settings of an
add-on's feature stay on the Settings page; the tab shows them under
"Server settings for this add-on", with a read-only summary of the
Sandbox network and links to ?group=tools#settings-egress and
#settings-builtin-tools.

The SDK gets a settings kit in sdk.ui (Section, Field, Select,
TextInput, Switch, Choice, StatusChip, Callout, List/ListRow, Code,
Actions, Table, ExternalLink), drawn with host classes and tokens, and
more theme tokens. The Sandbox and Web sections are rebuilt with it:
backend rows with a status chip and the raw reason collapsed in a code
block, a backend choice with one line per option, and the AppArmor and
user-namespace hint (with a docs link) only for the gVisor
"exit status 128 ... cannot read client sync file" failure.

The Settings page showed "Changed, default 30, Reset" next to a 0 for the
audit retention, and the same for the Sandbox network limit, preset and
ports: the server stores 0 or an empty value for a setting never set.
Those fields now read that value as their default, and the retention
input has its unit and bounds.

Assisted-by: Claude:claude-opus-5-5

* Adapt the add-on runtime specs to the new navigation

Three specs of the esm-ui suite still assumed the old shell:

- the Add-ons tab that lists the pages add-ons declare is part of the
  legacy layout, so the navigation spec found no hub subnav; it now
  sets the legacy switch on the server's flags for that page;
- the sidebar has no "Chat" link any more, so the unsaved-edits spec
  could not leave the page; it leaves through Home in the primary
  navigation;
- the shell keeps a notification stream open, so the network never
  goes idle and the spec for a member without the feature waited out
  its timeout; it waits for the server's switches, the account and
  the shell instead.

Assisted-by: Claude:claude-opus-5-5

* Name the legacy Add-ons tab unit case as the esm unit tests do

The esm-ui inventory listed "unit: registry: the Add-ons tab lists
page links only when the registry has usable add-ons", but the unit
test is named "the legacy Add-ons tab lists ...", so the suite could
never match its inventory. Use the name the test prints.

Assisted-by: Claude:claude-opus-5-5

* Say on the Collections page when the embedding model is missing

At startup the server opened a collection and its first embedding call
got 404 from POST /embeddings. The collections embed through this
server's own API with collections_embedding_model, the default
granite-embedding-107m-multilingual, and that model was not installed.
Nothing told the user: uploads and searches just failed.

GET /api/collections now also returns embedding_model and
embedding_model_installed (a loaded configuration of that name, or a
model file of that name). When it is missing, the Collections page
says "No embedding model installed", names the model, and gives an
administrator a button that opens the Models page with a search for
it.

Assisted-by: Claude:claude-opus-5-5

* Report modified times and the upload limit in folder listings

The Files page could not sort by date or show when a file changed: a
folder listing had no time. It also learned the upload limit only after
it had sent the whole file. The default server body limit is 15 MB,
lower than the 256 MiB a workspace file may have, so a large upload
failed late and without a clear reason.

Each listed entry now has "modified", the time of the change set of
its revision. Both workspace stores fill it in List: the JSON store
from its in-memory change sets, the database store with one query per
batch of revisions. The listing also has "max_file_size", the smaller
of the workspace limit and the server body limit, so a client can
refuse a larger file before it sends any byte.

The store contract suite checks the new time against the history, and
the route tests check both fields.

Assisted-by: Claude:claude-opus-5-5

* Add a sortable file list with thumbnails for the Files page

The building blocks of the new file browser, used by the page in a
later change:

- fileBrowser.js: the kind of an entry from its extension, the order
  (folders first, natural and case-insensitive names, then size,
  modified time or type), the filter of a folder, dates in words with
  the full date for a tooltip, the Shift range, the arrow moves of a
  list and of a grid, type-to-find, names that do not clash, why a
  name cannot be used, the name of a pasted screenshot and the view
  settings read back from storage.
- FileList: a grid with sortable column headers or a grid of tiles
  with image thumbnails, a check box per row, a row menu, inline
  rename with the reason a name is refused, rows that can be dragged
  onto folders, and the keyboard: arrows, Shift, Space, Enter, F2,
  Delete, Ctrl+A, Escape, Backspace and typing a name.
- MoveDialog: a folder picker that walks the tree and refuses a move
  into the moving folder itself.
- ActionMenu can open at a point with no trigger (a right click), and
  its trigger can take another icon and class (a split button).
- fileSize also writes gigabytes.

Assisted-by: Claude:claude-opus-5-5

* Add an upload queue with progress, cancel and retry

An upload of the Files page was one fetch per file in a loop: nothing
showed while it ran, one failure stopped the rest, and leaving the
page lost it.

- uploadQueue.js keeps every upload outside React: queued, sending
  with the bytes sent and a smoothed rate, done, failed with a reason
  and whether Try again can help, or canceled. Two files go at once.
  It lives for the whole app, so opening another folder or page does
  not stop an upload.
- filesApi.uploadWithProgress sends the same PUT with XMLHttpRequest,
  which reports upload progress and can be aborted. The server already
  streams the body into the store; nothing changes there.
- useUploads reads the queue and asks before the tab closes while a
  file is on its way.
- UploadTray shows the queue at the bottom of the page: the total and
  the time left, each file with its progress, speed and time left,
  Cancel, Try again with the reason, and a done state that fades out.
  A live region tells a screen reader when uploads start, fail and
  end.
- ConflictDialog asks what to do when an upload has the name of a file
  in the folder: replace it, keep both, or skip it.
- dropFiles.js reads dropped folders with their paths.

Assisted-by: Claude:claude-opus-5-5

* Rebuild the Files page as a file browser

The Files page was a list of links: the store's byte order, the same
icon for every file, three icon buttons on every row, no progress on
an upload, no selection, and on a phone a preview that opened below
the whole list.

Browsing: a breadcrumb that folds a deep path, sortable columns (name,
modified, type, size) with folders first, a list or a grid of
thumbnails, both kept per user in the browser, a filter of the open
folder with a search of every file on Enter, type icons, human sizes,
dates in words with the full date on hover, and Back and Forward
between folders.

Uploads go through the upload queue and its tray: the Upload button
or its folder menu, drops of files and folders anywhere on the page or
onto a folder row or crumb, and paste. A clash asks to replace, keep
both or skip; a file over the server limit is refused before it is
sent.

Selection and actions: click, Ctrl and Shift, check boxes, a selection
bar (Download, Move to, Move to Trash; at the bottom of the screen on
a phone), a row menu and a right click, inline rename and new
folder or file, drag to move, the keyboard, and a toast with Undo for
a delete, a move and a rename.

States: a skeleton on the first load, an error with Try again, empty
states that say how files get here, and a no-match state. The preview
uses the side pane: docked on a wide screen, a sheet on a phone, with
the same actions as the row menu. Trash, History and the Managed by
LocalAI group stay as they were.

Every new string is in the nine languages. The page uses design
tokens only (seven raw font sizes fewer). The specs cover sorting,
the filter, the views, selection and bulk actions, the keyboard, the
menu, rename checks, drag to move, drops, paste, clashes, the size
limit, upload progress, cancel and retry against the real server, and
the error states.

Assisted-by: Claude:claude-opus-5-5

* Fix two specs that fail on the integration branch

The Describe spec of the egress package checked that the no-card line
does not contain "asks the user", but that line says "nothing asks the
user". It now checks for the sentence that promises a card.

The context meter spec gave the chat a 4k context window. The tool
schemas of the chat now take 3k of it, so the turn stopped before the
model request. The spec uses a 16k window.

Assisted-by: Claude:claude-opus-5-5

* Translate the always-allowed hosts note of the chat network row

The three egress keys of the always-allowed hosts were only in English,
so the locale check failed for every chats namespace. The workspace
namespaces now miss two keys fewer, so their baseline goes down too.

Assisted-by: Claude:claude-opus-5-5

* Bring the contracts and host-slots case inventories up to date

The contracts suite has eight new specs for the check of a tool that
shadows a built-in. The host-slots suite runs the unit case of the file
preview slot, which the inventory did not list.

Assisted-by: Claude:claude-opus-5-5

* Add the design of the file text index

The search of the chats' files reads only small UTF-8 text files, so a
PDF or an Office file is found by name only. The design extracts the
text when a file is written, keeps it in LocalAI's own store keyed by
node and hash, checks access at query time and runs one indexer per
cluster under an advisory lock.

Assisted-by: Claude:claude-opus-5-5

* Give extracted documents their pages, sheets and slides

Extract now says where each page, sheet and slide starts in the text,
so a search hit can name its page or sheet. A PDF page starts with a
"## Page N" line, as sheets and slides already start with a heading.
A PDF also stops at a page limit (200 by default) and at the text
limit, which it ignored before, and a panic of the reader is an error.

The test fixtures move to extracttest, with a real text PDF and a
workbook with shared strings, so other packages can build the same
documents.

Assisted-by: Claude:claude-opus-5-5

* Add a store for the text of workspace documents

textindex keeps one entry per workspace node: the text read from a
PDF or Office file, the hash it was read from, its pages or sheets and
its status (pending, ready, failed, skipped), plus the cursor of the
indexer in the workspace journal.

The GORM store keeps a lower-cased copy of the text, so SQL and the
Go matcher agree on every script, and searches it with FTS5 and the
trigram tokenizer on SQLite or a pg_trgm index on PostgreSQL. Without
them it scans the column. The JSON store keeps the metadata in memory
and each text in its own file, and scans the texts. One contract
suite runs on JSON, SQLite and PostgreSQL.

Assisted-by: Claude:claude-opus-5-5

* Index the text of new and changed workspace documents

The indexer tails the workspace journal from a cursor kept in the text
store. A write of a PDF, Word, Excel or PowerPoint file marks it
pending (the same hash only follows the path), a delete removes the
entry and a move rewrites the paths below a folder. Then it reads the
pending files two at a time, with a timeout, a size cap, a page cap and
a text cap, and stores ready, failed or skipped. A panic of the reader
fails the file, not the pass. The same content is read once.

The first pass starts at revision 0, which indexes the files that were
there before, in bounded steps that resume from the cursor. Enqueue
marks an upload pending at once and Forget drops the entries of nodes a
purge erased. In a cluster each pass runs under a new advisory lock,
so one frontend reads.

Assisted-by: Claude:claude-opus-5-5

* Search and read the text of documents in the chats' files

search_files and GET /api/chats/files/search now also search the text
index: after the name and text-file hits come lines of PDF, Word, Excel
and PowerPoint files, as path (page 3, line 40): text, with the sheet or
slide where a document has them. Each entry is shown only when the
caller may read its file now and the file still has the content the
text came from. Documents keep up to a third of the hits. When files
below the path are still pending, the answer says so instead of
"Nothing found.", and the REST result has indexing.

read_file returns the text of a PDF or Office file with the usual offset
and limit, from the index when it matches the file and read on the
spot otherwise. An upload and a write of a chat tool queue the file at
once, and emptying the Trash drops the entries of the purged files.

Assisted-by: Claude:claude-opus-5-5

* Start the file text index with the files and add its settings

With files on, the server opens the text store next to the workspace
index (the auth database, or a folder under the data path) and starts
the indexer. A chat worker searches the index but runs no indexer.

chats_index_files (default on) switches the reading off and
chats_index_max_text_bytes (2 MiB by default, 64 KiB to 16 MiB) caps
the text kept for one document. Both apply at the next pass, and both
are rows of Settings > Tools.

Assisted-by: Claude:claude-opus-5-5

* Show the page, sheet or slide of a document hit on the Files page

Search all files now lists hits inside PDF and Office files with their
page, sheet or slide instead of a line number of the extracted text,
and says how many documents are still being indexed. The strings are
in all nine locales.

Assisted-by: Claude:claude-opus-5-5

* Test finding an uploaded PDF by its words through the routes

The PDF has a generated name and is uploaded with the REST upload. The
search route first reports it as being indexed, then finds it on its
page after a pass of the indexer, and so does the search_files tool of
a chat. Another user finds nothing.

Assisted-by: Claude:claude-opus-5-5

* Document the search inside documents

The chats page says what the index reads, where the text is kept, who
finds it, which search engine each database uses, the two settings,
the backfill and the single-runner mode in a cluster, and how
search_files and read_file answer for documents. The API instructions
and the PDF skill say the same in short.

Assisted-by: Claude:claude-opus-5-5

* Test the indexer on the JSON workspace store

A server without a database keeps the workspace and the text in JSON
files. The indexer follows the uploads, a folder move and a delete in
that journal as it does in the database one.

Assisted-by: Claude:claude-opus-5-5

* Read all of a long document with read_file

The index keeps the first chats_index_max_text_bytes of a document, so
read_file used to stop where the index stopped. It now uses the index
only when it holds the whole text, and reads the document again (up to
16 MiB of text) when the index cut it.

Assisted-by: Claude:claude-opus-5-5

* Start bash in the user's home

The Sandbox's bash and the built-in bash started in /workspace, the
folder that holds the mounts, so a model that ran a script by a
relative path failed and a file it saved there was lost. A call now
starts in /workspace/home, the folder of the user's files: a relative
path is the same file the file tools see. The tool descriptions and
the commands block of every turn say where a call starts and that
only what is saved there is kept. The built-in bash says that the
/workspace paths do not exist on the host and that a shared folder is
next to the home.

Assisted-by: Claude:claude-opus-5-5

* Name files in tool results by the path the model uses

write_file, edit_file and search_files named a file of the home by its
path in the home ("/scripts/build.py") while the model had written
/workspace/home/scripts/build.py, and a model took the two spellings
for two files. The results now give the path the model uses, and
move_file says where it moved the file to. The cards and the change
notes keep the path in the home.

Assisted-by: Claude:claude-opus-5-5

* Name the paths a command's commit did not save

The result of a bash call ended with "N file(s) changed in your files
meanwhile; the sandbox's version was kept as a copy" for every path the
commit refused, also for a file written outside the user's files,
where no copy is kept. It now has one line a path, with the path as
the guest sees it, the reason, and the path of the copy when the store
kept one, so the model and the user can act on it. The host returns
the paths and the copy to the Sandbox add-on relative to the user's
files, as its mounts are.

Assisted-by: Claude:claude-opus-5-5

* Show the files a command saved under its step

A document that a script wrote with bash reached the user's files, but
the chat showed nothing: only the file tools put a file on their step,
and the Sandbox committed for no chat, so the chat's file list, the
chips under the answer and the preview pane never saw the file.

The host now commits a Sandbox call for the chat that ran it (the
action resource of the request names the chat). After each bash call,
of the Sandbox or of this machine, the chat reads the change sets of
the chat that the call made from the shared store and puts the files
on the step's card, next to the card that says who served the call
(session.SetToolFiles; files with path, change set and op on the
card's new files field). The step lists them as chips that open the
preview pane, the chat's clients are told that its files changed, and
the files reach the file list, so the answer gets its chips and the
pane opens on the newest one, as for write_file.

Assisted-by: Claude:claude-opus-5-5

* Keep JSON out of the answer of a trigger run

The workspace skill told the model to put the widget data shape and a
source marker in a trigger prompt. Some proposals then asked the run to
print that JSON, and every run ended its chat with a stray line of it.

The skill now says source and note live inside the data file and are
never the answer, and that a prompt must not ask for JSON. The runner
appends one closing instruction to the first message of every trigger
run: finish with one short plain sentence. The propose_trigger and
update_trigger descriptions say the prompt must stand alone and must
not ask for JSON. Connector chats are talks with people and do not get
the line.

Assisted-by: Claude:claude-sonnet-5-5

* Read the images of a page as base64 for its frame

The bridge of a page reads UTF-8 text only, so the UI could not put a
png or a jpg of the page's folder into the page. A read of the page
file routes with encoding=base64 now returns an image as base64 with
its media type. Only the extensions of an image are read this way
(png, jpg, gif, webp, avif, bmp, ico, svg); any other file is 415, so
this is no way to read an arbitrary binary file. The path checks, the
1 MiB limit, the viewer's rights and, for a shared page, the consent
check are the same as for a text read.

Assisted-by: Claude:claude-opus-5-5

* Let a page use the files next to it

A page runs from a srcdoc with no network, so fetch('news.json'),
<script src="app.js">, <link rel="stylesheet" href="style.css">,
<img src="logo.png"> and import './x.js' all failed without a word.
Models that split a page into files, or load its data with fetch,
made pages that showed nothing.

Before the frame loads, the UI now reads the files that the page names
with relative URLs, with the same calls as the bridge, and puts them in:
classic scripts in place (deferred ones at the end of the body), style
sheets as <style> with their @import and url(), images as data: URLs,
and ES modules as blob: URLs behind an import map, so cycles and
import() work. A page with modules gets blob: in script-src; no other
part of the policy changes. Inside the frame, fetch and XMLHttpRequest
read relative URLs with files.read and answer with a real Response (200,
404 for a missing file, 413, 415); any other URL rejects with "network
is off in pages; put the data in a file next to the page".

Every path is resolved inside the page's folder: .., %2e%2e, absolute
paths, backslashes and protocol-relative URLs are refused, and the
query and the fragment are dropped. The files go in only after a shared
page is allowed, with the viewer's rights. The files that go in are at
most 4 MiB, images 2 MiB; a script or style sheet that does not load
shows a notice in the page. A change of one of these files reloads the
page, as a change of index.html does.

The skills, the docs and the shared pages fixture say and test the same.

Assisted-by: Claude:claude-opus-5-5

* Return tool descriptions from an MCP connector test

A connection test listed only the tool names. A person who adds a
server cannot tell from a name such as "query" what the tool does or
whether to keep the connector on.

The test response now also has tool_details: each tool with the
description its server gives, in the order of tools. tools is
unchanged, so existing clients keep working.

Assisted-by: Claude:claude-opus-5-5

* Add jq, ripgrep, sqlite3 and archive tools to the Sandbox guest

The guest user cannot install packages and apt is blocked by the proxy,
so any tool the model needs has to be in the image. Add jq, ripgrep,
sqlite3, zip, unzip, file and tree to the final stage.

A new check-tools.sh runs in the existing build step and fails the build
when one of these programs, Poppler, git or curl is missing or does not
start.

The list is one constant, GuestTools, in the shared sandbox package. The
Commands block of the prompt of an isolated chat names it. The tool
description (capped at 400 characters, already full) and the turn note
(cut at 400 characters) have no room for it. Tests keep the Dockerfile,
the check script and the Sandbox documentation in line with the constant.

Assisted-by: Claude:claude-sonnet-5-5

* Keep the helper files of a chat in its work folder

A chat asked for a Word document left a 19 KB build script at the top of
the user's home, next to the user's own files. Chats like it leave
scripts, intermediate data, logs and drafts at the top level or in
folders such as scripts/, and nothing tells them apart from what the
user asked for.

Each chat now has a work folder, Chats/<chat folder>/work/ in the
user's home, next to its attachments. The chat takes the name of its
folder at its first turn (checked against the folders and the other
recent chats of the user, so two chats with one title get two names)
and makes the folder only when it is used: before a bash command runs,
or when a file is saved there. The prompt of every turn names the
folder and says what goes in it: scripts, helper code, intermediate
data, logs and drafts go there; the deliverable goes where the user
said or at the top of the home; no new top-level folders for scripts;
a run-once command needs no file. A sub-agent is told its parent's
folder. The descriptions of bash (the Sandbox's, within its 400
characters, and the built-in one) and of write_file end with one short
hint, and the built-in office, apps and workspace skills say the same
in one line.

The steering is soft. When write_file, move_file or a command leaves a
helper file outside the work folder, the result gets one line that
suggests moving it, at most once a turn of a chat.

One function (workspace/workfiles) decides the kind of a file:
working for anything in a chat's work folder, or for a script, a log
or a temporary file by its extension or a folder named scripts, tmp or
scratch; deliverable for everything else, and always for pages, the
LocalAI folder, the shared folders and the files the user attached.
The chat's file list (kind), the files of a command's card
(files[].kind) and the card of a file tool (file_kind) carry it, so the
interface only renders it.

The runtime setting chats_work_folder (on by default) turns the
steering off.

Assisted-by: Claude:claude-opus-5-5

* Stop telling the model that the Sandbox has npm

The guest image has no Node, so the network hints named a tool that
does not exist.

Assisted-by: Claude:claude-sonnet-5-5

* Update the Sandbox description test after dropping npm

Assisted-by: Claude:claude-sonnet-5-5

* Open the links of a page after the person confirms them

A page runs in a frame without allow-popups or top navigation, so a
click on <a href="https://..."> did nothing, and a link to #id loaded
LocalAI's own address in the frame, since a srcdoc resolves it against
the parent. A news page had only dead links.

The bridge of the frame now takes the clicks on links (also with a
modifier, the middle button or Enter) and window.open(), and sends
link.open(url) to the host. The host lets through only an http or https
address without a user name or password, at most 2048 characters;
javascript:, data:, file:, blob:, //host and links to other files of
the page are refused with a message in the console. A bar above the
page shows the site in large type, the whole address, and Open and
Cancel. A host in punycode also says how it reads, since it may imitate
another site. Open is a click in the app, so the new tab is not blocked
and opens with noopener and noreferrer. A page asks at most 5 links at
once, then one every 3 seconds. A link to #id scrolls the page.

The sandbox, the policy of the frame and the consent of shared pages do
not change; a shared page asks the same way.

Assisted-by: Claude:claude-opus-5-5

* Let the items of a Home widget link

A list widget showed each item as plain text, so a digest of articles
or a list of pages could not take the person anywhere. An item now has
an optional url. An http or https address without a user name opens in
a new tab with noopener and noreferrer, and its tooltip names the site;
a path of the app (/app/pages, /app/chats, /app/files and the other
screens a person reads) opens inside LocalAI. The label is the link,
with a focus ring, an underline on hover and focus, and an icon for an
address outside the app.

The widget-data schema checks the url, and the server adds an error in
words that names the item and the rule, so a trigger run that writes a
bad link learns why. The skill and the docs show the field.

Assisted-by: Claude:claude-opus-5-5

* Attach any file in a chat with one button

The chat had three attach buttons (image, audio, file) that all add to
the same list, and the file button's dialog filtered on */*, which
some file dialogs read as a type filter that greys out archives such as
.zip. A chat now shows one Attach file button whose dialog has no type
filter. Composers without anyFile keep the three buttons.

Assisted-by: Claude:claude-opus-5-5

* Accept both Node behaviours for a broken punycode label in the link test

Node 22 refuses the address, Node 24 parses it. Both mean the page never
opens it unwarned.

Assisted-by: Claude:claude-sonnet-5-5

* Show a chat's working files apart from what it made

The Files pane, the steps and the preview treated a helper script like
the document the user asked for: a 19 KB build script was listed next
to the document, got a chip under the answer, and the preview opened on
whichever file was newest.

The interface now renders the kind the server puts on each file. The
pane lists what the chat made first and folds the working files into
one quiet heading under the rest, "Working files · 2", with a line that
says what they are; opening it shows their rows, which preview like any
file. A working file opened from a step unfolds the group. The preview
that opens by itself after a turn picks the newest file that is not a
working file, and the chips under the answer name only those files. A
step that saved working files lists them as one line, "Also saved 2
working files", that opens to their chips, and the file of a write_file
step that is a working file is a quieter chip. The files the user
attached are unchanged.

Assisted-by: Claude:claude-opus-5-5

* Add a search field and a segmented filter to the UI kit

The Agents views each drew their own toolbar: a search box capped at
320 px on the agents list, round chip filters on the triggers history,
and a bare checkbox on the tasks page. Add-ons already had the look the
product wants (a search that fills the row and a segmented tray), but
only as add-on classes.

SearchField and Segmented are that toolbar as two small primitives with
ui-* classes and token values only. Segmented buttons carry their id in
data-filter, so specs can press them. The README shows how to use them.

Assisted-by: Claude:claude-opus-5-5

* Draw an agent task run as a history row with its output

The jobs of an agent task were table rows that opened to bare <pre>
blocks: no copy, parameters printed as k=v, the traces in a raw
<details>, and "by manual" in the server's words.

RunItem is one run of a history list: a toggle, the state chip, what it
was and what happened, when and for how long, small actions, and the
details that open under it. OutputBlock draws an answer or an error in
mono with a Copy button. JobRow and JobDetail use them for a job: the
values it ran with as labelled chips, the answer or error, the steps,
the webhook outcome and a link to the chat that holds the run. Times
show as words with the full date as a tooltip.

The agent task helpers gain the pure rules the page needs: the jobs of
a task, the run filters (waiting and running count as in progress), the
on/off filter and search of the task list, the field errors of a save,
how a job started, and the API call that runs a task by name.

Assisted-by: Claude:claude-opus-5-5

* Create, edit and run agent tasks in a side sheet

The task form opened as bare fields above the list, with a checkbox for
On and a Save button that was only disabled when a field was missing.
Run with parameters was a second inline form in the same place.

TaskSheet is a side sheet with three labelled groups (about, who runs
it, what it does), help under each field, the parameters the prompt
asks for as chips while you type, and an On switch. Save checks the
required fields, says next to each one what is missing, and moves the
focus to the first. RunSheet asks for each value the prompt names and
shows the prompt as the task will receive it.

Assisted-by: Claude:claude-opus-5-5

* Show the details of an agent task in a pane with tabs

A task had no details view: its prompt, its runs and the way to run it
over the API were only in the lede of the page.

TaskPane is the kit's side pane with three tabs. Overview shows who
runs the task, the values it asks for, the prompt and a curl call that
runs it by name, with Copy. Runs lists its jobs. Settings holds the On
switch, the webhook count and Delete. Run and Edit stay in the footer.
The tabs move with the arrow keys, Home and End.

Assisted-by: Claude:claude-opus-5-5

* Rebuild the agent tasks page from the shared list pieces

The tasks page looked unstyled next to Add-ons and the agents list: two
raw tables under uppercase eyebrows, a bare checkbox for All users,
forms that opened above the list, three text buttons on every row, and
on a phone a table that broke names one syllable per line.

The page now uses the same pieces as the polished pages. The header has
one line and New task. A toolbar holds the search, an All/On/Off filter
and, for an administrator, an All users switch. Each task is an agent
row: a tile, the name, two lines of description, who runs it and what
it asks for, the last run's state and time with the run strip, an On
switch, Run and a menu. A task with no parameters runs at once. The
name opens the details pane; New task, Edit and Run open the sheets.
The run history is grouped by day, with a state filter, a task filter
and Show more. A job is deleted after an in-page question. With no
tasks the page explains what one is, with an example; a failed load
shows the error with Try again. On a phone the controls grow to 44 px
and the filters scroll sideways.

New strings, and the task strings that existed only in English, are in
all nine locales; the strings the page no longer uses are gone. A new
stubbed spec covers the list, filters, sheets, pane and history, and the
real-server spec follows the new controls. The docs describe the page.

Assisted-by: Claude:claude-opus-5-5

* Rebuild the MCP connectors page on the Add-ons patterns

The page read as a form: every connector was a row of raw controls (a
checkbox, a Test button, red Delete text), the add form pushed the list
down, a test result vanished on reload, and the frame was narrower than
the pages next to it in the sidebar.

The page now uses the same blocks as Add-ons and Files:

- Full page width and the shared PageHeader with a one-sentence purpose
  and "Add connector".
- The Add-ons toolbar (search and a state filter with counts) from four
  connectors, and the Add-ons rows: a monogram tile, a state chip
  (Connected, Needs sign-in, Can't connect, Not tested, Off), one line
  with the tools or the reason, a meta line, the kit Switch and a menu.
- Details in the shared SidePane: the switch, the last test, the tools
  with their descriptions and a search, the connection with the query of
  the address hidden, and what is saved.
- Add and edit in the shared SideSheet: kind first, grouped fields with
  inline checks, masked secrets with show and hide, then "Save and
  test", the result with the tools, and Turn on. Escape and Cancel ask
  before they drop typed changes.
- An empty state that says what a connector is for.

The last test of each connector is kept in this browser with a hash of
what it tested, never the address or a secret, so the page shows the
last known state after a reload. Strings are in all nine locales.

Assisted-by: Claude:claude-opus-5-5

* Document the MCP connectors page and its states

The MCP servers section now describes the add sheet (kind, checks,
masked secrets, Save and test, Turn on), the states a connector shows,
where the last test is kept, the search and the filter, and the details
pane. The test example shows tool_details.

Assisted-by: Claude:claude-opus-5-5

* Render a preloaded page without its loading placeholder

A page that was preloaded still showed the route's loading placeholder
when it opened: React.lazy suspends once on its first render even when
the chunk is already in memory, and React keeps a fallback on screen
for at least 300 ms once it shows one. Opening a chat from Home showed
a blank page for that time.

The page loader now remembers the module once it has loaded and hands
it to React.lazy as a thenable that resolves at once, so a preloaded
page renders in the same commit. A preload of a segment now warms
every page registered under it: /app/chats is the chat page, its
connectors and its conversion page, and before only the last one
registered was preloaded.

Assisted-by: Claude:claude-opus-5-5

* Move the composer from Home into the new chat in one gesture

Sending the first message on Home was a hard cut. The page stood still
while the chat was created, then showed a blank placeholder, the empty
"new chat" screen with its composer in the middle, an empty thread
with the composer at the bottom, and only then the message, while the
sidebar folded and moved the whole column. Focus was lost.

Now Send fades the rest of Home and keeps the composer in place while
the message goes. When the send has answered, a view transition moves
the composer to the dock of the chat, lifts the text out of it into
the message, moves the attached files with it and folds the sidebar in
place; the model's indicator comes in after the message has landed and
the empty composer has the focus. The chat page draws the message from
what Home already knows (a small hand-off store: the chat record, the
text and the files, with the local copy of an image for its thumbnail)
and keeps the same row when the server's transcript replaces it, so
nothing is drawn twice or animates again. What is sent to the server
does not change.

A browser without view transitions gets a FLIP slide of the composer
and the message (Web Animations, transform and opacity only). Less
motion gets a short cross-fade, or an instant switch. A send that fails
brings Home back, shakes the composer and keeps the draft; a second
Enter while one send is in flight sends nothing. The draft is cleared
when Home closes, so the box never shows its placeholder on the way.

Focus mode folds the sidebar in a layout effect, so the fold happens in
the frame the conversation appears. One motion token is added,
--dur-glide (360 ms), for a part that travels to its place on the next
page.

Assisted-by: Claude:claude-opus-5-5

* Draw the triggers as rows and their history like the task runs

The triggers page used a table with a switch column next to the agents
card list, folded its list behind Hide, filtered the history with round
chips, and on a phone cut each history row at the right edge.

Each trigger is now an agent row: a clock or folder tile, the name, the
schedule in the server's words, the agent or model and when it runs
next, the last run's state and time with the run strip, an On switch,
Run now and a menu. The week sentence sits under the Run history title.
The history uses the segmented filter and the same run rows and day
headings as the agent tasks, with Show more after 30 runs; an open run
shows its reason with Copy. On a trigger page the prompt comes before
the runs, in the body face, the On switch is the kit switch, and the
runs stop at 10 with Show more.

Assisted-by: Claude:claude-opus-5-5

* Use the shared search and the full width on the agents list

The agents list stopped its search at 320 px and used the content width,
while the Triggers and Tasks views next to it use the full width, so the
title and the view tabs moved when you switched views.

The list now uses SearchField, which fills the toolbar row as on
Add-ons, and the full page width like its sibling views.

Assisted-by: Claude:claude-opus-5-5

* Polish the agent task sheets and pane after a visual review

Screenshots of every state showed a few rough edges:

- The name placeholder looked like a filled-in value next to the error
  "Give the task a name". The name field has no placeholder now, and
  the prompt placeholder starts with "For example:".
- A task run by an agent showed the agent id until the agents were
  read. It says "Agent" until then.
- A run opened in the details pane also opened in the history, and the
  two copies shared an element id. The pane keeps its own open runs and
  ids.
- A failed run in the pane drew its meta line in red; the title carries
  the error colour there now.
- A group title that is not a legend had a double gap under it, and the
  kebab menus were under 44 px on a phone.

Assisted-by: Claude:claude-opus-5-5

* Cover the code files of a shared page in its consent

The consent to a shared page was kept against the hash of index.html
and page.json. The UI also puts the scripts, style sheets and modules
next to the page into it before it runs, so the author could change
app.js after a viewer allowed the page and run new code without asking
again.

The server now finds the same files as the UI's inliner: every script
src, stylesheet link, relative CSS @import and module import of the
folder, followed through the module graph. Their content hashes go into
the hash of the page, and a file the page names that is missing counts
too, so adding it later also asks again. Data files and images stay
out, so saving data does not ask again. The files are listed as
code_files. What a file names is kept per content hash in a small
per-frontend cache, which only saves reads.

Two more checks close the gaps around it. A frame of an older version
that reads a changed file of code gets 409, so a script edited while
the page is open never runs under the old consent. The UI puts no text
file into a shared page that is not in code_files, so if the server and
the UI ever disagree, the file fails to load instead of running outside
the consent. The trust sheet names the files of code when only one of
them changed.

Assisted-by: Claude:claude-opus-5-5

* Let a page be a tile on Home in its compact mode

Home widgets could show a list, a number or the last chats, but not one
of the pages a person or the assistant built. A page can now be a tile.

A page opts in with a widget block in page.json: a size (small, medium
or large, the height of the tile) and an optional short title. A
widget of type page in LocalAI/home.json names the page, and a folder
id for a page of a shared folder. The server takes at most three page
widgets and says why in words, and refuses a name that is not a page
name.

The tile runs the page in the same sandbox and bridge as the full
view. The page reads localai.page.mode ("widget" in a tile, "page"
otherwise) and localai.page.size, and <html> carries data-mode and
data-size, so the page decides how its tile looks instead of being
shrunk. The tile reserves the height of its size, builds its frame only
once it scrolls into view, tells the page through a new visibility
event when it goes out of sight or the tab is hidden, takes the frame
down far off screen, and keeps at most three frames alive. It has a
title bar with Open page and a refresh button, a skeleton while it
loads, Retry on an error, and the link question of the full view. A
page that is gone or has no widget block shows a calm line with a link
to Customise Home; a shared page that the viewer has not allowed shows
Allow this page, which opens the same trust sheet.

Customise Home lists the pages with a widget block, with the size of
their tile. The task board starter has a compact view, the skills tell
the assistant how to put a page on Home, and the docs describe it.

Assisted-by: Claude:claude-opus-5-5

* Give the Files upload its own size limit

The Files page upload was capped by the general --upload-limit (15 MB
by default), although a workspace file can be 256 MiB.

Make 0 the default of --upload-limit, meaning not set. When it is not
set, the general routes keep the 15 MB limit and the Files upload route
skips it, so the workspace file size is its limit. When the operator
sets a value, it applies to the Files upload too, lower or higher (a
file never exceeds the workspace size). The max_file_size of a folder
listing reports the limit in force.

Assisted-by: Claude:claude-sonnet-5-5

* Refuse writes of code files from a page

A page loads its own scripts, modules and styles, so a page that could
overwrite app.js or style.css would run new code on its next load. The
page bridge now writes data files only.

The server refuses a write whose final segment has a code or document
extension (js, mjs, cjs, css, html, htm, svg, xml, xhtml, wasm), is named
index, or is page.json, with 403 and a clear message. The check applies
to the page routes only; the Files page and the assistant's file tools
keep full write access. The judgement lives in pages.IsCodeFile and is
mirrored by isCodeFile in the web host, which refuses early with the
error code forbidden. Both run one table of cases, covering case, double
extensions, trailing dots and spaces, percent-encoding, backslashes and
lookalike dots.

Assisted-by: Claude:claude-sonnet-5-5

* Make the trigger tools a built-in group a chat can switch off

propose_trigger, list_triggers, update_trigger and delete_trigger were
added to every chat a person starts, with no switch. They are now four
entries of the built-in tool registry in one group, triggers, so the
Tools menu, the tools panel, the draft list and the builtins switch
endpoint handle them like the web group. The group is on by default.

A trigger or connector run and a sub-agent still never get them. The
panel lists the group as not usable, with a reason, for those chats,
for a user without the triggers feature, and not at all when the server
runs no triggers. The group has no runtime setting, so the registry
gets an Unlisted flag and Settings > Tools skips it. The panel now
builds every grouped tool to learn whether it is usable, not only the
opt-in ones.

Assisted-by: Claude:claude-sonnet-5-5

* Show the Triggers group in the chat Tools menu

The menu rows come from the groups the server lists, so the row needs
only its label in the nine locales. A Playwright spec covers the row,
its switch request, Select all and Disable all, and the disabled row
with its reason.

Assisted-by: Claude:claude-sonnet-5-5

* Talk about settings, not the files behind them

The settings cards, the shortcuts row, Home and the change notes in a
chat named the files that hold a person's settings: "Defined in
LocalAI/shortcuts.json", "Layout is in LocalAI/home.json", "Saved
LocalAI/persona.json". A person changes their shortcuts or their Home,
not a file, and the Files page still shows the files to whoever looks.

The "Stored in", "Defined in" and "Layout is in" lines are gone; the
history line keeps Versions, Revert and the Invalid badge. The notes
say what changed ("Saved your shortcuts.", "Created the page Task
board."), the errors name the setting or the widget instead of a path,
and the Source view says "The settings as text" instead of JSON and
schema. A failed save now says what happened in one plain sentence and
keeps the server's own words under a collapsed Details. The drafts that
"Edit with the assistant" puts in the composer no longer carry the
path; the built-in workspace skill already maps shortcuts and Home to
their files.

All nine locales are translated; only the changed keys moved, and four
keys that nothing reads any more are removed.

Assisted-by: Claude:claude-opus-5-5

* Drop internal names from the skills, add-ons and tasks copy

Other pages used words that only make sense to someone who knows how
LocalAI is built:

- Skills said "a folder with a SKILL.md" and "the file workspace". It
  now says what a skill is for, and that skills need Files.
- The add-on notes blamed "the owner", the internal name of the server
  that runs add-ons. They now say what happened to the action, or that
  the add-on service is not available; a private library names a
  credential "an administrator configured on this server".
- The Tasks header listed an API route, and the first-run wizard spoke
  of a "host bash fallback". The memory line of Settings no longer
  mentions "the resources endpoint".

All locales that have these keys are translated. The skill delete
dialog, the unused skills intro and the Tasks header exist only in
English and change only there.

Assisted-by: Claude:claude-opus-5-5

* Fail the unit tests when UI copy names implementation details

A new test reads every string of every locale and fails when one names
a settings or data file (.json, .yaml, LocalAI/), an API route or HTTP
method, a setting key (chats_*), an environment variable, a format
word (JSON, YAML, schema) or a path shown as code. Plain words such as
"schema" and "endpoint" are checked in English only, since other
languages use them for other things.

Operator pages may still name the model configuration, the metrics
route or a startup setting that the docs name. Those keys are listed in
ui-copy-allowlist.json, each with the audience and the reason. The list
only shrinks: an entry whose string no longer needs it, or whose key is
gone, fails too.

Assisted-by: Claude:claude-opus-5-5

* Name the triggers group in the chat tools routes

The routes that list and switch the built-in groups of a chat said only
"a name such as web". They now name all three: web, triggers and
saved_agents, with the tools of each.

Assisted-by: Claude:claude-opus-5-5

* Make the built-in skills answer in plain words

The skills told the model to report file names, folders and formats
after a change. Add one rule near the top of localai-workspace and
localai-apps: do the work with files, but say what changed, and name a
file only when the user did or asks. Replace the example replies that
carried paths, keep every path that says where to write, and pin the
rule with a test.

Assisted-by: Claude:claude-sonnet-5-5

* Apply the Go 1.26 modernizers to the latest Studio code

go fix suggested slices.Contains, strings.SplitSeq and range-over-int
on lines added since the first pass. ParseSearchProviders had the same
hand-written membership loop, so it uses slices.Contains too. Only
lines that the branch added since then are changed.

Assisted-by: Claude:claude-opus-5-5

* Share the blob wait and the counting of the two workspace purges

The JSON and GORM stores each had the 2-minute default for the blob
grace, the same switch that reads the BlobGrace option, and the same
loop that counts files, folders and bytes per revision. They now call
blob.Grace and PurgeResult.Add, both with specs. The SQL and the
in-memory walk that pick the nodes stay in each store.

Assisted-by: Claude:claude-opus-5-5

* Read the upper layer of a sandbox in one place for both stores

The JSON and GORM workspace stores had the same readUpper, place,
joinRel, depth and sortedKeys, with the same upperSet types. They move
to workspace/internal/upperset, which has its own specs. The commit
algorithm that applies the set to the nodes stays in each store; it
needs a storage interface first.

Assisted-by: Claude:claude-opus-5-5

* Let chat attachments use the 1 GiB message limit

The docs promise 1 GiB for a message with attachments, but the general
body limit (15 MB by default) ran first, because only the Files upload
skipped it. Skip it also for a multipart POST to the messages route
when the operator set no --upload-limit. A JSON message keeps the
general limit, and an explicit --upload-limit still wins. A message
that goes over the 1 GiB limit now gets 413, not 400.

Assisted-by: Claude:claude-opus-5-5

* Name the read_file tool in the skills index header

A weaker model read a built-in skill with the wrong path and then tried
the Sandbox, which cannot see skill folders. The header now says which
tool reads a skill and that the shown path is the one to use, before it
mentions where the user's own skills go.

Assisted-by: Claude:claude-sonnet-5-5

* Tell the model that skill folders are not in the Sandbox

The Commands block of an isolated chat now says that skills are read
with read_file. The Sandbox tool description and the per-turn note are
at their size caps, so the sentence goes in the system prompt.

Assisted-by: Claude:claude-sonnet-5-5

* Point read_document at read_file for text files

read_document reads PDF, Word, Excel, PowerPoint and EPUB files. For a
Markdown or text file it said only that the file is not a document it
can read. The error now names read_file.

Assisted-by: Claude:claude-sonnet-5-5

* Tidy the text index stores and the indexer pool

The two stores wrote the default limits 20 and 100 as numbers in five
places; they are now named constants, so the stores cannot drift. The
JSON store checks a missing file with errors.Is. The indexer starts its
readers with wg.Go and keeps the first error inline, which drops the
cmpErr helper.

Assisted-by: Claude:claude-opus-5-5

* Refuse the server's public address in the Sandbox proxy

web_fetch refuses the addresses of the server: its interfaces,
loopback and the host of the external base URL. The Sandbox proxy runs
in the add-on process and listed only the interfaces of its own
machine. Under "All public sites", an always-allowed host or an Allow
card, a command could reach the LocalAI API through the public name or
address of the server, a NAT address or a proxy in front of it.

The host now keeps one list for both. The application builds it once,
gives it to web_fetch, and sends it as CIDR strings in the new self
field of every egress.check allow. The proxy keeps the last list it
got and refuses it on each dial, besides its own interfaces, also for
an allow it cached. safehttp gets the codec and a join of deny lists,
so both sides read the list the same way.

Assisted-by: Claude:claude-opus-5-5

* Check the TLS server name on every port of the Sandbox proxy

The proxy checked the ClientHello only on CONNECT to port 443. An
always-allowed host pinned to another port, or any port added to
chats_egress_ports, was a raw tunnel: a command could open TLS to
another tenant of the same front through it. The docs said the server
name must still match.

On other ports the proxy still waits for nothing, since the protocol
may be anything and the server may speak first. But when the first
bytes from the guest are a TLS handshake record, it reads the whole
ClientHello and passes it on only when the server name is the CONNECT
host; otherwise it closes the tunnel and audits the reason sni.

Assisted-by: Claude:claude-opus-5-5

* Say what sent each notification

The inbox only knew whether a notification was about a run or needed
attention, so a trigger that was switched off and a connector that was
switched off looked the same. Each notification now carries a source:
trigger, connector or system (the conversion of old data). The list
endpoint returns it, and items stored before have none.

Assisted-by: Claude:claude-opus-5-5

* Say where a skill is when a read of it fails

When read_file, read_document or list_files fail with not found for a
path that names a skill of the chat (a wrong skills folder, or just
<name>/SKILL.md), the error now gives the skill's real path and says to
read it with read_file. The skills come from the chat's own resolver, so
every layer, add-on and per-user switch is respected, and nothing is
looked up unless a read fails.

Assisted-by: Claude:claude-sonnet-5-5

* Keep the last valid Sandbox network lists when a value is bad

The settings endpoint refuses a bad chats_egress_allowed_hosts with the
reason, and the runtime settings file keeps the value in force. A bad
value that reached the configuration another way was dropped with no
log line: the always-allowed hosts and the extra registries became
empty, and a bad port list fell back to 80 and 443, which can widen a
narrower list.

Each of the three lists now keeps the last value that parsed, logs a
bad value once, and uses the default only when the process never saw
a valid one. The parse runs again only when the value changes, not on
every egress.check.

Assisted-by: Claude:claude-opus-5-5

* Use the keyless chain of the built-in search in the Web add-on

The add-on replaces the built-in web_search, but its keyless provider
was DuckDuckGo alone. DuckDuckGo often answers data-center addresses
with a bot check, so installing the add-on without a key made search
worse than the built-in, which falls back to the Brave Search and Bing
result pages.

The keyless entry of the add-on's order (saved name duckduckgo) is now
the built-in chain from pkg/web/search: DuckDuckGo, then the Brave
Search page, then the Bing page, with the same cool-down. The status
tool reports the providers in their cool-down, and the Settings tab
shows them with the reason. Test the providers gives one row for each
page of the chain.

Assisted-by: Claude:claude-opus-5-5

* Fix the GitHub hosts in the Q1 row of the egress design

The Q1 row still listed raw.githubusercontent.com on the registries
list. The owner decision below it and registries.json exclude it,
since it serves any user content, and add
release-assets.githubusercontent.com. The row now says the same.

Assisted-by: Claude:claude-opus-5-5

* Keep one private-address rule for the network cards

A card asked whether its host is a private IP literal by calling
egress.Admits with a made-up ceiling, which re-derived the rule that
egress already has. egress.PrivateLiteral is now exported (with a spec)
and the card uses it. The two String methods of an allowed host write
host:port with net.JoinHostPort, which brackets an IPv6 literal the
same way the hand-written code did.

Assisted-by: Claude:claude-opus-5-5

* Read a unique constraint violation in one place

The jobs and triggers stores had the same isUniqueViolation, and the
saved agents store had the same test inline before it looked for the
name index. They now call dberr.IsUniqueViolation, which has specs for
the SQLite and PostgreSQL error texts. The workspace store keeps its
own stricter match.

Assisted-by: Claude:claude-opus-5-5

* Ask before a Home widget link opens another site

A list widget turned an http or https item into a plain link to a new
tab. Its data file is written by trigger runs and the assistant, which
read web text, so a planted link opened with one click, and only a
tooltip showed the host. A link of a page asks first.

An external item link now opens the confirmation bar of page links in
the widget: the site, the full address, the punycode warning, and Open
or Cancel. A link inside the app still navigates directly. The bar
takes a label, so the widget says that it wants to open the link.

Assisted-by: Claude:claude-opus-5-5

* Prune the audit of the commands run on this machine

The audit of the built-in bash had no retention: the database table
grew without bound in a cluster, with commands of up to 4 KiB each.
The Sandbox network audit had chats_egress_audit_days and a daily
prune under an advisory lock.

Both audits record what the commands of the chats did, so the host
command audit now uses the same retention and is pruned in the same
daily pass: one frontend of a cluster, under the same lock; a
standalone process alone. The GORM and file stores get Prune. No new
setting.

Assisted-by: Claude:claude-opus-5-5

* Save the Studio JSON stores with atomicfile.WriteJSON

Seven stores (agents, connectors, jobs, MCP connections, notifications,
push subscriptions and triggers) encoded their state and then called
atomicfile.Write with the same four lines. atomicfile.WriteJSON does
both and has specs. The load side stays in each store, since the error
messages differ.

Assisted-by: Claude:claude-opus-5-5

* Build the result page walkers of the keyless search on one walk

hasChallenge and firstByClass each had their own recursive walk that
stopped at the first match. They are now predicates passed to
firstElement, which already did that walk. The order of the walk and
the element tested first are the same.

Assisted-by: Claude:claude-opus-5-5

* Expire network cards that a dead frontend left pending

Pending network cards expired only on the turn-end path of the process
that ran the turn. When that frontend died, its cards stayed pending
in the chat record, counted toward the cap of three, and gave the next
turn's connections cards-full blocks. The UI showed them as waiting.

A card now names the turn that asked it. One rule, shared with the
command card in a later change, says whether a card still has a
waiter: its turn holds the chat and its wait has not run out. A card
that fails it reads as expired, takes no place under the cap, and is
expired in the record when the next card is asked, when the next turn
starts, or when the reaper ends the dead turn.

Assisted-by: Claude:claude-opus-5-5

* Reuse ChooseProvider for the expected add-on of a tool

shadowers.expected repeated ChooseProvider line for line: the pinned
add-on when it is installed, else the oldest install. It now calls it.
commandFiles kept an order field and sorted the nodes of a map by it;
it now appends each node to a list when it first sees it, which gives
the same order without the sort.

Assisted-by: Claude:claude-opus-5-5

* Do not print a nil error when a key or entry is missing

Three checks joined "the read failed" and "the read found nothing" in
one branch, so the second case wrote "<nil>" into the message: the
VAPID keys of push notifications, the sealing key of connectors and a
rendered catalog entry. Each case now has its own message. The message
of a failed read does not change.

Assisted-by: Claude:claude-opus-5-5

* Check the close errors and rename the tool variables

golangci-lint failed on the new studio code. Five deferred Close calls
and one Fprintf into a hash dropped their error, and two locals named t
made forbidigo read `t.Run` as a testing call. Discard the errors
explicitly and name the locals tool.

Assisted-by: Claude:claude-opus-5-5

* Make the file text index forget purged files and say its limits

Three gaps of the text index of documents:

- A purge dropped the index entries of what it erased in a goroutine
  that only logged its errors, and the purge had already erased the
  journal rows. A crash or a store error left the text in the database
  for good. Each pass of the indexer now also checks a batch of entries
  (200 every 30 s, round all of them) and deletes those whose node is
  gone, so what a failed Forget leaves is removed.

- A document search stopped at 20000 files or 64 MiB of text without a
  word, and the "still being indexed" count stopped at 50. The search
  now sets Limited (search_files says "results limited", the REST
  answer has "limited", the Files page shows a note), and the count is
  the number of pending entries below the path.

- read_file extracted a long document again for every page, up to 50
  MiB and 60 s each time. The first read keeps the whole text in the
  index store by content hash (a new documents table, or files in the
  JSON store), and the next pages come from it. The text goes away with
  the last entry of that content, on delete and on purge.

Search reads the text without its folded copy, which only the database
needs.

Assisted-by: Claude:claude-opus-5-5

* Stop the PDF reader when the extraction gives up

The PDF and EPUB reader runs in a goroutine, and the call returns after
60 seconds when it is slow. The goroutine went on reading every page,
since its context ended only with the caller's. It now gets a context
that ends when the call returns, so it stops at the next page. What
the call returns does not change.

Assisted-by: Claude:claude-opus-5-5

* Compare the code files of a shared page as the store does

The guard that refuses new code to a frame of an older version compared
the name it reads with the names of the page's code exactly. The
workspace folds case, so an old frame that read Index.html or JS/App.js
got the new code with no 409. Compare names in the store's form
(workspace.FoldName), as the write check already does, and test the
case variants.

Assisted-by: Claude:claude-opus-5-5

* Expire the command card of a turn that is over

The card of the built-in bash was cleared only by a defer in the call
that asked. When that frontend died, the card stayed in the chat
record. The chat hid it only while no turn ran, so the next turn
showed it again, and an answer to it reached no one but still got 204.

The card now names its turn and when it was asked, and follows the
same rule as the network cards: it waits only while that turn holds
the chat and its five minutes have not run out. GET /api/chats/{id}
shows only such a card. The next turn and the reaper clear a card left
behind, and the waiting call clears only its own card, so it never
removes a newer one. An answer to a card with nobody waiting is
410 consent_expired, and the chat shows the card as not answered, with
no buttons. The store gets UpdateHostConsent, an atomic change of the
card.

Assisted-by: Claude:claude-opus-5-5

* Answer a trigger change once across frontends

ApplyChange, UndoChange and DismissChange checked the outcome of the
change record under a mutex of one process, then wrote the store. Two
frontends could both see a pending change and both apply it, or both
undo it.

Add Store.SetOutcome, a conditional update of the outcome (only from
the outcome it names), to the GORM and JSON stores and to the store
suite. The three answers now claim the record with it before they
write, give the outcome back when the change itself fails, and answer
ErrProposalAnswered (409) when another request won. A test runs the
two answers on two frontends at the moment between the read and the
write, for an edit, its undo, and a delete.

Assisted-by: Claude:claude-opus-5-5

* Add the shadowers spec to the tables-tools inventory

The tool adapter gained a spec that lists the add-ons that replace a
built-in in any state. The tables-tools suite runs every spec of that
package, so verify.sh found one case more than the inventory holds.

Assisted-by: Claude:claude-opus-5-5

* Offer Clear filters when the backend Catalog shows nothing

The Installed view had a Clear filters button in its no-results state;
the Catalog view did not, although the spec asks for it in both. Move
the button to one place and show it in the Catalog when a search, a
type filter or a library filter leaves no row. Add Playwright specs for
both views, which the earlier Installed fix lacked.

Assisted-by: Claude:claude-opus-5-5

* Give the chat settings without an assistant tool their reason

chats_max_iterations, chats_work_folder, chats_index_files and
chats_index_max_text_bytes had no MCP tool and no entry in
adminRoutesWithoutTool, and the older chats_saved_agents,
chats_suggestions, chats_vision_model and chats_transcription_model
were unlisted too. Add each with the reason it stays a person's choice
in Settings: they apply to every user's chats, and the step limit is
one a model must not raise for itself.

Assisted-by: Claude:claude-opus-5-5

* Remove the stray allowed-hosts keys from the chats inbox locales

allowedHosts, allowedHostsOff and allowedHostsNote sat twice in eight
locales: under egress, where English has them and the UI reads them,
and again under inbox, where nothing reads them. In de and ko the two
copies had drifted apart. Delete the inbox copies and keep the egress
ones.

Assisted-by: Claude:claude-opus-5-5

* Bring three design specs in line with what shipped

- web tools and shadowing: the status still said phase 2 waits; both
  phases shipped. Note that the Web add-on's keyless entry is now the
  built-in keyless chain.
- Home front door: Home now creates the chat and sends the first
  message itself, with a short-lived hand-off to the chat page, and
  its composer takes attachments. Both were described the old way or
  as out of scope.
- chat footer: the meter bar is 96 px with the tokens left, and a phone
  keeps the bar and the tokens left, not the percentage only.

Assisted-by: Claude:claude-opus-5-5

* Store the command card's answer and read it back every two seconds

The answer to the command card of the built-in bash went only as a
bus message. When the message was lost, the call waited the full five
minutes and ended as not answered, although the person had clicked.
The network card stores its answer first and re-reads the card every
two seconds.

The command card now does the same, through one shared pair of
helpers: listenConsent subscribes before the card is stored, and
waitConsent wakes on the bus message or every two seconds and reads
the record, which holds the truth. The network card uses the same
helpers. HostExecDecide writes the decision to the card atomically
before it sends the message: the same answer twice is 204, the other
answer 409 already_answered, and an answered card is no longer shown
as waiting.

Assisted-by: Claude:claude-opus-5-5

* Load skills by name with a load_skill tool

A weaker model failed to read a skill at the path the index showed. The
chat now has a built-in load_skill tool that takes a skill's name and
returns the instructions of its SKILL.md, without the frontmatter.

The tool reads the same resolved skill set as the index: the nearest
skill of each name, the chat's switches, the add-ons the chat can use
now and the grants of its agent. It reads through the user's rights.
When the skill's folder holds other files, the answer starts with the
base directory and names up to 20 of them for read_file; a chat without
read_file gets the instructions only. A skill that needs commands, in a
chat that cannot run them, is refused with the reason. An unknown name
is an error that gives near names first and then every name the chat
has.

Every chat with at least one skill gets the tool, and a sub-agent gets
it with its parent's tools. It has no switch and Settings > Tools does
not list it.

The index of skills now lists names and descriptions only and tells the
model to call load_skill. The Sandbox sentence and the error for a
wrong skill path now point at load_skill too.

Assisted-by: Claude:claude-opus-5-5

* Tell the built-in skills to load other skills by name

localai-apps told the model to read the localai-workspace skill, and
writing-skills said a description is what the agent sees before it
opens the file. Both now speak of loading a skill with load_skill.
localai-workspace keeps the path of its schemas folder and says to read
it with read_file, since those files are not a skill.

A test checks that no built-in skill sends the model to another skill by
"read" or by the path of its SKILL.md.

Assisted-by: Claude:claude-opus-5-5

* Document load_skill and the skills index

The chats page now says that the prompt lists skills by name and
description, that load_skill returns a skill's instructions and names
the files of its folder, what it refuses and who gets it. It no longer
says the model reads a skill by its path. The add-ons page says how a
chat loads an add-on's skill, and the saved agents page says that the
skills grant limits load_skill too.

Assisted-by: Claude:claude-opus-5-5

* Add the proposal action spec to the tables-acceptance inventory

The spec that an editor cannot confirm a schema change as a write ran in
the suite but was never in the inventory, so the evidence manifest check
failed. CI did not reach that check before, because the burst spec
failed first.

Assisted-by: Claude:claude-opus-5-5

* Give a chat a goal that it works toward by itself

A chat can now hold one goal. While it is active, every turn's prompt
names it, the model gets goal_done and goal_blocked, and when a turn
ends by itself the frontend that ran it starts the next one with
"Continue toward the goal". The person's waiting messages still go
first, and a turn they start does not count as an automatic one.

The goal pauses when the person presses Stop, when a turn fails or is
cut short, and when the automatic turns or the model steps reach their
limits. Resume gives it fresh budgets. A sub-agent, and a chat that a
trigger, a connector, a job or a page started, has no goal and no goal
tools.

The goal lives with the chat record in the store (UpdateGoal on the
JSON and GORM stores, with contract tests and a column migration), so
every frontend reads the same one. The next turn goes through the same
turn claim as any other, so only one frontend continues it; a reaped
turn, or a JSON store that reloads after a crash, leaves the goal
paused instead of continued twice. The transcript keeps goal lines
(set, replaced, continue, paused, resumed, done, blocked, cleared) as
their own role, so an automatic message never reads as typed by the
person.

Assisted-by: Claude:claude-opus-5-5

* Add a store for the loops of the chats

A loop repeats a prompt in one chat, at an interval or when the model
asks for the next run. The new loops package keeps the loops, the
daily budget of runs of each owner, and which chats confirmed a loop
once. JSON and GORM stores share one contract suite. Add checks the
limits per chat and per owner in the same step as the insert, and
TakeRun never hands out the last run of a day twice, also across
frontends.

A user message that a loop sent now carries a loop mark (loop ID, run
number, self-paced or not). Both chat stores keep it. The model reads
a short line before the prompt that says the message repeats by
itself, and, for a self-paced loop, how to ask for the next run.

Assisted-by: Claude:claude-opus-5-5

* Add the file preview slot specs to the host-slots inventory

The file preview slot came with two Go specs in the host-slots package.
verify.sh found them in the run but not in the inventory.

Assisted-by: Claude:claude-opus-5-5

* Check the Settings page and the add-ons with the UI copy guard

The guard read the locale files only. The Settings page keeps its
English labels and descriptions in settingsSchema.js, and several
add-ons carry their own English bundle, so neither was checked.

The guard now also reads the label, description and note of every
setting and the label and hint of every group, and the English
bundles of the add-ons (an en.js module or an English JSON file).
The allow-list takes settings:<key>.<what> and addon:<id>:<key>
entries, each with its reason; the operator ones (the SearXNG JSON
format, the fine-tuning file types, the API route of a voice, the
schema of the Databases add-on) are listed.

What it found in the voice library is fixed in plain words: "model
YAML is not modified" and "Existing model YAML is not changed" now say
that the models' settings are not changed, in the voice add-on, the
Studio add-on and the host locales that have the strings (en, fr,
pt-BR). The two host allow-list entries for them go.

Assisted-by: Claude:claude-opus-5-5

* Add the update digest specs to the updates inventory

Two checker specs pin that an update names its candidate digest and
that a stale cached notice stays readable. The updates suite runs the
whole checker package, so the inventory needs both.

Assisted-by: Claude:claude-opus-5-5

* Draw the Add-ons search and state filter with the kit pieces

The Add-ons page had its own copy of the search box and the segmented
filter (ad-search, ad-seg), the same shapes and styles as the kit's
SearchField and Segmented that the Agents pages use. Use the kit
pieces and drop the copy; the narrow-screen rules now target the kit
classes inside the toolbar. The search keeps its test id.

Assisted-by: Claude:claude-opus-5-5

* Add the limits of a chat goal to the runtime settings

chats_goal_max_turns (1 to 200, default 20) is the most turns a chat
with a goal starts by itself, and chats_goal_max_steps (1 to 10000,
default 400) the most model steps of all the turns of one goal, before
the goal pauses. Both are read when a turn ends, so they apply without
a restart. They sit in Settings > Tools next to the step limit of one
turn, and the settings page documents them.

Assisted-by: Claude:claude-opus-5-5

* Add the goal routes of a chat to the API

GET, PUT and DELETE /api/chats/{id}/goal read, set and clear a chat's
goal, and POST /api/chats/{id}/goal/resume resumes a paused or blocked
one. They belong to the chat's owner like the other chat routes and
take the chats feature. A chat view carries the goal with the limits
that apply now, the transcript gives goal lines with goal_event and
goal_turn, and the event stream has a goal event so that other tabs
read the chat again.

The routes are in the route feature registry, the swagger file and the
chats entry of /api/instructions. They manage the person's own chat,
not the server, so they have no MCP tool, as the other chat routes. The
chats page documents goals, their states and limits, and how several
frontends share them.

Assisted-by: Claude:claude-opus-5-5

* Redesign the notification inbox

The inbox panel was a plain list: every row had the same weight, a
failed run looked like a finished one, the panel ran off the screen on
a phone, and a failed load showed nothing.

The panel now has a header with the unread count, a menu for Clear read
and Clear all, Mark all read, and an All / Unread / Failed filter. Items
are grouped under Today, Yesterday and Earlier. Each item shows what
sent it (trigger, connector or LocalAI) with a tinted icon, the title,
two lines of text or the reason of a failure, and when it happened, with
the exact time on hover. A click marks it read and opens its chat; a
connector that was switched off opens the connectors page. Mark read and
Delete show on hover or focus, and stay in view on a touch screen.

The panel has a loading skeleton, an error with Try again, an empty
state that says what shows up here, and a caught-up state. A new
notification slides in, is highlighted for a few seconds and is
announced once. The badge stops at 99+. Focus moves into the panel when
it opens; the arrow keys, Home, End and Delete work on the items, and
Esc returns focus to the button. On a phone the panel is a sheet over
the bottom of the screen with 44 px targets.

Assisted-by: Claude:claude-opus-5-5

* Explain the reads of the text index and the provider requests to gosec

gosec flagged three new lines. The text index reads files from the
directory the host gave it, with names it listed there. A search
provider sends its request to the endpoint fixed at construction, never
to a URL from a tool call. Mark the lines with the reason.

Assisted-by: Claude:claude-opus-5-5

* Keep the add-on details open when Escape closes a dialog

Escape in the restore dialog of the recovery panel also closed the
details pane around it. The pane handles Escape in React, and the
dialog listens on the document, so the pane ran first and saw no
preventDefault. The pane now registers as a modal layer and closes only
when it is the top layer, as the side panes do.

Assisted-by: Claude:claude-opus-5-5

* Add skill and agent messages, compact and clear to the chats API

A message can now carry a skill of the chat or one of the user's saved
agents. The server checks the name against the chat's resolved skill
set (switches, the agent's grants, shadowing, add-ons) or the saved
agents the chat may call, and refuses an unknown one with a 400 that
lists what the chat has. A skill message gives the model the text of
the skill's SKILL.md before the user's words, for that turn only; an
agent message asks the model to start the agent with spawn_agent. The
transcript keeps the words and the command (Entry.Invoked), not the
instructions. A regenerate or an edit runs the command again. Such a
message does not wait for a running turn: it answers 409.

POST /api/chats/{id}/compact runs the summary the loop makes when the
context fills up, through a new optional session.Compactor that the
loop harness implements with its own history compaction. The compacted
state is saved for the next turn and a "compacted" line with the sizes
goes in the transcript.

POST /api/chats/{id}/clear writes a "cleared" line and drops the saved
state. The model then sees nothing above the line: the rebuilt state
and the transcript summary start after the last such line, so a fork
from above it keeps what the model saw there. Export, import and the
Markdown export carry both lines.

Both calls take the turn claim, so no frontend starts a turn on the
chat meanwhile; messages sent during them start a turn after. No MCP
tool: they act on one chat of the caller and are not admin calls.

Assisted-by: Claude:claude-opus-5-5

* Give each network card spec its own settings

A check that a card spec starts can still wait on its card when the
next spec begins. It read the settings through a closure over a
variable that the next BeforeEach replaced, which the race detector
reported. Each service now reads the settings holder it was built with,
under a lock, and the specs change the settings through it.

Assisted-by: Claude:claude-opus-5-5

* Show the effective audit retention in the settings snapshot

The settings snapshot returned 0 for chats_egress_audit_days while the
setting was unset. Any page that saves the whole snapshot back, such as
the Traces settings strip, then posted 0 and the server rejected it with
"Invalid chats_egress_audit_days", so saving tracing settings failed on a
fresh install.

Report the retention that applies (30 days by default), the same way
chats_max_iterations does.

Assisted-by: Claude:claude-sonnet-5-5

* Run the Sandbox smoke test in a short scratch folder

The smoke test step was reached for the first time and both isolated
backends refused to start: their state folder under the checkout's
cache made the socket paths 126 and 132 bytes, over the 107 a unix
socket allows. Point SANDBOX_SMOKE_WORK at /tmp/sandbox-smoke, which
leaves room for the sockets.

Assisted-by: Claude:claude-opus-5-5

* Use paths from the home folder in the Sandbox command specs

bash now starts in the user's home folder, so the end-to-end specs that
read and wrote home/<name> found nothing. Use names relative to the
home folder, as the Sandbox specs already do.

Assisted-by: Claude:claude-opus-5-5

* Show the goal of a chat on the chat page

A bar above the composer shows the goal with its state: Pause (which
stops the running turn and so pauses the goal) while it works, Resume
when it paused or needs the person, and Clear. A second line says why
it paused, what the model needs, or what it did. The status line shows
"Goal · turn 3 of 20" while the goal is active, and the Continue button
of a paused turn stays away then, since the next turn starts by itself.

The transcript shows the goal lines as quiet rows: set, replaced,
continuing (with its turn), paused with the reason in plain words,
resumed, reached with the summary, blocked and cleared. A goal event,
also from another tab, reads the chat again, and a hidden live region
says each change to a screen reader once.

The strings are in all nine locales. Unit tests cover the helpers and
the thread rows; a Playwright spec covers the bar, the progress, the
lines, Pause, Resume, Clear and the event.

Assisted-by: Claude:claude-opus-5-5

* Run the loops of the chats

The loop host fires the loops that are due. A run sends the prompt to
the chat as a new message only when the chat is idle: a busy chat
skips the run, and an interval loop moves to its next slot instead of
piling missed runs up. A self-paced loop gets two tools while it is
active: schedule_next (clamped to the minimum interval and one day)
and loop_done. A run in which the model calls neither ends the loop
with a note. A loop ends after its run limit, pauses after three
failed runs in a row, and waits for the next UTC day when the owner's
daily budget of runs is used up.

One frontend fires the loops, the holder of a new advisory lock. A run
is claimed in the store first, and the turn claim keeps one turn per
chat, so a short overlap of two leaders starts no run twice. A new
leader finishes the runs the old one watched. Deleting a chat removes
its loops; a due loop whose chat is gone removes itself.

Five runtime settings bound the loops: chats_loop_min_interval,
chats_loop_max_runs, chats_loop_max_per_chat, chats_loop_max_per_user
and chats_loop_daily_runs.

Assisted-by: Claude:claude-opus-5-5

* Add the loops API of the chats

GET, POST and DELETE /api/chats/{id}/loops list, start and stop the
loops of a chat; PUT and DELETE /api/chats/{id}/loops/{loopId} pause,
resume, confirm or stop one. GET /api/chats/{id} carries the loops,
the transcript marks the user lines a loop sent with loop {id, run},
and a loops event tells every open tab that the list changed.

The routes are gated by the chats feature and documented in the
swagger spec and the API instructions. They act on the caller's own
chats only, so they get no assistant tool. The chats page gets a loop
section, with when to use a loop and when a trigger, and the cluster
behaviour; the runtime settings page lists the five loop settings.

Assisted-by: Claude:claude-opus-5-5

* Show the loops of a chat above the message box

Each loop of the open chat shows as one line over the message box,
such as "Loop · every 5 min · run 4 · next at 09:20", with Pause or
Resume and Stop. A loop that paused itself or ended says why; an
ended one has Dismiss. The first loop of a chat shows a card instead:
"Run this every 5 minutes until you stop it?" with Start and Cancel.
The server keeps the pending loop, so a reload shows the card again.

A message that a loop sent carries a small "Loop run 4" label. The
page reads the loops again on the loops event, so every open tab
follows. The answer to /loop list is ready for the slash command. The
strings are in all nine locales.

Assisted-by: Claude:claude-opus-5-5

* Build the chat slash menu on one command registry

The slash commands of Home and of a chat now come from one registry,
src/utils/slashCommands.js. A command has a kind (navigate, insert or
server), the surfaces that offer it, a needs check and, optionally, an
argument provider: args(query, ctx) returns the items the menu lists
when the box holds "/cmd <query>". Every existing command and test id
stays; the registry adds:

- one command per skill of the chat or the draft (/review), marked
  built-in, add-on, shared or yours, and /skill <name>, which also
  reaches a skill named like a command;
- /agent <name> <task>, which lists the saved agents, or offers to turn
  them on for the chat when they are off;
- /model and /models with a model name, which switch at once;
- /compact and /clear, which show their progress in the status line
  and leave a marker in the thread; the context meter drops;
- /attach and /attach clear, /help (a card that lists the commands)
  and /about (a card with the model, its state, the tools, skills,
  where commands run and the context size).

Tab completes a command or an argument, Enter runs it, and the first
row whose name starts with what was typed is selected first. A message
typed as a skill or agent command is sent with its marker, not as text;
the transcript shows the words and a chip. Home offers the same set
without the commands that need a thread.

The words are in all nine locales. The registry has unit tests, and a
Playwright spec covers the menu, the completion, the marker, the chip,
compact, clear and the cards with the routes stubbed. The docs page
gains a slash commands section.

Assisted-by: Claude:claude-opus-5-5

* Add the /goal command to the chat

/goal shows a card with the goal, its state, the automatic turns and
steps it used out of the limits, and when it was set. /goal <text>
sets a new goal at once, replacing the one the chat had without a
question, and the goal bar says what it replaced. /goal clear and
/goal resume act on the goal; typing "/goal " lists resume and clear
for the goal the chat has. None of these is sent as a message. In a
new chat, /goal <text> creates the chat first.

The command sits in the Context group of the shared command registry,
on the chat surface only, and not in a chat that a trigger or another
run started. A skill named goal is reached through /skill. Unit tests
cover the parser and the menu items; the Playwright spec covers the
card, setting, replacing, resuming and clearing from the box.

Assisted-by: Claude:claude-opus-5-5

* Add /loop to the chat

/loop [interval] <prompt> starts a loop in the chat; /loop list shows
the loops of the chat with the code that /loop stop takes, and
/loop stop [id] stops one or all. The command is in the slash
registry: after "/loop " the menu offers list and stop, and after
"stop " the loops of the chat.

A loop prompt that invokes a skill (/review the diff, or
/skill review the diff) runs as the person's skill command would: the
model gets the skill's text, and the transcript shows the words, the
skill's chip and the loop label. The server checks the skill when the
loop starts and at each run; any other command is refused with the
reason.

Assisted-by: Claude:claude-opus-5-5

* List the loop settings on the Settings page

The five limits of the loops of the chats show under Tools with the
other chat settings: the shortest interval, the runs per loop, the
loops per chat and per user, and the loop runs per day. All apply at
once.

Assisted-by: Claude:claude-opus-5-5

* Clear a sent Home draft when the page goes away too

Home clears the draft of a sent message when it closes, so the text
stays visible while the composer moves into the new chat. While the
chat page still loads, a reload or a closed tab could come first, and
the sent text came back on Home. Leaving the page now clears it too.

Assisted-by: Claude:claude-opus-5-5

* Check the close error of a previewed file

golangci-lint (errcheck) flagged the deferred Close of the file the
preview endpoint reads. Discard its error explicitly, as the other
studio file handlers do.

Assisted-by: Claude:claude-opus-5-5

* Explain the document read of the text index to gosec

gosec reported the read of a stored document text as a file inclusion
through a variable. The path is the configured index directory joined
with a hash that documentPath refuses when it holds a path separator
or a dot, so it cannot leave the documents folder.

Assisted-by: Claude:claude-opus-5-5

* Build the darwin/arm64 add-on packages on macos-15

GitHub retires the macos-14 runner image on 2026-11-02 and runs
scheduled brownouts until then: every darwin/arm64 package job failed
at once with the brownout notice. macos-15 is the Apple Silicon image
that replaces it.

Assisted-by: Claude:claude-opus-5-5

* Expect the chat's own load_skill tool in the agent prompt spec

The spec that keeps the agent engine's text out of the prompt also
required that no load_skill tool or mention reach the model. Chats now
offer their own load_skill tool and the skills index tells the model to
call it, so the spec failed. Keep the checks on the engine's own skill
and command text, and check that the chat's tool is offered.

Assisted-by: Claude:claude-opus-5-5

* Read the user's scripted tool call in the mock model

The chat prompt now names the chat's work folder, and the folder name
comes from the chat title, which holds a cut copy of the user message.
The mock model read the first ADDON_TOOL_CALL marker, the cut one in the
system prompt, and called the tool with broken arguments over and over.
Read the last marker, the one of the user message.

Assisted-by: Claude:claude-opus-5-5

* Keep the phone targets of the inbox at 44 pixels in every build

On a phone the inbox close button and the item actions must be 44
pixels wide. Their rule had the same weight as the kit's .dk-btn
min-width, so the order of the stylesheets decided. The single-chunk
coverage build puts the kit later and the close button shrank to 28
pixels. Scope both rules to their container so they win in any order.

Assisted-by: Claude:claude-opus-5-5

* Let the about-you spec accept the sidebar's own seen date

When the user has pages, the sidebar writes the date of its New label
under seen.new in user.json. In the whole suite other specs make pages
first, so the saved record held that date as well and the exact
comparison failed. Check that the uploaded seen record is kept.

Assisted-by: Claude:claude-opus-5-5

* Give the typed-while-working chat spec time for two turns

The spec waits up to 30 seconds for each step, but two turns of the
mock model with its tool steps take about 28 seconds on their own, so
the default 30 second test limit cut it under load. Set its limit to
90 seconds.

Assisted-by: Claude:claude-opus-5-5

* Scope the foundation spec's state query to the sheet header

The details pane now repeats the observed state under Health, so a
role query for "State: Off" inside the add-on sheet matched two
elements and failed in strict mode. The administrator-manages spec,
and the two specs after it, did not run.

Query the chip in the header (.ad-pane__meta), which is the state the
administrator sees next to the add-on's name, through one helper used
for all three assertions.

Assisted-by: Claude:claude-sonnet-5-5

* Wait for the page to read each apps answer in the registry polling spec

The spec moved the page clock as soon as the stand-in saw a request.
The registry sets its next timer only after it reads the answer, so on
a slow runner the clock could pass the due time of a timer that did not
exist yet, and the next read never came. With the CPU throttled ten
times, the old spec failed in 3 of 3 runs.

The spec now counts the answers the page has read and lets queued work
finish before it moves the clock. It pauses the clock before it leaves
the page, and it tries the pause again when a slow machine passed the
target time. The assertions do not change: one read at once, one read
per interval and no faster, and no read after the disable.

Assisted-by: Claude:claude-opus-5-5

* Close the egress test listener only after it accepts

The dial helper of the egress proxy specs closed its listener when it
returned, while the goroutine that accepts the connection could still
be waiting to run. On a loaded runner the listener sometimes closed
first. The kernel then reset the connection that was still in the
accept queue, the spec's write consumed the reset error, which the
spec ignores, and the next read saw EOF. The spec failed with
"unexpected EOF" before the proxy got the request.

Wait for Accept to return before the listener closes, then start the
proxy handler on the accepted connection.

Assisted-by: Claude:claude-opus-5-5

* Run host commands in a shell that reads no startup file

The host backend promises a fixed environment, but bash -lc reads
/etc/profile and the user's dotfiles. On macOS path_helper rewrote PATH
there, so the command did not get the fixed PATH. Turn the login shell
into 'bash --noprofile --norc -c' in the host backend only, and make the
test assert the exact fixed PATH.

Assisted-by: Claude:claude-sonnet-5-5

* Answer the command card in the platform turns and 400 a bad answer

The tool-list spec of the platform suite timed out: without accounts
the one person is an administrator, so the chat has the built-in bash
in ask mode. The tool list is sorted and bash is first, and the mock
model calls the first tool, so every turn waited five minutes for a
yes on the command card. The turn helper now refuses each card it
sees, as a person who clicks deny, and the turn goes on to its step
limit. bash stays in the checked list.

An administrator who posts a consent with no card id or an unknown
decision got 403 "only an administrator can allow commands". That is
a bad request, not a refusal: it now answers 400, as the swagger
already says, and the route check for administrators passes.

Assisted-by: Claude:claude-opus-5-5

* Let a chat switch off its shell, file, media, collection and sub-agent tools

The Tools menu of a chat switched only three groups of built-in tools:
web, saved_agents and triggers. A person could not keep a chat away
from the shell, the files, the helper models, the collections or the
sub-agents.

Add five groups to the registry of built-in tools, all on until the
chat switches them off:

- shell: bash, whoever serves it. The Sandbox add-on's bash and the
  built-in bash on this machine both go, and the add-on list reports
  commands switched_off. The row stays listed when nothing can run
  commands, not usable, with the reason.
- files: the file tools and read_document.
- media: describe_image and transcribe_audio.
- collections: list_collections and search_collection, usable only
  for a user with the collections feature.
- subagents: spawn_agent. list_agents and /agent follow it.

The registry gets two flags. External marks a tool that its own code
builds for the turn (with the wrappers it needs); the registry lists it
in its group and Hidden tells the builder what the chat switched off.
Covers makes a group's switch also hide the add-on tool that replaces
the built-in one. PUT .../builtins, the panel, the draft list and the
fallbacks use the existing group code.

A sub-agent follows the switches of its parent chat. The turn prompt
is built from the final tool list: no file tool names, no skill
writing hint, no work folder and no rules pointer when the chat cannot
use files, no describe_image when the helper is off, and a commands
block that says the person switched commands off.

Assisted-by: Claude:claude-opus-5-5

* Queue the edited chat message after the first step starts

The spec queued its message as soon as the chat showed a busy state.
The turn is busy before its first model request starts, and that
request takes every message that waits. With the chat's tools and
skills, the first request now starts a few hundred milliseconds after
the turn, so the message went with it, and the edit got 409 "already
delivered" (5 of 6 local runs).

Wait until the answer streams, as the Stop spec does: the request is
running then, and the queued message waits for the next step.

Assisted-by: Claude:claude-opus-5-5

* Give the reload chat spec a turn that outlasts the reload

The spec reloaded the page during a 3 second turn and expected the
page to show the turn still running. A reload of the coverage build
on a busy runner takes 4 to 5 seconds (measured with a 6x CPU
throttle), so the turn had ended before the page could show it. With
the 30 second turn the page always showed it running, so the resumed
state works and only the window was short.

Add MOCK_LONG_STREAM_MEDIUM to the mock model, a turn of about 10
seconds, and use it in the spec with a 60 second limit.

Assisted-by: Claude:claude-opus-5-5

* Wait for the chat page after the Home hand-off in the specs

Send on Home changes the address inside a view transition, and the
chat page mounts about 35 ms later. Specs that waited only for the
address acted on Home in between:

- The title spec pressed Ctrl+K at once, because the server names a
  chat after its first message as it arrives, so the title poll ends
  at once too. The key reached the page before the chat page listened,
  and the menu did not open (3 of 3 presses at 0 ms failed, all at
  300 ms and later passed).
- The Back spec went back before the chat page was drawn. Home was
  never closed, so its draft of the sent message stayed in the box.

startChat now waits for the chat page, the title spec for the title
in the chat header, and the Back spec for the chat page before Back.

Assisted-by: Claude:claude-opus-5-5

* Accept a resolved user.json conflict in the quiet page specs

The sidebar notes in the seen record of user.json the first day it
labels Pages as new, and saves it from the version it read. All specs
share the test server's one user, so tabs of other workers save the
same record at the same time: one gets 409 and useSeen reads the file
again and saves once more. Three tabs that open /app/files at once
give 200, 409, 200. The quiet check of the shell pages counted the
409 as a problem.

Ignore a 409 on PUT /api/chats/wellknown/user in that check.

Assisted-by: Claude:claude-opus-5-5

* Check only the spec's own items in the Trash after the reload

After Empty Trash and a reload, the spec expected the whole Trash to
be empty. The Trash belongs to the test server's one user, and specs
of other workers move files to it meanwhile, so CI saw "In the Trash:
3 files, 356 B." after the reload.

The checks right after Empty Trash stay. After the reload, check that
the server's Trash, the Trash tab and Files do not hold the spec's
items, once each list has loaded.

Assisted-by: Claude:claude-opus-5-5

* Show the new tool groups in the chat Tools menu

The Tools menu lists the groups of built-in tools that the server
sends. The five new ones need words and an order, and the shell row
needs to say who runs the commands.

Name the groups in all nine locales (Run commands, Your files, Images
and audio, Collections, Sub-agents) and order the rows by what they
reach: the shell and the files first, the triggers last. The shell row
carries a chip with the provider (Sandbox or This machine) and a line
under it that says the commands run isolated, or a warning when they
run on this machine. When nothing can run commands the row is disabled
with the words of the commands notice. A switched-off sub-agents group
also turns /agent off in the composer.

The specs cover the rows, the off and on requests, the bulk actions,
the provider chip, the disabled reasons, the draft on Home and a real
server round trip.

Assisted-by: Claude:claude-opus-5-5

* Document the groups of built-in tools a chat can switch off

The Tools menu section described only the web, triggers and saved
agents groups. List every group with its tools, say which groups the
server may leave out and why a row can be not usable, how Run commands
covers both providers, what the model is told when a group is off, and
that a sub-agent follows its parent's switches.

Assisted-by: Claude:claude-opus-5-5

* Fix the host command consent spec for the 400 contract

The consent route answers 400 for a body without a card id or with a
decision other than once or deny, whoever asks. The spec still expected
403 for always from an administrator. Cover the malformed answers for
an administrator and a user, each in their own chat, keep another
user's chat at 404, and say it in the chats guide.

Assisted-by: Claude:claude-sonnet-5-5

* Find the web tool group by name in the route spec

The tools panel lists the shell group before the web group, so the
spec that read the first entry compared the wrong group. Look the
group up by name, which does not depend on the registry order.

Assisted-by: Claude:claude-sonnet-5-5

* Give Skills its own sidebar item

Skills work in every chat, but the sidebar filed the page under Agents
and only agent skill chips linked to it. Add a Skills item right after
Memory, show it in the legacy list too, and stop lighting up Agents on
the skills pages.

Assisted-by: Claude:claude-sonnet-5-5

* Add Manage skills to the skills picker and a /skills command

The picker now ends with a Manage skills link, and /skills opens the
Skills page from the composer on Home and in a chat. The picker keeps
the height of its status line, so a refused change does not move the
rows. Words are added in all nine locales.

Assisted-by: Claude:claude-sonnet-5-5

* Say on the Skills page that every chat can use skills

The page subtitle named only agents as users of skills. Say that all
chats use them, translate it, and document where to manage skills: the
sidebar item, the picker link and /skills.

Assisted-by: Claude:claude-sonnet-5-5

* Keep Home on screen while its widgets load their strings

A Home widget reads the pages strings, and i18next loads a namespace
the first time a component asks for it, by suspending. Home had no
Suspense boundary of its own, so the route's boundary hid all of Home
(display: none) and showed the route fallback until the strings came.

A widget shows only after home.json is read, so this happened after
the composer was already drawn. Focus given to the composer in that
window went nowhere, and a key typed then was lost. The slash menu
spec failed in CI this way when another spec had written widgets to
the shared home.json: the box was empty and the body had the focus.
With widgets in home.json the composer was hidden right after it
appeared on 5 of 5 loads, and the spec failed 1 or 2 times in 5.

The examples and the widgets now have their own boundary with no
fallback, so only they wait. A new spec holds the pages strings while
it types in the composer; it fails without the change (the composer
never takes the focus) and passes with it.

Assisted-by: Claude:claude-opus-5-5

* Send the stubbed file change after the Files page read the folder

The change feed spec answered the first stream request with the change
at once. The app shell (the appearance sync) opens the shared stream
when the app starts, before the Files page chunk has loaded, so on a
slow runner the change reached a tab where nobody watched the folder
yet and was dropped. The page then never read the folder again, and
the spec failed on all three CI attempts. A CPU throttle of 8 makes it
fail 3 of 3 times locally.

On a real server the page's own first read would already show such a
change. The stub now holds the change until the page has read the
folder; the page subscribes in the same commit as that read, so the
change arrives with a subscriber in place. The resume check is kept.

Assisted-by: Claude:claude-opus-5-5

* Give the oversized 3D input as a sparse file, not a buffer

The spec built a 32 MiB buffer and passed it to setInputFiles.
Playwright sends a buffer to the browser over the protocol: that step
alone took 5.5 s on an idle machine, and on the loaded CI runner the
test hit the 30 s timeout on all three attempts.

It now writes a sparse file of the same size to the test output folder
and passes its path, which the local browser opens as is. The page
still checks the size before it reads the file, so the toast and the
no-request assertion are unchanged. The step takes milliseconds.

Assisted-by: Claude:claude-opus-5-5

* Check the tool line, not the tool name, in the tool call journey

The spec looked for /list_files/ in the transcript. The user's own
message holds that name, so the check could pass before any tool ran.
Once the model's call streamed, a second element matched, and the
strict locator failed (2 matches on the first attempt, 5 on the
retry).

It now waits for the activity line that the tool run adds ("Listed
/"), which only shows after the tool ran.

Assisted-by: Claude:claude-opus-5-5

* Measure the proposal card and its neighbours in one frame

The layout spec read the boxes of the message text, the card and the
message actions with three boundingBox calls. They are viewport
positions taken in three frames, and the transcript scrolls to its end
while the card fills in. On the phone the actions were read after a
scroll and came out 156 px above the bottom of the card.

The three boxes now come from one page.evaluate, so they are read in
the same frame. The order assertions are unchanged.

Assisted-by: Claude:claude-opus-5-5

* Read the page fixture time when the page is served

The task board fixture of the discovery and Pages specs set its
change time once, when the file loaded. A worker loads it once and
keeps running specs, so a spec that ran two minutes later read
"Changed 2 minutes ago by the assistant" where it expects "just now".

The change time is now a getter, read when the fixture is serialized
into a response.

Assisted-by: Claude:claude-opus-5-5

* Check a background process without /proc in the host test

The alive helper read /proc/<pid>/stat, which macOS does not have, so it
reported every process as dead and the spec failed there with "did not
survive the suspend". Use signal 0 for existence and ps (or /proc where
present) to tell a zombie from a live process.

Assisted-by: Claude:claude-sonnet-5-5

* Keep the composer switch menus at their own height

The base rule of the dropdown menu caps it at 320px and lets it scroll.
The switch menus set their own height with a rule of the same weight in a
lazy sheet, so which one won depended on the order the sheets loaded in.
When the base rule won, the picker showed a few rows, and a click on a row
at the edge of the list scrolled the list by a few pixels.

Double the class on the switch menu rules so they win in any order, and
add a test that loads the base rule last.

Assisted-by: Claude:claude-sonnet-5-5

* Show what applies in the chat Network switch

The Network row highlighted the choice stored with the chat, so a
chat that asked for All public sites under an administrator limit of
Off showed All highlighted although it was disabled and not in effect.

Highlight the effective choice, the lower of the chat's choice and the
limit, which the server already computes. When the chat asks for more
than the limit, say so in one line instead of highlighting it. Add a
line that the limit is set by the administrator, and tell the host
network line that limits need the Sandbox add-on.

Assisted-by: Claude:claude-sonnet-5-5

* Design the host components that add-ons reference

The host UI names the Sandbox and the Web add-ons to place their server
settings, and the chat's Network row links to the global Settings page.
Describe a closed registry of host components that an add-on references
by ID in its manifest, entitled by the existing host operation grants
and tool shadows, so the controls live on the add-on's own page and a
second add-on can reuse them with no host change.

Assisted-by: Claude:claude-opus-5-5

* Let an add-on ask for host controls in its manifest

An add-on had no way to say that a host-owned control belongs on its
page, so the host UI placed those controls by add-on name. Add
ui.controls, a list of IDs from a closed registry of host controls:
network-policy (the network of the commands an add-on runs) and
tool-providers (the rows of the built-in tools it replaces).

The host decides which controls it draws: EntitledControls keeps a
control only when the add-on holds its host operation grant and
replaces its built-in tool. The field is optional, so every existing
manifest stays valid.

Assisted-by: Claude:claude-opus-5-5

* List the host controls an add-on is entitled to

The apps list now carries ui.controls with the host controls the
manifest declares and the host setting entitles the add-on to: the
manager passes each add-on's host operation grants to the list.

GET /api/chats/{id}/egress gains addon, the add-on that runs the chat's
commands when it has the network-policy control, so the chat can draw
its Network row from that add-on and link to its settings. applies and
applies_reason keep their meaning: the policy holds with or without the
control. The model and the 400 error no longer send an administrator to
Settings > Tools for the limit.

Assisted-by: Claude:claude-opus-5-5

* Draw the host controls on the add-on's own page

The host UI placed the Sandbox network and the tool rows by add-on name
(SERVER_SETTINGS), showed a read-only summary on the add-on's Settings
tab, and linked the chat's Network row to the global Settings page.

Add a registry of host controls keyed by the IDs of the contract. The
Settings tab of an add-on draws, below its own sections, the controls
the apps list gives it, in one form with the pending bar of the
Settings page: network-policy reuses the network section and its
setting rows, tool-providers the rows of the built-in tools the add-on
replaces. The chat draws its Network row only when the server names an
add-on with network-policy, and the administrator link opens that
add-on's tab. The row itself is unchanged.

The Settings page keeps the settings of features that exist without an
add-on, points to the add-on where one serves them, and no longer
lists the network settings. Its old address opens the add-on's tab.

Assisted-by: Claude:claude-opus-5-5

* Ask for the host controls in the Sandbox and Web manifests

The Sandbox asks for network-policy and tool-providers, so the network
of its commands and the bash row are drawn on its own Settings tab and
its chats get the Network row. The Web add-on asks for tool-providers
only: it holds no egress grant. The manifest tests check that the
default grants entitle each add-on to what it asks for.

The proxy's refusal now sends the model to the Sandbox Settings tab
instead of Settings > Tools.

Assisted-by: Claude:claude-opus-5-5

* Keep the button of the network pointer row at its size

The row used the wide layout of the tool rows, so its Open settings
button stretched across the page. Use the plain setting row, which
puts the button on the right like the other controls.

Assisted-by: Claude:claude-opus-5-5

* Fix the add-on contract inventory and the apps Swagger fixture

The contracts case inventory missed the six host control specs, so
verify.sh rejected the Ginkgo report. List them.

The Swagger spec test never saw `controls` in the emitted UIView JSON
because its fixture left the omitempty field empty. Set it there.

Assisted-by: Claude:claude-sonnet-5-5

* Add the host controls spec to the apps inventory

The apps suite ran one more spec than scripts/addons/suites.json
listed, so verify.sh rejected the apps report as an inventory
mismatch. List the new spec in the report inventory and in the
suite's aggregate case list. Every other Ginkgo suite was re-run
and matches its inventory.

Assisted-by: Claude:claude-sonnet-5-5

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-01 12:08:23 +01:00

126 KiB

+++ disableToc = false title = "Chats" weight = 21 url = "/features/chats/" +++

Chats are conversations that the server keeps. You can keep typing while the model works, the model can hand work to sub-agents, long conversations are compacted for you, and the model can use the MCP servers of its model configuration.

The chats API sits beside the OpenAI-compatible /v1/chat/completions endpoint, which does not change. Use /v1 when a client brings its own history and needs the OpenAI format. Use /api/chats when the server should keep the conversation.

{{% notice note %}} Chats need the chats feature. It is on for every user by default. An administrator can switch it off for one user in the user's permissions. Saved agents, triggers, connectors and agent jobs are separate features that are off by default; see Permissions. {{% /notice %}}

Permissions

With authentication on, an administrator grants features to each user in Settings → Users and keys. Plain chats are on by default. The parts that run without a person, or reach the outside world, are off by default for a user, and an administrator always has all of them:

Feature Default for a user What it allows
chats on Chats, files, skills, notifications, push, your MCP connectors. A chat needs no more.
agents off Saved agents (/api/chats/agents) and starting a chat with one.
triggers off Scheduled and file triggers (/api/chats/triggers): runs that start by themselves.
connectors off Telegram and Slack bindings (/api/chats/connectors): a public door to one of your agents.
mcp_jobs off The agent tasks and jobs API (/api/agent/...).
pages off The pages of the workspace (/api/chats/pages...): HTML pages in pages/ of the home, shown in a sandboxed frame. See [Pages]({{%relref "features/pages" %}}).

A command-line MCP server (stdio) in your MCP connectors stays for administrators only, whatever the features. A trigger or a connector whose owner loses its feature stops running: a trigger run is skipped with the reason, and the bot says it is not available. The owner can get the feature back without losing the definition.

Upgrading. On the first start after the features were split, a one-time rule keeps what each existing user was using, and nothing else. The rule runs once; a user who registers later gets the defaults. A value an administrator stored always wins.

  • agents: kept if the user has the stored agents permission (the one the old agent pool had) or owns a saved agent.
  • triggers: kept if the user owns a trigger, or had agents or mcp_jobs.
  • connectors: kept if the user owns a connector binding, or had agents.
  • mcp_jobs: stored as it was.

Items that the conversion of the old agents makes after that first start belong to users who had the old permissions, which the rule already read.

What an administrator can see

An administrator sees what every user has set up, each with its owner, and can stop it. The view is read-only apart from switch-off and delete, and never shows a bot token or an agent's instructions:

Route What it does
GET /api/chats/admin/agents Saved agents of every user
GET /api/chats/admin/triggers, GET .../triggers/{id}/runs Triggers with their last run, and the runs of one
GET /api/chats/admin/connectors Connector bindings
POST .../admin/triggers/{id}/disable, POST .../admin/connectors/{id}/disable Switch one off
DELETE .../admin/agents/{id}, .../admin/triggers/{id}, .../admin/connectors/{id} Delete one
/api/chats/admin/shared-folders... Create shared folders and choose who may use them (see Shared folders)

The agent tasks and jobs have their own view: ?all_users=true on GET /api/agent/tasks and GET /api/agent/jobs (see Agent tasks and jobs).

Switch the chats off

The operator can switch the whole surface or one part off. A part that is off starts nothing: no loop, no trigger runner, no bot. GET /api/features reports what runs (chats, agents, triggers, connectors).

Setting Default Effect
LOCALAI_DISABLE_CHATS false Turns off the chats: the /api/chats API, saved agents, triggers, connectors, notifications and the agent jobs (/api/chats/*, /api/workspace/* and /api/agent/* answer 404 with the type chats_off). The files workspace of the add-ons is not affected.
LOCALAI_DISABLE_TRIGGERS false Turns off the trigger runner. Trigger routes answer 409 triggers_off.
LOCALAI_DISABLE_CONNECTORS false Turns off the connector bots. Connector routes answer 409 connectors_off.
LOCALAI_DISABLE_AGENTS false Deprecated. It kept the meaning it had in the old agent pool, which also held the knowledge base: it sets LOCALAI_DISABLE_CHATS and LOCALAI_DISABLE_COLLECTIONS, and logs a warning.

LOCALAI_DISABLE_COLLECTIONS stays separate and turns off only the collections.

Start a chat and send messages

# A chat talks to one model, chosen when it starts.
curl -s localhost:8080/api/chats \
  -H 'Content-Type: application/json' \
  -d '{"model": "my-model"}'
# {"id":"3f1c...","model":"my-model","running":false,...}

curl -s localhost:8080/api/chats/3f1c.../messages \
  -H 'Content-Type: application/json' \
  -d '{"text": "Summarize the three files I mentioned."}'
# {"started":true}

# Rename a chat. This answers 409 while a turn runs.
curl -s -X PATCH localhost:8080/api/chats/3f1c... \
  -H 'Content-Type: application/json' \
  -d '{"title": "Trip plans"}'

The reply is not in that response. Read it from the event stream or from the transcript:

# Live server-sent events: text, reasoning, tool_start, tool_end, turn_end, files, ...
curl -N localhost:8080/api/chats/3f1c.../events

# The transcript, in order. Read it first, then open the event stream: the
# stream carries only what happens after you connect.
curl -s 'localhost:8080/api/chats/3f1c.../messages?after=0'

The transcript lists messages, tool lines and one checkpoint line per turn. Undo works per tool call, not per turn: each write_file, edit_file or move_file call is its own change set in your files, and its tool line carries a card {"kind": "files_change", "id": "<rev>:<path>"} (delete_file moves to the Trash, where you restore it). POST /api/chats/files/undo {"rev": N} undoes that change set; the chat page uses this for the Undo of its change notes (see Make LocalAI yours). To undo a whole turn, undo the changes of its tool lines, newest first. The checkpoint line marks the end of the turn; its rev is 0 and its text is empty, because the turn's changes are already kept call by call.

A chat is named after the first line of its first message, unless you gave it a title. To rename it, see Settings, rename and model change.

Start a chat with its switches

A chat can start with tools, add-ons and skills already switched off, and with opt-in tools switched on. switches in POST /api/chats takes each list in the form its own endpoint takes (see Switch tools off for one chat and Skills). The chat is stored with them, so its first turn already has them. A bad entry refuses the whole request with 400, and no chat is created.

curl -s localhost:8080/api/chats -H 'Content-Type: application/json' -d '{
  "model": "my-model",
  "switches": {
    "addons_off": ["office"],
    "mcp_off": ["crm", "connector:c1"],
    "builtins_off": ["web"],
    "builtins_on": ["saved_agents"],
    "skills_off": ["builtin:pdf"]
  }
}'

To show these switches before the chat exists, read what a chat on a model (and a saved agent of yours) could use. Each switch is in its default state:

curl -s 'localhost:8080/api/chats/draft/tools?model=my-model'
curl -s 'localhost:8080/api/chats/draft/addons?model=my-model&agent_id=<agent>'
curl -s localhost:8080/api/chats/skills

The answers have the shapes of GET /api/chats/{id}/tools and GET /api/chats/{id}/addons. A model you may not use is 403, a model the server does not have is 400, and another user's agent is 404.

Keep typing while the model works

Send another message while a turn runs and it waits. The response says so:

{"started": false, "pending_id": "p2"}

Waiting messages reach the model together, in the order you sent them, at its next request. A waiting message shows the files you attached to it as the same chips a sent message has. Until it is delivered you can change or remove it:

curl -X PATCH  .../api/chats/3f1c.../pending/p2 -d '{"text": "Use euros."}'
curl -X DELETE .../api/chats/3f1c.../pending/p2

Once a message has reached the model, both calls answer 409. A pending_delivered event tells you when that happened.

If the turn ends normally before a waiting message was delivered, that message starts the next turn. After a stop or a failed turn, nothing starts by itself: the messages stay, and you decide.

curl -X POST .../api/chats/3f1c.../stop       # end the running turn
curl -X POST .../api/chats/3f1c.../send-now   # start a turn from the waiting messages

Loops: repeat a prompt in a chat

A loop sends a prompt to its chat again and again, as a new message, until you stop it. Type /loop in the message box, or use the API.

/loop 5m Check the build and tell me when it is green.   every 5 minutes
/loop Watch the deploy and tell me when it is done.      self-paced
/loop list                                               the loops of this chat
/loop stop                                               stop every loop of this chat
/loop stop 3f9a01                                        stop one loop (the code that /loop list shows)
  • With an interval (30s, 5m, 2h, 1h30m) the prompt is sent at each interval. The shortest interval is one minute unless an administrator changes it (chats_loop_min_interval, never below 30 seconds); the longest is 24 hours.
  • Without an interval the loop is self-paced: after each run the model calls schedule_next with how long to wait (from the minimum interval to 24 hours) and a short reason, or loop_done when the task is complete. A run in which the model calls neither ends the loop with a note, so a loop never goes on silently. The chat has these two tools only while it has a self-paced loop.
  • The prompt is plain text, or a skill: /<skill-name> (or /skill <name>) and its words. Each run sends it as that skill command would: the transcript shows the words and the skill's chip. Other slash commands cannot repeat. A skill that is gone or switched off at a run makes that run fail.
  • The first time in a chat a card asks Run this every 5 minutes until you stop it? with Start and Cancel, because every run costs tokens. The card stays with the chat, so it is still there after a reload. Once you start one loop in a chat, later loops there start at once.
  • What you see. Each loop shows as a line above the message box, such as Loop · every 5 min · run 4, with Pause and Stop. A message that a loop sent has a small Loop run 4 label, so you can tell it from one you typed. A loop that ended by itself stays as a line with the reason until you dismiss it.
  • Busy chats. A run starts only when the chat is idle. When you or another run keeps the chat busy at the due time, that run is skipped, and the loop waits for its next interval instead of piling runs up. A self-paced loop runs as soon as the chat is free.
  • Limits. A chat has at most 3 loops and a user at most 10 (chats_loop_max_per_chat, chats_loop_max_per_user; ended loops do not count). A loop ends after 100 runs (chats_loop_max_runs). One user starts at most 200 loop runs a day, over all loops (chats_loop_daily_runs, UTC days); after that the loops wait for the next day. A loop pauses itself after 3 failed runs in a row; Resume starts it again. A run has the same time limit as a trigger run (15 minutes).
  • Stop removes a loop. It does not stop a run that goes on; the chat's stop button does that. Deleting a chat removes its loops.
# Start a loop (no interval: self-paced). The first loop of a chat is pending.
curl -X POST .../api/chats/3f1c.../loops -d '{"interval": "5m", "prompt": "Check the build."}'
# {"id":"9b2e...","prompt":"Check the build.","interval":"5m","interval_seconds":300,
#  "state":"pending","runs":0,"running":false,"created":"..."}

# Confirm it (state active), pause it, or resume it.
curl -X PUT .../api/chats/3f1c.../loops/9b2e... -d '{"state": "active"}'
curl -X PUT .../api/chats/3f1c.../loops/9b2e... -d '{"state": "paused"}'

# List, stop one, stop all.
curl .../api/chats/3f1c.../loops
curl -X DELETE .../api/chats/3f1c.../loops/9b2e...
curl -X DELETE .../api/chats/3f1c.../loops

A loop's state is pending (waits for your yes), active, paused or ended; reason says why it paused, ended or waits, next when it runs next, and wait what the model of a self-paced loop waits for. GET /api/chats/{id} carries the same list as loops, a loops event tells every open tab that the list changed, and a user line that a loop sent carries loop: {id, run} in the transcript.

Loop or trigger?

Both run a prompt without you typing it. Choose by where the answers should go and how often:

Loop Trigger
Where it runs In the chat where you started it, which keeps its history A new chat for each run
How often From every minute (or as the model decides) At most once an hour, on a schedule, or when a file arrives
How long Until you stop it, it reaches its run limit, or the model says it is done Until you switch it off
Good for Watching something now: a build, a deploy, a long job Routine work: a morning summary, filing new documents

In a cluster. State mode: single-runner. The loops live in the shared store with the chats (the auth database). One frontend at a time fires them: the one that holds a PostgreSQL advisory lock, as for triggers. A run is claimed in the store before it starts, and the turn claim of the chat keeps one turn per chat, so two frontends never start the same run. After a restart, or when another frontend takes over, the loops go on from the store, and a run that a dead frontend left behind is finished and counted as failed. A loop run starts on the frontend that fires it; it is not sent to a chat worker.

Settings, rename and model change

A chat keeps its own model settings. They apply from the next turn, and they are part of the chat: a fork and an export carry them.

curl -X PATCH .../api/chats/3f1c... -H 'Content-Type: application/json' -d '{
  "title": "Trip plan",
  "model": "another-model",
  "settings": {"system_prompt": "Answer in short sentences.", "temperature": 0.3,
               "top_p": 0.9, "top_k": 40, "reasoning_effort": "low", "context_size": 16384}
}'
  • system_prompt goes before the instructions of a saved agent.
  • temperature (0 to 2), top_p (above 0, at most 1), top_k and reasoning_effort (none, minimal, low, medium, high) go to the model with every request of the chat. A missing field means the model's own default. settings replaces all of them, so send the whole object; {} clears it.
  • context_size is the window, in tokens, that the loop plans with when it decides to compact the conversation. It does not reload the model.
  • A new title (1 to 80 characters) or model needs a chat with no turn running (409 otherwise). The history stays when the model changes.

Context meter and speed

Each model request sends a usage event with used (tokens in the context the request sent), limit (the window the loop plans with, when known), completion (tokens generated), tokens_per_second and prompt_ms (the time the backend took to read the prompt; both only when the backend reports timings), model (the model of the request) and node (the worker that served it, only when the server runs with --expose-node-header).

The status line

The line under the composer shows what the chat is doing now, as one word: Ready, Loading model, Thinking, a tool step (for example Reading q2.pdf), Answering, Waiting for you (messages wait for Send now), Paused, Stopped, Failed or Interrupted. A spinner marks a running turn, and an icon marks a state that needs your attention. While the composer has focus and is empty, the line also shows the keyboard hint.

Paused means that the turn used all its steps: the model made as many rounds of tool calls as one turn allows (chats_max_iterations, 40 by default) and then wrote an answer from what it had, so the work may not be finished. The line says how many steps the turn used (for example Paused · after 40 steps) and shows a Continue button. Continue sends a message that asks the model to go on from where it stopped; you can also type your own. A turn that pauses is otherwise like one that ended by itself: messages that wait start the next turn, and a sub-agent that pauses reports back. The API reports it as last_turn: "paused" and sends a paused event with steps before turn_end.

On the right, the context meter shows how full the model's context was at the last request: a bar, the tokens used and the limit (for example 12.3k / 32.8k tokens), the tokens left (20.4k left) and the speed of that request. Two ticks on the bar mark 80 % and 90 %. While an answer streams, the speed is an estimate (shown with ≈); the measured figure replaces it when the request ends. From 80 % the meter turns amber, from 90 % red, both with a warning icon. When the server does not know the limit, the meter shows only the tokens used, with no bar. Where the line is narrow, the percentage replaces the used and limit figures; narrower still, the speed moves to the details. Select the meter to see:

  • the context used, the limit and the tokens left, and what happens near the limit (the server compacts the older part of the chat),
  • the model, its backend and, when the server runs with --expose-node-header, the node that served the request,
  • the tokens in and out, the time to the first token, the prompt processing time and the speed, with the average of the last requests.

The figures come from the usage events of the page's event stream. The server does not keep them, so they are empty after a reload until the next request. On a phone the line shows the word, the bar, the percentage and the tokens left, and the details open in a sheet.

Goals

A goal is an objective that a chat keeps working toward over many turns, for example "Turn the notes in /Research into a two-page summary and check every figure". While a goal is active, the chat does not wait for you to say "continue": when a turn ends by itself, the server starts the next one.

Set a goal with the /goal command in the composer, or with the API:

Command What it does
/goal <text> Sets the goal. It replaces the goal the chat had, and the chat shows what it replaced. With no turn running, a turn starts on the goal at once.
/goal Shows the goal: its text, its state and how many turns and steps it used.
/goal clear Removes the goal. A running turn goes on, and nothing follows it.
/goal resume Resumes a paused or blocked goal.

While the goal is active:

  • Every turn's instructions name the goal, and the model gets two tools: goal_done (the goal is reached, with a one-line summary) and goal_blocked (the model cannot go on without you, with the reason and what it needs). The tools exist only while a goal is active.
  • When a turn ends by itself, the server sends the next turn Continue toward the goal: "<text>". The transcript shows it as a goal line, not as a message from you.
  • Your messages still steer the work. A message you send while a turn runs reaches the model at its next step, and messages that wait run before the next automatic turn. Turns that you start do not count as automatic turns.
  • Above the composer, a line shows the goal with Pause (or Resume) and Clear. The status line shows the progress, for example Goal · turn 3 of 20.

The goal has one of four states:

State Meaning
active The chat works toward the goal.
paused Nothing goes on by itself. The goal pauses when you press Stop or Pause, when a turn fails, when the server stops during a turn, and at the limits below. Resume (or /goal resume) goes on with new counts.
done The model called goal_done. The transcript shows Goal reached with its summary. Set a new goal to go on.
blocked The model called goal_blocked. Answer it, then resume the goal.

Two runtime settings in Settings > Tools limit a goal: chats_goal_max_turns (20 by default) is the most turns the server starts by itself for one goal, and chats_goal_max_steps (400 by default) is the most model steps that the turns of one goal make together. A goal that reaches a limit pauses, and resuming it starts new counts. The step limit of one turn (chats_max_iterations) still applies to each turn.

A goal belongs to a chat that you started. A sub-agent, and a chat that a trigger, a connector, a job or a page started, cannot have a goal and never gets the goal tools.

# Set a goal. With no turn running, a turn starts on it at once.
curl -X PUT http://localhost:8080/api/chats/$ID/goal \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"text":"Turn the notes in /Research into a two-page summary"}'
# {"goal":{"text":"Turn the notes in /Research into a two-page summary","state":"active",
#  "turns":0,"max_turns":20,"steps":0,"max_steps":400,"set":"...","updated":"..."}}

# Read it. GET /api/chats/$ID also carries it as "goal".
curl http://localhost:8080/api/chats/$ID/goal -H "Authorization: Bearer $KEY"

# Pause it (Stop pauses the goal), resume it, clear it.
curl -X POST http://localhost:8080/api/chats/$ID/stop -H "Authorization: Bearer $KEY"
curl -X POST http://localhost:8080/api/chats/$ID/goal/resume -H "Authorization: Bearer $KEY"
curl -X DELETE http://localhost:8080/api/chats/$ID/goal -H "Authorization: Bearer $KEY"

A paused goal has paused_by: user, turn_limit, step_limit, error or interrupted. A done goal has the model's summary in note, and a blocked goal has its reason there. The event stream sends a goal event when the goal changes (its text is the new state, empty when the goal was cleared), so every open tab reads the chat again. The transcript has goal lines with role goal and goal_event set to set, replaced, continue (with goal_turn), paused, resumed, done, blocked or cleared. Goal lines are not exported and not copied by a fork.

Several frontends. The goal is stored with the chat record in the shared store, so every frontend reads the same goal and nothing about it is kept in memory. The next turn of a goal goes through the same per-chat turn claim as any other turn: only the frontend that ran the turn starts the next one, and a message from you, or a resume on another frontend, never starts a second turn. If a frontend stops during a turn, the turn is reaped as interrupted and the goal pauses, so a restart never continues a goal twice.

Regenerate, edit, fork, search, export

# Answer the last message again. Your messages of that turn stay.
curl -X POST .../api/chats/3f1c.../regenerate
# Replace one of your messages and run the chat again from it. The messages
# after it are removed. seq is the line number in the transcript.
curl -X POST .../api/chats/3f1c.../messages/12/edit -d '{"text":"A better question"}'
# Copy the chat up to and including line 14 into a new chat.
curl -X POST .../api/chats/3f1c.../fork -d '{"from_seq":14,"title":"Other idea"}'
# Find chats by title or by what was said (newest 500 chats).
curl '.../api/chats/search?q=invoice'
# Take a chat out as Markdown (default) or JSON, and bring a JSON file back in.
curl '.../api/chats/3f1c.../export?format=json' > chat.json
curl -X POST .../api/chats/import -d "{\"chat\": $(cat chat.json)}"
  • Regenerate and edit remove the lines after the cut point from the transcript and from what the model sees. Files that the removed turns changed stay changed, and tool calls of the kept part are not replayed to the model, only the messages.
  • A fork has the model, the settings and the tool switches of the original. It has no folder and no files of its own until you attach some.
  • The JSON export has version, title, model, settings and messages (role, text, at, and for a message of yours with files attachments, with the path of each file, and note, the text the model read from them). It holds no files and no model state. The Markdown export and a copied chat name the files of a message, not their paths. An import makes a new chat of yours with the model of the file, or the one named in model, which you must be allowed to use.

Images, video and documents

Attach files with multipart/form-data on POST /api/chats/{id}/messages (see above). In the chat page, one Attach file button takes files of any type (images, audio, documents, archives such as .zip); you can also paste an image or drop files on the chat. What the model gets depends on the file and the model:

  • Images. When the chat's model can look at images (a multimodal projector, vision in known_usecases, or image in known_input_modalities) the image goes to the model inside the message, as an image_url part, and the model answers about it directly. A model behind a cloud proxy cannot be asked what it accepts: declare vision in its configuration (see [Cloud passthrough proxy]({{% relref "operations/cloud-proxy#images" %}})). With a model that cannot look at images, the model gets the file path and the describe_image tool.
  • Video. mp4, webm, mov, mkv and avi files go to a model that declares video input as a video_url part, with the next request after the message and for that turn only. Other models get the path only.
  • PDF, Word, Excel, PowerPoint and EPUB. The text of the file is added to the message, the first 20,000 characters of each document, and the model reads the rest with the read_document tool (offset and limit). No helper model is needed. A workbook (.xlsx, .xlsm) comes sheet by sheet, each sheet as a heading and its rows as tab-separated lines, up to 2,000 rows and 50 columns per sheet; a note says what a cut sheet leaves out. A Word document gives its headings (#), list items, paragraphs and tables (one tab-separated line per row), and a presentation its slides, each with its title, text and speaker notes. A PDF with no text layer is reported as a probable scan; an Office file with no text is reported as empty.
  • Audio. A voice note is transcribed when a transcription model is set, as before.

The files are always kept in the chat's folder too. Images over 20 MB, videos over 100 MB and more than 128 MB of media in one message stay files only.

Images and vision

  • Size. An image whose longest side is more than 2048 pixels is scaled down to 2048 before it goes in the message. An opaque image goes as a JPEG, an image with transparency as a PNG. The file in the chat's folder is not changed.
  • Only with its own message. The images of a message go to the model with every request of that turn, tool calls included. The next messages do not send them again: in the history, each earlier image becomes a short note that says so. To look at an earlier image again, the model uses describe_image on its file. This keeps a few photos from filling the context, and a cloud model from billing them on every turn.
  • What the model is told. A chat with files gets an Images: block in its instructions, after the Commands: block. It says that the model sees the attached images and answers about them directly, or that it cannot see images and uses describe_image, or that no model here can look at images: then the model tells the user so and suggests choosing a model that can.
  • describe_image. The tool uses the first model of this list: the Chat image model setting, the chat's own model when it can look at images, the first installed model that can, and last the chat's own model when it is a passthrough cloud-proxy model with no vision declaration. LocalAI cannot ask such an upstream what it accepts, so the tool tries it. If the model refuses the image, the tool says that the model did not accept it and what to do: turn on "This model can look at images" in the model's settings, choose another model, or ask an administrator to set a Chat image model. A local model that cannot look at images is not tried. With no model to try, it fails with "No vision model: choose a model that can look at images for this chat, or an administrator can install one from the model gallery and choose it in Settings under Chat image model."
  • In the chat page. When you attach an image and the chat's model cannot look at it, a notice over the message box says so and names the model that will describe the image, or says that no model can. GET /api/chats returns that model as image_model; the field is left out when there is none.

The files of a chat

Each chat lists the files that belong to it: the files you attached, the files the model created, and the files it changed. The model gets the same list at every turn, so it knows which files exist and where they are now.

  • Where attachments live. In the chat's own folder in your files: /Chats/<date> <title>/<name>, for example /Chats/2026-10-29 Budget/q1.csv. The model sees the same file as /workspace/home/Chats/2026-10-29 Budget/q1.csv. The folder is made at the first attachment.
  • What is listed. Every file in the chat folder, and every file that the chat's turns created or changed elsewhere in your home folder: with bash, or with a file tool such as write_file. The list is read from the history of your files, so a file that you or the model moved or deleted later is not listed at its old path.
  • Rename. Renaming a chat renames its folder to match the new title, and the list gives the new paths at once. Earlier messages keep the old paths. The model is told at its next turn that the folder was renamed and which path is current.
  • Delete. Deleting a chat removes its transcript and its sandbox. Its folder (with its work folder) and every file the model wrote stay in your files.
  • Fork. A fork has no folder of its own until you attach a file. The files that the copied messages attached stay in the original chat's folder; the fork lists them while they are there.

To follow the changes to your files as they happen (from any tab, chat, trigger or frontend), open the change feed (GET /api/workspace/events).

curl .../api/chats/3f1c.../files
# {"files":[{"name":"q1.csv","path":"/Chats/2026-10-29 Budget/q1.csv",
#   "guest_path":"/workspace/home/Chats/2026-10-29 Budget/q1.csv","size":2048,
#   "mime":"text/csv","origin":"user","turn":1,"at":"..."},
#  {"name":"report.md","path":"/report.md","guest_path":"/workspace/home/report.md",
#   "size":930,"mime":"text/markdown","origin":"model","turn":2,"at":"..."}],
#  "folder":"/workspace/home/Chats/2026-10-29 Budget/","folder_path":"/Chats/2026-10-29 Budget"}

origin is user (you attached it, or put it in the chat folder), model (a command of the model wrote it) or tool (a tool of the chat wrote it, such as write_file). changed marks a file the chat changed but did not create. media marks an attachment that the model received as an image or a video inside the message; the others reach the model by path. path works with the files API above. stale lists old folder paths that earlier messages name. A files event on the chat's event stream says that the list may have changed; read it again.

Files that a command saved

A command of bash (the Sandbox's, or the built-in one on this machine) saves what it writes in your home folder as one change of the chat, the same way write_file does. The chat then shows those files where it shows a file tool's:

  • Under the step. The bash step lists the files the command created or changed ("Saved to your files"), one chip each; a chip opens the file in the preview pane. A file the command deleted is listed by name, struck out.
  • In the pane and under the answer. The files are in the chat's file list above, with the turn that made them, so they get a chip under the answer and the preview pane opens by itself on the newest one (a Word, Excel, PowerPoint or PDF file, an image or a text file), as it does for a file of write_file.
  • In the transcript. The tool line of the call has a card with files: [{"path":"/report.docx","rev":42,"op":"created"}], where path is inside your home, rev is the change set (undo it with POST /api/chats/files/undo) and op is created, changed or deleted. At most 50 are listed; files_more counts the rest. The card keeps its own kind and id (who served the call: tool_provider for the Sandbox, host_exec for this machine).

Only what the command saves under /workspace/home (or a writable shared folder) is kept. bash starts in /workspace/home, so a relative path such as report.docx is in your home. A file written anywhere else, such as /workspace/report.docx or /tmp, is not saved, and the result of the call says so by name. When you or a file tool changed a file while the command ran, your version stays and the command's version is saved as a copy; the result names the copy's path.

The list of files comes from the change sets of the chat in the shared store, read after each call, so every frontend of a cluster shows the same files. Nothing about it is kept in memory.

The list that the model gets has the newest 50 files, one line each with the path, size, type, origin and turn, and a count of the rest. A chat with no files gets no list. The list is built from the stored history when a turn starts, so every frontend of a cluster gives the same one.

Working files and the work folder

A chat that makes a document often writes a script, some intermediate data or a log on the way. These working files must not mix with your own files, so every chat has a work folder: /Chats/<date> <title>/work/ in your files (/workspace/home/Chats/<date> <title>/work/ for the model), next to the chat's attachments. It is a normal, visible folder.

  • What the model is told. At every turn the prompt names the chat's work folder and says: scripts, helper code, intermediate data, logs, drafts and anything else you did not ask for go there; the file you asked for (the document, the spreadsheet, the image) goes where you said, or at the top of your home, and nowhere else; no new top-level folders for scripts; a command that runs once needs no file (python3 -c, a heredoc). The descriptions of bash and write_file and the built-in docx, xlsx, pptx, pdf, localai-apps and localai-workspace skills say the same. Pages (pages/<name>/) and the LocalAI/ settings folder are not working files.
  • When the folder is made. The chat takes the name of its folder at its first turn, but makes the folder only when it is used: before a bash command runs, or when a file is saved there. A chat that saves nothing leaves no folder behind. bash still starts in /workspace/home.
  • A gentle nudge, no refusal. When write_file, move_file or a command leaves a helper file (see below) outside the work folder, its result gets one line that suggests moving it there, at most once a turn. Nothing is refused or moved: you may have asked for that script.
  • Delete. Deleting a chat keeps its folder, with the work folder and the attachments in it, like every other file the chat wrote.

Each file of the chat's list has a kind: deliverable or working. A file is working when it is in the work folder of any chat (/Chats/<chat>/work/...), or when it looks like a helper: the extension .py, .sh, .js, .ts, .mjs, .rb, .pl, .ps1, .bat, .log or .tmp, or a folder named scripts, tmp or scratch on its path. Files of a page, of the LocalAI/ folder or of a shared folder, and the files you attached, are always deliverable. The server decides the kind in one place and puts it on the file list (kind), on the files of a command's card (files[].kind) and on the card of a file tool (file_kind).

curl .../api/chats/3f1c.../files
# {"files":[{"name":"summary.docx","path":"/summary.docx","kind":"deliverable",...},
#  {"name":"build_document.py","path":"/Chats/2026-10-31 Summary/work/build_document.py",
#   "kind":"working",...}], ...}

In the chat:

  • The Files pane lists what the chat made for you first. The working files are in a group of their own under it, Working files · N, folded and quieter; open it to see and preview them. The files you attached are listed as before.
  • The preview that opens by itself after a turn shows the newest file that is not a working file. A working file never opens it by itself.
  • The chips under the answer name only what the chat made for you.
  • A step that saved working files says so in one line ("Also saved 2 working files"); click it to see their chips.

An administrator can turn the steering off with Work folder for helper files (chats_work_folder, Settings > Tools). The prompt then names no work folder and no nudge is added; the pane still shows working files apart. The nudge is counted per process: a turn runs on one process, so nothing is shared, and a restart only lets the line come once more.

Sub-agents

The model can start a sub-agent for a self-contained task. By default it waits for the answer. With wait set to false it carries on, and the result arrives later as a message in the conversation and as a sub_agent_done event.

  • A sub-agent is its own conversation, linked to its parent. It appears in the list with kind set to subagent and a parent_id.
  • A sub-agent gets the parent's tools, or a subset the parent names. It cannot start sub-agents of its own.
  • A chat runs at most four sub-agents in the background at once. The count comes from the shared store, so it holds when turns of the same chat run on different frontends of a cluster.
  • Stopping a chat, or a turn that fails, stops its running sub-agents, also when they run on another frontend and when the parent has no turn left.
  • A sub_agent_started event (with the run's id and title) and a sub_agent_done event mark the run in the parent's event stream.
  • A sub-agent cannot run commands when its parent cannot (see Running commands). A task that asks to run something ("execute the script", "run make_deck.py") is then refused at once with "the sub-agent has no way to run commands either", and no sub-agent starts.
  • A task with the same words as an earlier sub-agent of the chat is refused while no file of the chat was created or changed since that run: it would answer the same. The check reads the store, so it holds on every frontend of a cluster.
curl .../api/chats/3f1c.../agents                      # runs, their state, the limit
curl .../api/chats/3f1c.../agents/9ab2.../messages     # one run's transcript
curl -X POST .../api/chats/3f1c.../agents/9ab2.../stop # stop one run

Saved agents as sub-agents

A chat can also start one of your [saved agents]({{%relref "features/chats-agents#saved-agents" %}}) as a sub-agent. This is off for every chat until you switch Saved agents on in the chat's Tools menu. The chat then has two more things:

  • list_agents lists your saved agents: id, name, description and model, at most 100. It lists only your own agents.
  • spawn_agent takes an agent argument, the id or the name (ignoring case) of one of them. The sub-agent runs with the agent's instructions, its model (or the chat's model when the agent names none) and only what the agent grants, the same as a chat started with that agent. tools cannot be given together with agent. The run is listed with the other sub-agents, with the agent's name in its title.

The sub-agent cannot start sub-agents of its own and never gets list_agents. Each call checks again that the chat has the switch on, that an administrator left chats_saved_agents on, and that you have the agents feature, as the agents API does. When one of them fails, or the agent does not exist or is another user's, the call fails with a message that says why and no sub-agent starts. Without the agents feature the switch is shown greyed out with the reason.

curl -s -X PUT localhost:8080/api/chats/$ID/builtins -d '{"off":[],"on":["saved_agents"]}'

on lists the opt-in groups that are on and replaces the earlier list; omit it to keep them. An opt-in group in off is ignored. The group in GET /api/chats/{id}/tools has opt_in: true, and usable and reason say whether it can serve the chat now.

Running commands

A chat runs commands and code with one tool, bash. Who serves it depends on the server:

  • The Sandbox. With the [Sandbox add-on]({{%relref "features/addons#sandbox" %}}) installed, running and on for the chat, bash runs in an isolated sandbox. The add-on's bash replaces the built-in one (see [Tools that replace a built-in tool]({{%relref "features/addons#tools-that-replace-a-built-in-tool" %}})), so the chat never has two.
  • This machine. Otherwise, when an administrator allows it, the built-in bash runs commands directly on the machine that runs LocalAI, with no isolation. This covers a server with no Sandbox, a chat that switched the Sandbox off, and a Sandbox that is stopped or cannot run.
  • Nothing. With neither, nothing in the chat runs code.

At every turn the model is told in one short block which of the three holds. With host commands the block says that bash runs on this machine with no isolation and with its network, and tells the model to be careful: no destructive or data-sending commands outside the task unless you asked. When nothing runs, the block says that the model must not say or imply that it ran something, nor send a sub-agent to run it; it writes the result as a file (text, CSV, Markdown, HTML, JSON) or gives you the script, and tells you how to get commands.

A skill can say that it needs commands with requires: [exec] in its frontmatter (see Skills). In a chat that cannot run commands, such a skill is listed to the model as "unavailable here: needs command execution", without the path to its procedure, so the model explains instead of starting.

Network of the Sandbox commands

The Sandbox has no network device: its commands reach HTTP and HTTPS only through a proxy that asks LocalAI about each connection (see [Network access]({{%relref "features/addons#network-access" %}})). Each chat has a Network row in its Tools menu with three choices:

  • Off: commands reach nothing.
  • Package registries: commands reach the package registries only (PyPI, npm, Go modules, crates.io, RubyGems, Maven Central and GitHub releases), so pip install, npm install, go get and cargo build work.
  • All public sites: commands reach every public host.

The highlighted choice is what applies: the lower of the choice of the chat and the limit. When a chat asks for more than the limit (for example All public sites with the limit Off), the row shows the limit highlighted and says in a line what the chat asked for. Where commands run on the server's own machine, the row is a line saying they use its network; network limits need the Sandbox add-on.

A choice above the limit an administrator set (chats_egress_ceiling) is greyed out with Limited by your administrator. When the limit is off (the default), the row says Off: the administrator has not allowed network access and gives an administrator a link to the Settings tab of the add-on that runs the commands, named by its title; anyone else is told to ask one. The model is told the same, so it reports that the server's setting blocks the network instead of waiting for an approval. A new chat starts with the administrator's default. A fork keeps the choice and the allow rules of the chat it copies. The choice and the rules live with the chat on the server, never in your files, so the code that runs in the Sandbox cannot change them. Rules and requests under the row lists the hosts this chat allowed or denied, with a button to remove each, and the requests that asked. When an administrator keeps a list of always-allowed hosts (chats_egress_allowed_hosts), the row says how many there are and the panel lists them, read only, under Always allowed by your administrator. They are reachable whenever the network of the chat is on (Package registries or All public sites), without a card; with the network off they wait until you turn it on.

The model knows that curl, pip, npm and git work in the Sandbox within this setting. It prefers web_fetch and web_search to read or search web pages when the chat has them, and uses curl for APIs, downloads and installs.

When a command asks for a host the chat does not allow but the limit would, the chat shows a card: A command in this chat wants to reach host:port, with Allow once (for the rest of that command), Allow for this chat and Deny. The command waits up to 60 seconds for your answer and goes on by itself after it. The card stays with the chat, so a reload shows it; once answered it becomes one line under the bash call that asked, such as Allowed for this chat by you, 14:02. A host inside your network needs an administrator to allow it. If the limit was lowered while the card waited, the card has no buttons: it explains that network access is off (or limited) on this server and who can change it, and it ends as Refused: the server's network limit does not allow it. A trigger, a connector run or a sub-agent never shows a card.

When commands run on this machine instead, the row is one line, Commands run on this machine and use its network: these settings do not apply there.

The Network row is drawn by LocalAI for the add-on that runs the chat's commands, when that add-on has the network-policy [host control]({{%relref "features/addons#host-controls" %}}) (the Sandbox has it). With an add-on that runs commands but does not have it, the Tools menu has no Network row; the limit and the default still apply to its commands.

The routes, for the chat's owner (another user's chat is 404):

Route What it does
GET /api/chats/{id}/egress {preset, ceiling, effective, applies, applies_reason, addon, registries, allowed_hosts, rules, cards}. applies is false when commands run on this machine (applies_reason host) or no Sandbox serves bash (none). addon is the add-on that runs the commands and draws the network controls of the chat (it has the network-policy [host control]({{%relref "features/addons#host-controls" %}})); it is absent when applies is false or that add-on has no such control, and the chat then shows no Network row. A rule the ceiling no longer admits has above_limit.
PUT /api/chats/{id}/egress {preset} Sets the preset: off, registries or all. Above the ceiling it is 400 egress_above_ceiling.
DELETE /api/chats/{id}/egress/rules/{host}/{port} Removes an allow or deny rule.
POST /api/chats/{id}/egress/consent {id, decision} Answers a card: once, chat or deny. Allowing a host inside your network needs an administrator (403). The same answer sent twice is one answer (204, nothing changes). Another answer to a card already answered is 409 already_answered. A host the ceiling does not admit refuses the card and gives 400 egress_above_ceiling on this answer and on every later one, so a stale card never ends in a conflict loop. In GET /api/chats/{id}/egress such a card has above_ceiling: no answer can make it work.

The chat's event stream carries egress_consent (ids: [card id], text: host:port) when a card asks and egress_card when the chat's network changes, so every open tab reads it again. Administrators read the settings in force and the audit with GET /api/chats/admin/egress and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host=, and see both on the Settings tab of the add-on that runs the commands. Its sandbox field names the add-on that runs the commands now: of the running add-ons that replace bash, the one chosen in chats_tool_providers or the one installed first, and none when the chosen one is stopped. It is read from the add-on host each time, so it is right after a restart and the same on every frontend of a cluster.

Commands on this machine

The built-in bash is for a server that cannot run the Sandbox. Read this before you allow it:

First start. While no model is installed, the Home page of an administrator lists the first steps. The optional last step, Run code safely, offers to install the Sandbox add-on (it opens the add-on browser at the Sandbox) and says what happens without it: the model can only use the built-in host bash fallback, which has full network access and no isolation. Press Skip to hide the step (the browser remembers it). The step is not shown when the Sandbox is already installed, or when add-ons are off.

  • No isolation, full network. A command runs as the user LocalAI runs as. It can read every file that LocalAI can read and reach the internet, your local network, the LocalAI API on the loopback and cloud metadata addresses. Nothing blocks the network without privileges, so there is no "no network" option, and the network settings of the Sandbox (chats_egress_*) do not apply. Install the Sandbox to isolate commands.
  • Never as root. When LocalAI runs as root, the built-in bash is not offered.
  • Your files. Each chat gets a scratch folder with your files: home/ there is /workspace/home of the file tools, and shared-<name>/ is a shared folder. A command starts in home/, so a relative path is a path in your home and a shared folder is ../shared-<name>. The skills are there too, read-only, at the names the file tools use: shared-skills/ (shared by an administrator), shared-builtin-skills/ (built in) and shared-addon-skills/ (from add-ons), so a skill's scripts run in place, for example python3 ../shared-builtin-skills/pdf/scripts/verify_pdf.py report.pdf. A shared folder named skills is at shared-skills 2/. After each call, what the command changed is saved to your files as one change of the chat, which you can undo in Files, History; a file you or the file tools changed meanwhile is read again before the next call.
  • A clean environment. A command gets PATH, HOME, LANG and TZ only: no API key, database address or token of the server.
  • Limits. A call stops after timeoutSeconds (120 by default, 1800 at most), and the whole process group is killed then, when the turn is stopped and when the output passes 64 KiB. One call runs at a time in a chat, and at most four chats of one user run commands at once.
  • Audit. Every call is recorded: user, chat, command (cut at 4 KiB), exit code, duration and whether it timed out, in the auth database (in a cluster, shared by every frontend), in a file under the data path, or in memory. The server log has one line per command with the prefix [host-exec]. Administrators read the audit with GET /api/chats/admin/host-exec/events or the assistant tool list_host_exec_events. Lines older than chats_egress_audit_days (default 30, the retention of the Sandbox network audit) are removed daily; in a cluster one frontend does it, under an advisory lock. The file store also keeps at most 10000 lines.

The runtime setting chats_host_exec (Settings, Tools, the bash row; LOCALAI_CHATS_HOST_EXEC) decides who gets it:

Value Who gets the built-in bash
off Nobody.
ask Administrators, in chats they watch. Each command waits for a card in the chat.
on Every user who may use chats, also unattended runs that have an agent, and sub-agents.

Empty is the default: off on a server with accounts, ask without. In ask mode the chat shows a card under the running call: "Run this command on this machine?", the command, and Run once, Always on this instance (saves chats_host_exec: on) and Don't run. A person who is not an administrator can only refuse it. The card lives as long as the call waits, at most 5 minutes. The server keeps it with the chat, so a reload, or another browser or frontend, shows it again and it can still be answered; once the call ends the card is gone and the call's line says how it ended. GET /api/chats/{id} carries the waiting card as host_consent (id and command) while the turn that asked runs. The card names that turn: a card of an earlier turn is not shown, also while a later turn runs, and is cleared when the next turn starts. An answer to a card with nobody waiting on it (its turn has ended, its frontend stopped, or a newer card replaced it) is 410 consent_expired, and the chat shows the card as not answered: the command did not run. The answer is sent with:

curl -X POST .../api/chats/3f1c.../host-exec/consent -d '{"id":"<id of the host_exec_consent event>","decision":"once"}'

Only an administrator may send once; anyone who owns the chat may send deny. An answer without the card id, or with any other decision, is 400 for everyone, also for an administrator. The first answer counts: the same answer again is 204, the other one 409 already_answered. The call's line in the chat then says what happened: run on this machine, allowed once, refused or not answered (card host_exec). A call that the Sandbox served carries the card tool_provider with the add-on's ID.

Distributed mode. The scratch folders belong to the frontend that runs the turn, like the Sandbox's sandboxes: your files are the record, and a chat whose next turn runs on another frontend gets a new folder there. The waiting card is stored in the chat record (state mode: shared), so any frontend can show it; its answer is written to the card in the record first and then sent on the chat bus to the frontend that runs the turn, which also reads the card again every two seconds, so an answer given through any frontend reaches it within two seconds even when the bus message is lost, as for the network cards. A card left by a frontend that died is not shown and cannot be answered: its turn no longer holds the chat, and the reaper, or the next turn, clears it. The network cards of the Sandbox follow the same rule. The setting is a runtime setting like the others.

What the web interface is told

GET /api/chats/{id}/addons says the same for the web interface in commands: available, off (the Sandbox is installed but off for this chat, or its agent does not name it, and the built-in bash is not allowed), unavailable (installed, but it cannot run commands on this server; commands_reason says why) or none (no add-on that runs commands is installed for you). When commands run, commands_provider says who serves bash (builtin, or the add-on's ID) and commands_isolation says isolated or host. commands_consent is ask when each command waits for the card, and commands_fallback with commands_fallback_reason name the add-on that would serve but does not (off, or its reason). The Tools menu and the chip next to it say "Commands run on this machine" with the reason, and link an administrator to the Sandbox.

curl -s localhost:8080/api/chats/$ID/addons
# {"addons":[],"commands":"available","commands_isolation":"host","commands_provider":"builtin","commands_consent":"ask"}

Long conversations

When a chat fills the model's context window, the older part is compacted so the model can go on. The transcript keeps every message; only what the model sees shrinks. A compacted event reports the size before and after. The window comes from context_size in the model's configuration.

Compact or clear the context yourself

You do not have to wait for the window to fill. Two calls change what the model sees from the next turn on; both answer 409 while a turn runs, and the transcript keeps every message either way.

# Summarize the older part now, as the chat does when the window fills. It
# takes a while: the model writes the summary.
curl -X POST .../api/chats/3f1c.../compact
# {"before":18250,"after":4120}

# Start fresh in the same chat. Its files, title and switches stay.
curl -X POST .../api/chats/3f1c.../clear
# {"cleared":true}
  • Compact keeps the last messages as they are and puts a summary in place of the ones before. It answers 400 when the conversation is too short to compact. The transcript gets a line with role compacted and the sizes in before and after (tokens, the same estimate as the compacted event).
  • Clear writes a line with role cleared. From then on the model sees nothing above it. It answers 400 when the model sees no message to clear. A cleared event tells the other open pages to read the transcript again.
  • A fork from a line above a cleared line keeps what the model saw there. A regenerate or an edit rebuilds what the model sees from the transcript after the last cleared line. The JSON export and the Markdown export show both lines, and an import keeps them.
  • Both calls hold the chat the way a turn does, so in a cluster no frontend starts a turn on the chat while they run. Messages sent meanwhile wait and go when the call ends. Nothing is kept outside the chat store.

Messages sent with a skill or a saved agent

A message can carry skill, a skill of the chat, or agent, the id or the name of one of your saved agents. With a skill, the model gets the text of the skill's SKILL.md before your message, for this turn only; the transcript keeps your words, and the line has "invoked":{"kind":"skill","name":"review"}. With an agent, the model is asked to start that saved agent as a sub-agent with the task (the chat must have Saved agents switched on). A regenerate runs the skill again.

curl -X POST .../api/chats/3f1c.../messages -d '{"text":"check the diff","skill":"review"}'
curl -X POST .../api/chats/3f1c.../messages -d '{"text":"check the draft","agent":"rev1"}'
# An unknown skill: 400 with the names of the skills of the chat. An unknown
# agent, another user's, or saved agents off for this chat: 400.

Such a message starts a turn. While a turn runs it does not wait like other messages: it answers 409, and you send it again when the model has answered.

Slash commands

Type / in the message box of a chat, or on Home, for the list of commands. Typing filters it; arrows move, Enter runs the command, Tab completes it, and Esc closes the list. A command that takes an argument lists the choices as you type it: /agent lists your saved agents, /skill the skills of the chat and /model the chat models, /loop stop the loops of the chat.

Command What it does
/model, /models Open the model list; with a name, switch to that model.
/new, /chats Start a new chat, or open the list of your chats.
/find, /settings, /export, /delete Search this chat, open its settings, download it as Markdown, delete it.
/<skill>, /skill <name> Run the message with one of the skills of this chat (see below).
/skills Open the Skills page, where you write and manage skills (see Skills).
/agent <name> <task> Hand the task to one of your saved agents (see below).
/compact Compact the context now (see above).
/clear Clear the context: a fresh start in the same chat (see above).
/goal, /goal <text>, /goal clear, /goal resume Show, set, remove or resume the goal of the chat (see Goals). /goal lists resume and clear for the goal the chat has.
/loop [interval] <prompt>, /loop list, /loop stop [id] Repeat a prompt in this chat, list its loops, stop one or all (see Loops).
/attach, /attach clear Choose files to send, or remove the files waiting to be sent.
/help List every command with what it does.
/about Show the model, whether it is loaded, how many tools and skills the chat has, where its commands run and the size of its context.

Home offers the same commands, less /compact, /clear, /goal, /loop and /about: a chat that does not exist yet has no context. /<skill> and /agent work there because the chat is created with the switches you chose first.

Run a skill

Every skill the chat can use is a command of its own: /review check the diff sends "check the diff" with the review skill. The list marks where each skill comes from (built-in, add-on, shared or yours). A skill whose name is a command, such as a skill named new, is reached with /skill new. A skill that is switched off, replaced or for another user is not offered.

The message goes with the name of the skill. The model gets the text of the skill's SKILL.md before your message, for this turn only; the transcript keeps your words and shows the skill as a chip. A regenerate runs the skill again.

Hand a task to a saved agent

/agent Reviewer check the draft asks the model to start the saved agent Reviewer as a sub-agent with the task (see Saved agents as sub-agents). The chat must have Saved agents switched on in its Tools menu; while it is off, the list offers to switch it on.

A command message starts a turn: while a turn runs, send it again when the model has answered. The API of these messages is in Messages sent with a skill or a saved agent.

MCP tools

A model that has MCP servers and sets mcp.auto: true offers all of them to its chats, as it does for /v1/chat/completions. A user needs the mcp feature, and LOCALAI_DISABLE_MCP turns MCP off for the whole instance. A tool cannot take the name of a built-in tool such as spawn_agent.

You can also add MCP servers of your own, stored on the server: see MCP servers. Your enabled connectors are offered to your chats next to the model's servers; the model's own configuration is not changed.

Prompts and resources as context

MCP servers can offer prompts (ready-made instructions with arguments) and resources (documents the server holds). A chat can list them for the servers it can use now, which means the servers of the model, less the ones you switched off for the chat, or the ones the chat's saved agent grants, and your enabled connectors. You can add any of them to a message as context for that turn:

curl .../api/chats/3f1c.../mcp/prompts      # {"prompts":[{"server":"connector:ab12","name":"review","arguments":[...]}]}
curl .../api/chats/3f1c.../mcp/resources    # {"resources":[{"server":"wiki","uri":"wiki://home"}]}
# Preview one:
curl -X POST .../api/chats/3f1c.../mcp/prompts -d '{"server":"connector:ab12","name":"review","arguments":{"lang":"Go"}}'
curl -X POST .../api/chats/3f1c.../mcp/resources/read -d '{"server":"wiki","uri":"wiki://home"}'
# Send them with a message. The server expands them as you and puts the text before yours.
curl -X POST .../api/chats/3f1c.../messages -d '{"text":"Go ahead","context":[
  {"server":"connector:ab12","name":"review","arguments":{"lang":"Go"}},
  {"server":"wiki","uri":"wiki://home"}]}'

A server the chat cannot use is 403 mcp_refused, and the message is not sent. A server that does not answer is 502 mcp_error. Binary parts of a resource are left out, and the text is cut at 256 KiB. The expanded text is part of the message in the transcript.

Switch tools off for one chat

The Tools menu over the message box lists the groups of built-in tools, the add-ons you may use, the MCP servers of the chat's model and your connectors, with a switch each. What cannot be used now is shown off, with the reason (a connector that is switched off, a model server that the model does not use by itself). A switch takes effect at the next turn. Switches only take away: a chat with a saved agent lists only what the agent grants and never gets more than that. A trigger run with no agent copies the switches of the chat that proposed it.

The menu groups the rows under Built-in tools, Add-ons, MCP servers of the model and Your connectors. Each row has the name, a line of text when there is something to say (why it cannot be used, how the Sandbox runs commands), a chip with the add-on that serves a built-in tool ("Served by add-on searx"), the number of tools of an add-on, and the switch. A click on the name also toggles the switch, and Space toggles a focused switch. The header gives the count of what is on among what can be used ("4 of 7 on"), with Select all and Disable all. A group with two or more switches has All on and All off for that group only. These buttons never change a row that cannot be used, and Select all leaves Saved agents off: you switch it on by hand. They send one request for each kind of switch (built-in tools, add-ons, MCP servers and connectors). A change that the server refuses goes back, and a line at the bottom of the menu says so. On a phone the menu opens as a sheet at the bottom of the screen.

curl -s localhost:8080/api/chats/$ID/tools
# {"addons":[...],"servers":[{"name":"crm","off":false,"usable":true}],
#  "connectors":[{"id":"c1","name":"Docs","off":false,"usable":true}],"off":[]}
curl -s -X PUT localhost:8080/api/chats/$ID/mcp -d '{"off":["crm","connector:c1"]}'

off holds the name of a server of the model, or connector:<id>. At most 200 switches.

The built-in tools switch by group. Every group is on until the chat switches it off, so a chat that never touches the menu works as before:

Row Group Tools
Run commands shell bash, whoever serves it: the Sandbox add-on (isolated) or the built-in bash on this machine
Your files files list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document
Web web web_search and web_fetch
Images and audio media describe_image and transcribe_audio
Collections collections list_collections and search_collection
Sub-agents subagents spawn_agent
Saved agents saved_agents list_agents and the agent argument of spawn_agent (opt-in)
Triggers triggers propose_trigger, list_triggers, update_trigger and delete_trigger
curl -s -X PUT localhost:8080/api/chats/$ID/builtins -d '{"off":["web","files"]}'

off replaces the earlier list, and a name that is not a group is refused (400). A group the server does not offer is not listed: Your files and Images and audio need the files, Images and audio the helper models, Collections the collections. A group that this chat cannot use now is listed as not usable with the reason, for example Collections for a user without the collections feature.

Run commands governs bash from both providers: switched off, the chat has no bash, and the add-on list says "commands":"switched_off". The row names who runs the commands (Sandbox with "Commands run isolated", or This machine with a warning), and the notice about commands on this machine stays at the top of the menu. When nothing can run commands, the row is not usable and gives the reason of that notice. The Sandbox row under Add-ons stays for the add-on's other tools.

What the model is told follows the tools it has. With Your files off it hears nothing about the file tools: no hint to save files with write_file, no work folder, no pointer to AGENTS.md, and no line about writing skills when nothing can save a file. The list of the chat's files stays, with a line that the model cannot open them. The files pane, uploads and previews work as before, and load_skill stays: it follows the Skills menu. With Images and audio off the model is not told to call describe_image. With Run commands off the model is told that you switched commands off and where to switch them back on.

A sub-agent follows the switches of its parent chat: it never gets a tool of a group the parent switched off. A trigger run with no agent copies these switches from the chat that proposed it, as it copies the others. Each group keeps its own rules for the runs that start by themselves (for example a trigger or connector run never gets the trigger tools). load_skill and the goal and loop tools have no switch.

The trigger tools have a group too, triggers: propose_trigger, list_triggers, update_trigger and delete_trigger switch together, and the menu shows them as one Triggers row. It is on by default; {"off":["triggers"]} takes the four tools away from that chat. A chat started by a trigger or a connector and a sub-agent never have them, and the row then shows as not usable with the reason. The row also shows as not usable when you do not have the triggers feature, and it is not listed at all when the server runs no triggers (LOCALAI_DISABLE_TRIGGERS). The group has no runtime setting: the start-up switch governs it. See Proposals.

The saved agents group (saved_agents) is opt-in: it is off until on names it. See Saved agents as sub-agents.

Interactive tool pages (MCP Apps)

A tool of an MCP server can declare an interactive HTML page in _meta.ui.resourceUri, as the MCP Apps extension defines. When the model calls such a tool, the chat shows the page under the tool line, with the arguments and the result of the call. This works for your connectors and for the MCP servers of the chat's model. The page appears as soon as the tool call ends, while the turn goes on, and it stays when the turn ends or the chat is opened again.

A live tool_end event carries the reference of the app (server, tool, resource, visibility) and seq, the number of the call's line in the transcript. The arguments and the full result are not in the event. A client that wants them reads that one line with GET /api/chats/{id}/messages?after=<seq-1>&limit=1, which has the same checks and size limits as the whole transcript. Because the line is in the shared store, this works whichever frontend runs the turn. A client that misses the event or cannot read the line shows the text result, and finds the app in the transcript when the turn ends.

The MCP connection stays on the server, so two routes of the chat carry the page and its calls. Both answer only for a server that the chat may use at that moment: not switched off in the chat's tools panel, named by the chat's agent if it has one, and, for a connector, yours and enabled. You need the chats and mcp features. Anything else is 403 with the type mcp_app_refused.

# The page of the app. server is connector:<id> or the name of a server of the model.
curl -s "localhost:8080/api/chats/$ID/mcp-apps/resource?server=connector:c1&uri=ui://clock/app.html"
# {"uri":"ui://clock/app.html","mime_type":"text/html;profile=mcp-app","html":"...","meta":{"csp":{...},"permissions":{},"prefersBorder":true}}

# A tool call from the page.
curl -s -X POST localhost:8080/api/chats/$ID/mcp-apps/call \
  -d '{"server":"connector:c1","tool":"refresh","arguments":{"zone":"UTC"}}'
# {"result":{"content":[{"type":"text","text":"..."}],"structuredContent":{...}}}

Rules the server applies:

  • The resource must be a ui:// URI that a tool of the same server declares. No other resource is read. It must be text/html (with the mcp-app profile or none) and at most 4 MiB.
  • A tool call must name a tool of the same server whose _meta.ui.visibility includes "app" (the default is ["model","app"]). A tool in the add-on namespace or with the name of a built-in tool is refused. The call has the same time limit as a call of the model, its arguments are at most 16 KiB, and its result is cut at 256 KiB.
  • A tool with visibility: ["app"] is not offered to the model.
  • The answers never hold a token or a header of a connector, and an error from the server is cleaned of them.
  • In distributed mode, apps work for connectors. The MCP servers of the model run on workers there, so their apps answer 409 with the type mcp_app_unsupported.

The tool_end event and the transcript line of a call with a page carry app: server, tool, resource_uri and visibility. The transcript line also has input and result (the full MCP result, at most 256 KiB), which the page needs. The text of the line keeps its usual 2 KiB cut, and the model sees only the text of the result. The event has no input or result: read the transcript for them.

The web interface shows the page in an iframe with sandbox="allow-scripts" and nothing else. The page has no access to the interface (no same-origin access, no cookies, no storage), cannot navigate the top window and cannot open windows. Its content security policy allows no network by default. A page can ask for https and wss origins with _meta.ui.csp (connectDomains, resourceDomains, frameDomains and baseUriDomains); the server and the interface drop anything else. Browser permissions such as the camera are not granted. The interface reads messages only from that frame and ignores the rest. It supports ui/initialize, tools/call, ui/notifications/size-changed (height between 60 and 600 pixels), ui/message, ui/open-link and ui/request-display-mode; any other request gets the JSON-RPC error "method not found". A message that the page sends with ui/message is shown as a suggestion that you send or dismiss, never sent by itself. A link from ui/open-link must be http or https and opens only when you click it. When the page cannot be loaded, the chat shows the result as text.

Skills

In the web UI, the Skills item in the sidebar, right after Memory, opens the page where you write, change and delete skills. Every chat can use them, and agents use the ones you add to them. You can also open the page from the Manage skills link at the bottom of the Skills menu in the composer, or by typing /skills in the message box.

A skill is a folder with a SKILL.md: a name, a one-line description and a procedure the model reads when a task matches. An optional requires key lists what the procedure needs; today the only value is exec, a way to run commands (requires: [exec]). A chat that cannot run commands lists such a skill as unavailable (see Running commands). At each turn the chat's prompt lists the skills it has, by name and description only, and tells the model to call the load_skill tool with a skill's name before a task that matches it. load_skill returns the instructions of the skill, the text after its frontmatter. It offers exactly the skills of that list: for each name the nearest skill, not switched off, and the skills of the add-ons the chat can use now. Any other name is refused with the names of the chat's skills and the near ones first (for example Did you mean pdf? for PDF).

When the folder of a skill holds other files, such as references and scripts, the answer starts with Base directory for this skill: and the folder, and names up to 20 of the files, relative to it, so the model reads them with read_file. A sub-agent that has no read_file gets the instructions only. A skill with requires: [exec] in a chat that cannot run commands answers that it cannot be used there. Every chat with at least one skill has load_skill, and a sub-agent gets it with its parent's other tools. It has no switch and Settings > Tools does not list it; the Skills menu chooses what it offers. The Sandbox does not see the skill folders (the built-in bash does, see Commands on this machine). When the model reads a SKILL.md with read_file at a wrong path, the error names the right path and load_skill. A SKILL.md over 64 KiB is refused.

Skills come from five places, nearest first, and the nearest wins when two have the same name:

Layer Where Who writes it
Folder any .skills folder in your files you
Personal .skills in your home folder you
Shared for every user an administrator
Add-on ships with an installed add-on the add-on owner
Built-in ships with the server the server

The add-on layer holds the skills of the add-ons that the chat can use now: installed, running, usable by you and on for the chat (see Add-on tools). A chat with a saved agent gets them only for the add-ons the agent grants. The model loads one by its name with load_skill; its files are in /workspace/shared-addon-skills/<add-on>/<skill>/, where the add-on ID says where it comes from. Switching the add-on off for a chat takes its skills out of that chat's index, and the skill list shows each one with addon and addon_title. Its switch id is addon:<name>.

An add-on can also replace a built-in skill of the same name, for example the Office add-on's own xlsx, written for its tools. The add-on's skill then serves and the built-in one shows shadowed: "addon" with shadowed_by, the add-on's ID. When the add-on is off for the chat or not running, or you switch its skill off (addon:xlsx), the built-in skill serves again: the model is told in one line, and the built-in skill in the skill list carries fallback, the add-on's title. When several add-ons replace one skill, the one installed first serves until an administrator chooses another in Settings > Tools, under Skills that add-ons replace, or with the switch in the add-on's details on the Add-ons page.

In the Skills menu over the message box each skill says where it comes from: Built-in, the add-on's title, Personal, Shared or Workspace. A built-in skill that an add-on replaces is listed under the others, dimmed, with "Replaced by " and no switch. When a built-in skill serves because the add-on's own skill is off or not running, one line under the title says so. The Skills page lists add-on skills under the Add-on layer, with the add-on's title as the badge, and says which skill replaces which. See [Skills of an add-on]({{%relref "features/addons#skills" %}}) for how an add-on ships them.

The format is the same as the skills LocalAI already used, so an existing skill carries over by copying its folder into one of the places above. Git sources and zip import and export are not part of chats.

curl -X POST .../api/chats/skills -d '{"layer": "personal", "name": "review", "description": "Check a change before it ships", "body": "Read the diff.\nSay what you find."}'
curl .../api/chats/skills?chat=3f1c...            # what the chat has, with its switches
curl -X PUT .../api/chats/3f1c.../skills -d '{"off": ["review"]}'   # switch one off for this chat

A chat keeps its own switches. Turning a skill off takes it out of the index at the next turn. A switch with the plain name turns off the skill that wins, so a skill of the same name further away shows through, as the list says. A switch with the id layer:name (the id in the skill list, for example personal:review) turns off the skill of that layer only. A deleted skill goes to the Trash like any file. Only an administrator writes shared skills, and everyone reads them. Your personal skills are as private as your files.

The Skills page lists them by layer and edits personal skills, and the Skills menu over the message box switches them per chat. The menu has one switch for each skill, with its name and a line of its description (the full text shows as a tooltip), the count of skills that are on ("12 of 20 on"), and Select all and Disable all. From nine skills on it also has a search field; while a search is on, the two buttons read Select shown and Disable shown and change only the skills in the list. Every change is one request, and a change the server refuses goes back with a message under the list.

A chat stores the skills that are off, not the skills that are on. So Select all sends an empty list, Disable all sends the ids of every skill in the list, and a skill you add later is on in every chat, also in a chat where you chose Disable all. The menu says so under its title.

The server ships a few skills of its own, in the built-in layer: docx, xlsx, pptx, pdf, writing-skills, localai-workspace and localai-apps. localai-workspace tells the model how to change your persona, profile, shortcuts, Home widgets and theme; see Make LocalAI yours. localai-apps tells it to build an app, a tracker or a tool as a [Page]({{%relref "features/pages" %}}) and not as a program. The office skills are adapted from the Feather project (MIT licence); the NOTICE file in the same folder keeps the source and the licence. They tell a model how to work with documents in a sandbox, so they have requires: [exec] and are unavailable in a chat that cannot run commands. When the Office add-on runs, its own skills replace these four: they use its tools, which read, edit and create Word, Excel and PowerPoint files, and read and create PDF files, in your files, with no command runner.

Web

A chat can search the web with web_search and read a page with web_fetch. LocalAI serves both itself, with no add-on and no key, and they are on by default. They only read. Every result is labelled as untrusted content from the internet, and web_fetch refuses addresses inside your network. An administrator can switch each tool off, set a SearXNG instance for the searches and allow more ports in Settings > Tools. An add-on can replace either tool by name: the optional Web add-on replaces both, to search with Brave Search, Tavily or Google with a key and to read PDF files. See Web search and web pages in chats.

Collections

When you may use collections, a chat has two more tools: list_collections and search_collection (collection, query, limit). They search your own collections, the ones of the Collections page, and nobody else's: a name that is not yours is answered "collection not found". An answer holds at most 20 passages and about 16 KiB. A sub-agent gets these tools only when its parent names them.

Add-on tools

A chat can use the tools of the add-ons you may use. The Tools menu of the chat lists them with a switch for each add-on; a switch applies to that chat from its next turn. A tool runs as you, in your chat, and never as a user a model names.

  • A tool that only reads gives the model its result.
  • A tool that changes an add-on's data (rows, a schema, a new database, a job) changes it at once. Turning an add-on on for a chat or an agent is your consent, so there is no confirmation card. The model is told what was changed and whether it can be undone, and the tool entry in the chat says the same. Undo is the safety net, so a change the add-on kept a change set for shows an Undo button on its entry: Tables keeps one for every row and schema change and for a new database, and Fine-tuning and Quantization keep one for a job start (an undo stops the job while it runs). After you press it the entry says "Undone", or why it could not be: the data changed after the change (undoing would overwrite later work, and nothing is changed), the change is older than 7 days, or the job already finished. Only you can press it: the model is told the change can be undone but has no tool for it, and an API key cannot call it. Some changes keep no change set, such as one whose data was too large to keep, stopping a job or exporting a model; their entry says "This cannot be undone" and gives the reason. The file changes of a sandbox or an Office document are different: they are one change in the history of your files and you can undo them there. Reads and changes use your own permissions, and a change on data that moved since it was read is refused, not applied. A scheduled or file-triggered run uses the same rule, but a run with no agent gets no tool that changes anything: name the add-on in an agent to allow it.
  • An action tool, such as running a command in a sandbox, runs at once and only in a chat. No other place that offers add-on tools (the OpenAI-compatible API, MCP, agents) lists or runs one.
  • A sub-agent gets the add-on tools its parent names, or all of the parent's tools when the parent names none.
curl -s localhost:8080/api/chats/$ID/addons
# {"addons":[{"id":"office","tools":4,"off":false}]}
curl -s -X PUT localhost:8080/api/chats/$ID/addons -d '{"off":["office"]}'

Metrics

With metrics on, /metrics counts every turn of the loop (a chat in the page, a saved agent, a trigger run, a connector message and an agent job):

Metric Type Labels
localai_agent_runs_total counter agent (the saved agent's name, empty for a plain chat), origin (chat, trigger, connector or job), outcome (completed, error or cancelled)
localai_agent_run_seconds histogram the same labels

They keep the names the old agent pool had, so an existing dashboard works. The origin label is new.

Who can see a chat

A chat belongs to the user who started it. Another user's chat id always answers 404. Requests made on the user's behalf go through the same model access rules and quotas as a direct request to /v1/chat/completions.

With authentication off, chats belong to one anonymous owner.

Your files

Each user has a personal workspace: folders at any depth, uploads, a Trash and a history with undo. Paths in the API are inside your own home folder, which is /. You cannot name another user's files, and a path with .. is refused.

curl -X POST ".../api/chats/files/folders" -d '{"path": "/Reports"}'  # make a folder
curl -X PUT  ".../api/chats/files/content?path=/Reports/q1.txt" --data-binary @q1.txt
curl         ".../api/chats/files?path=/Reports"                    # list a folder
curl         ".../api/chats/files/content?path=/Reports/q1.txt"     # download
curl -X POST ".../api/chats/files/move" -d '{"from": "/Reports/q1.txt", "to": "/Archive/q1.txt"}'
curl -X DELETE ".../api/chats/files?path=/Archive"                  # to the Trash
curl         ".../api/chats/files/trash"                            # what can be restored
curl -X POST ".../api/chats/files/undo" -d '{"rev": 12}'            # restore it
curl         ".../api/chats/files/history?path=/Reports&limit=50"   # the changes, newest first (at most 200)
curl -X DELETE ".../api/chats/files/trash/12"                       # delete that one for good
curl -X DELETE ".../api/chats/files/trash"                          # empty the Trash for good
curl         ".../api/chats/files/preview?path=/Reports/budget.xlsx" # what a workbook holds
  • Nothing is erased, until you say so. A delete goes to the Trash, and undo restores it as a new change. A change you made after the deletion that conflicts with the restore makes it answer 409; with skip_conflicts the rest is restored.
  • Emptying the Trash. DELETE /api/chats/files/trash deletes everything in your Trash for good, and DELETE /api/chats/files/trash/{rev} deletes one entry, the rev that GET /api/chats/files/trash lists. Both answer {"files": 2, "folders": 1, "bytes": 4096}, what they removed. The list gives the same three numbers for each entry, and totals for the whole Trash, also what a long list leaves out. A permanent delete removes the files and folders, their older versions and their entries in the history, and frees the space the bytes held a couple of minutes later, unless another file shares the same bytes. It works the same on a server with a database, also with several frontends on one database, and on one with the JSON index. It cannot be undone: undo of that change answers 404. It touches only your own Trash; a rev that is not in it is 404. The file tools of a chat cannot do this, only you can.
  • Two uploads, one name. An upload replaces a file only when it says which version it started from (base). Otherwise both are kept, and the new one is named name (from you).ext; the answer says so in conflict_of.
  • Downloads are safe. Files are served as downloads. With inline=1 an image, PDF, plain text, audio or video is shown by the browser; anything else, HTML and SVG included, never runs as a page.
  • Attachments. Send a message as multipart/form-data with a text field and files. The files are saved in the chat's own folder under Chats/ and the model gets their paths after your text. With a turn running the files are saved at once and the message waits like any other; the model reads them by path when the message reaches it. A sandbox that started before the files arrived sees them from the next turn.
  • Attachments in the transcript. The transcript keeps your text and your files apart. In GET /api/chats/{id}/messages a user line has text, the words you typed, and attachments: path (inside your home, for the files API), name, size, mime, version, and missing when the file is no longer in your files. A file whose chat folder was renamed gets its new path. A waiting message has the same two fields. The list of paths and the text read from the files go to the model only: the chat shows an image as a thumbnail and any other file as a chip with its size and a download link, and a click or Enter opens the file in the chat's files pane. Messages sent before this change are shown the same way.
  • Previews of documents. GET /api/chats/files/preview?path= reads what a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file holds, for the preview pane of a chat and the Files page. A spreadsheet gives {"kind": "table", "sheets": [{"name", "rows", "total_rows", "total_cols"}]}: every sheet, the first 200 rows and 30 columns of each as text, with dates, booleans and the stored result of formulas. A Word document gives {"kind": "document", "blocks": [{"type": "heading" | "item" | "paragraph", "level", "text"}]} and a presentation {"kind": "slides", "slides": [{"n", "title", "lines", "notes"}]}; both keep at most 200 000 characters and set truncated when they cut. The formatting is not kept. The path rules are those of the download. A file over 20 MiB answers 413, another type 415, and a file that is not what its name says 422. Each frontend keeps the recent answers in memory, by the content of the file: a new version is a new entry, so frontends of a cluster never disagree, and nothing is shared between them.
  • Search. GET /api/chats/files/search?q=budget finds files by name, lines inside text files, and lines of the text of PDF, Word, Excel and PowerPoint files, ignoring case. A hit inside a document has document: true and its page, sheet or slide; indexing counts the documents whose text is still being read. Text files are read on each search: one over 1 MiB, and any other binary file, is found by name only, and a very large workspace is scanned up to a limit. See Search inside documents.
  • The model can use your files. A chat with files on gives the model one set of file tools: list_files, read_file, write_file, edit_file, move_file, delete_file and search_files. They are the same with or without the Sandbox. What they change is listed in the history as an agent change of that chat, and you can undo it from the note under the call; a delete goes to the Trash. A sub-agent gets the file tools of its parent chat (or the ones its parent names).
    • Paths. /workspace/home/<path>, home/<path> and /<path> all name <path> in your home folder. /workspace/shared-<name>/... is a shared folder, read-only when it is shared with you for reading, and /workspace/shared-skills/..., /workspace/shared-builtin-skills/... and /workspace/shared-addon-skills/... are the shared, built-in and add-on skills, read-only. Any other path under /workspace is refused with the right spelling, and nothing is written: an app the model builds goes to a Page, /workspace/home/pages/<name>/index.html.
    • read_file reads lines offset to offset+limit-1 (at most 2000 lines, text files up to 4 MiB). On a PDF, Word, Excel or PowerPoint file it reads the text of the document, with a ## Page N, ## Sheet or ## Slide N line where each one starts.
    • edit_file replaces old_string with new_string. old_string must match exactly once, unless replace_all is true; no match and several matches are errors that say what to do. Text files up to 8 MiB. When the file changed while it was edited, the edit is saved as a copy next to it.
    • list_files lists one folder, or with pattern every path below the folder that matches it: * matches any characters including /, ? one character, [...] a class. At most 500 paths.
    • search_files finds plain text in names, inside text files and inside the text of PDF, Word, Excel and PowerPoint files (at most 30 hits). With regex: true, query is a regular expression (the extended syntax of grep -E) matched against each line (at most 200 lines). Case does not matter unless ignore_case is false. path may be a file or a folder. A hit in a document reads path (page 3, line 40): text, with sheet "Name" or slide N for a workbook or a deck; the line is the offset to give read_file. When documents below the path are still being indexed, the answer says so with the full count (2 files are still being indexed), so the model searches again instead of concluding that nothing matches. A search looks at 20 000 files and folders and reads 64 MiB of text at most; when it stops at one of these limits before the end, the answer ends with [results limited: ...], the REST search sets limited, and the Files page says "Results limited", so a missing match is not taken as proof.
  • Pictures and recordings. describe_image lets the model look at an image in your files and transcribe_audio turns a recording into text. A voice note attached to a message is transcribed on the way in and its transcript goes with your message; the file is kept as it is. They use helper models, which you choose in the settings (Chat image model, Chat transcription model). Left empty, describe_image uses the chat's own model when it can see images, and otherwise the first installed model that can see images (or, for audio, transcribe). If there is none, a cloud-proxy chat model that does not declare vision is tried as a last resort. With none installed the tool says what to install. The helpers run as you, so your model access and quota apply.
  • A file is at most 256 MiB. Uploads of the Files page do not use the general upload limit (15 MB by default). They are limited by this size, unless you set --upload-limit (LOCALAI_UPLOAD_LIMIT) yourself: then your value applies to them too, and an upload is at most the smaller of the two. A folder listing reports the limit in force in max_file_size (bytes), and each entry has modified, when it last changed. A message with attachments is limited to 1 GiB in all, and each attached file to 256 MiB. A message with attachments does not use the general upload limit either, unless you set --upload-limit: then a message is at most the smaller of your value and 1 GiB. A message without attachments (JSON) always uses the general upload limit.

Search inside documents

When a PDF, Word (.docx), Excel (.xlsx, .xlsm) or PowerPoint (.pptx) file is uploaded, written by a chat, synced from a Sandbox or restored, the server reads its text in the background, usually within a few seconds. The file search of the Files page and the chats' search_files then find the document by what it says, even when its name is a random string.

  • What is read. The text layer of a PDF, page by page; the cells of each sheet; the headings, paragraphs and tables of a Word document; the slides and their notes. A scanned PDF has no text layer, so it is skipped (no OCR in this version). At most 50 MiB of file, 200 pages and chats_index_max_text_bytes of text (2 MiB by default) are kept for one file; a file over a limit is cut or skipped. A file whose content does not match its type (a .pdf that is not a PDF) is skipped.
  • Where the text is kept. In the server's own store, next to the index of the files: the auth database (SQLite or PostgreSQL) when there is one, otherwise a textindex folder under the chats' data folder. The text never becomes a file in anybody's folders, and it is not sent anywhere.
  • Who finds it. The text follows the permissions of its file: each hit is checked against the file at search time, so only users who can read the file find it, in their home or in a shared folder. A file that moved or changed since it was read is not shown until it is read again. Deleting a file removes its text; emptying the Trash removes what is left. The text of what a purge erased is dropped right after it, and when that cannot finish (the server stops, the database fails), the indexer finds the entries whose file is gone on its own: while Search inside documents is on, every 30 seconds it checks 200 entries, round all of them.
  • Search engine. On SQLite the index uses FTS5 with the trigram tokenizer, on PostgreSQL a pg_trgm index when the extension can be created; otherwise, and with the JSON store, the server scans the stored texts. The answers are the same: case-insensitive text, or a regular expression matched line by line.
  • Settings. Search inside documents (chats_index_files, on by default) and Text kept per document (chats_index_max_text_bytes) in Settings > Tools. With the switch off nothing new is read; text read before is still used for files that did not change.
  • Reading a long document. The index keeps the first chats_index_max_text_bytes of a document. When read_file needs more, it reads the whole file once and keeps its whole text (up to 16 MiB of characters) in the same store, by the content of the file, so the next pages come from there and not from the file again. A new version of the file is new content and is read again. The kept text goes with the file's index entry: deleting the file or emptying the Trash removes it too.
  • Existing files. At its first start the indexer reads the files that are already there, in steps, and goes on where it stopped after a restart.
  • Distributed mode. Single-runner: one frontend at a time reads files, under an advisory lock, and the text is in the shared database, so every frontend searches the same text. An upload on any frontend is queued in the database at once, so its search says the file is being indexed. A chat worker searches the text but never reads files for the index. The whole texts that read_file keeps are in the shared database too, so a long document is read once for every frontend. Where the check for entries of erased files stands is in the memory of the frontend that runs the pass: another frontend that wins the lock checks from its own place, which only repeats work.

The Files page

The Files page in the sidebar is a file browser for all of this.

  • Browse. A breadcrumb shows where you are; a deep path folds its middle into a menu. Folders come first. Click a column (Name, Modified, Type, Size) to sort by it, and again to reverse it. List shows a table; Thumbnails shows tiles with a picture of each image. The page remembers the view and the sort in your browser. Dates read as "5 minutes ago" or "yesterday"; hold the pointer on one for the full date. Back and Forward of the browser go between folders.
  • Filter and search. Typing in the box filters the open folder by name. Press Enter, or choose Search all files, to search the names and the text of every file.
  • Upload. Use Upload (its arrow also uploads a whole folder), drop files or folders anywhere on the page, or paste with Ctrl+V; a pasted screenshot gets the date and time as its name. A drop on a folder row, or on a folder of the breadcrumb, uploads into that folder. The upload panel at the bottom right shows each file with its progress, speed and time left, and the total for all of them. You can cancel a file, try a failed one again (the panel says why it failed), and keep working: an upload keeps going when you open another folder or another page of the app, and the browser asks before you close the tab while files are still on their way. A file over the server's limit is refused before it is sent. When an upload has the name of a file that is in the folder already, the page asks: Replace it, Keep both (the new one gets a new name) or Skip it.
  • Select. Click a row to select it, Ctrl-click (Cmd on a Mac) to add one, Shift-click for a range, or use the boxes. The bar above the list then offers Download, Move to… and Move to Trash for the selection.
  • Act. Each row has a menu (⋮, or a right click): Preview or Open, Download, Rename, Move to… and Move to Trash. Drag rows onto a folder to move them there. New folder and New file add one in place, with a name you can type over. A move, a rename and a delete show a message with Undo.
  • Keyboard. Arrows move between rows (and tiles), Shift+arrows extend the selection, Space selects, Enter opens, F2 renames, Delete moves to the Trash, Ctrl+A selects all, Escape clears the selection, Backspace opens the parent folder, and typing the start of a name jumps to it.
  • Preview. Opening a file shows it in a side pane, the same views as the preview pane of a chat, with Download, Rename, Move to… and Move to Trash. On a phone it is a sheet from the bottom. Escape closes it.
  • Trash and History. The Trash tab restores what you deleted, deletes one item for good, or empties the Trash; each of those asks first and says how many files and how much space it removes, and Empty Trash is off while the Trash is empty. The History tab undoes a change of the open folder.

LocalAI keeps two folders of your home for itself: LocalAI/, the settings it reads (persona, profile, shortcuts, Home, theme; see Make LocalAI yours), and Chats/, one folder per chat. Files lists your own files first and these two last, under Managed by LocalAI; the list API marks them with managed (localai or chats). Moving one to the Trash or renaming it asks first and says what stops working.

Your own rules: AGENTS.md

Put an AGENTS.md file in a folder to give the assistant rules for that folder: how to name files, which language to write in, what not to touch. LocalAI never sends the text of the file to the model. It sends one line that points at it, and the model reads the file with its file tools:

Rules for this folder are in /workspace/home/Reports/AGENTS.md. Read that file before you start.
  • At the start of each turn, the line names the AGENTS.md of the chat's own folder (Chats/<chat>/), of the folders above it, and of your home, nearest first, two at most. It is part of the note of the turn, not of the system prompt, so it costs about 25 tokens a file and only when such a file exists.
  • The first time in a turn that a file tool (list_files, read_file, write_file, move_file) works in a folder whose AGENTS.md, or the one of a folder above it, was not named yet, the same line is added to the result of that call. Each file is named once a turn. Reading the AGENTS.md itself counts.
  • A trigger run already gets the rules of its folder and the folders above it in its first message, so it gets no line for them.
  • The checks run as you, on the shared workspace, and keep nothing between turns, so they work the same with several frontends.

A folder pages/<name>/ with an index.html is a page: the UI shows it in a sandboxed frame under Pages. See [Pages]({{%relref "features/pages" %}}).

Shared folders

A shared folder is a folder that several users use. An administrator creates it and chooses its members: a user, or Everyone (every user, also the ones who sign up later), each with Can read or Can edit. A member's chats see the folder at /workspace/shared-<name> (read-only for a reader), and its pages show on the Pages screen under Shared with you (see [Shared pages]({{%relref "features/pages#shared-pages" %}})). A user who is not a member sees nothing of the folder: every call about it answers 404, as for a folder that does not exist.

In the UI, the Shared tab of Files lists the folders. An administrator creates, renames and removes them there, and opens Members to add a person or Everyone, change their access or take it away. Any other user sees the folders shared with them and their access.

  • Where it lives. A shared folder is /shared/<name> in the workspace, outside every home folder. A name has 1 to 64 letters, digits, spaces, dots, dashes or underscores, starts with a letter or a digit, and is unique without regard to case. A server keeps at most 100 shared folders, with at most 200 members each. A new folder has an empty pages folder, and the administrator who creates it is a member who can edit it, so they can fill it. Remove them when they do not need it.
  • Access is a grant. A member is a workspace grant on the folder and everything in it, checked on every call. A change applies at once to the Files and Pages screens and to the shared pages' bridge; an open chat sees it the next time it opens.
  • Rename keeps the members, the pages and the consents to them.
  • Remove moves the folder and its files to the Trash as one change, and answers with undo_rev. Undo (or POST .../restore with that rev) brings it back with its members. Nothing is erased. Removing a folder also drops every user's consent to its pages, so a restored page asks again before it runs.
  • Audit. Every change (create, rename, delete, restore, grant, revoke) is written with who made it and when: in the table studio_shared_folder_events of the auth database, or in chats/shared-folders/audit.json under the data path, and in the log. A change that the LocalAI Assistant made is written as @assistant.
Route Who Use
GET /api/chats/shared-folders any user The folders shared with you: id, name, path, pages, mode (read or write).
GET /api/chats/shared-folders/{id} any user One of them; 404 when it is not shared with you.
GET /api/chats/admin/shared-folders administrator Every shared folder with its members (principal, name, everyone, mode).
POST /api/chats/admin/shared-folders administrator Create one, body {name}. 409 when the name is taken or the limit is reached.
GET, PATCH, DELETE /api/chats/admin/shared-folders/{id} administrator Read one, rename it (body {name}), remove it (answers {folder, undo_rev}).
POST /api/chats/admin/shared-folders/restore administrator Undo a removal, body {rev}. 409 when a folder with its name exists now.
PUT /api/chats/admin/shared-folders/{id}/members/{member} administrator Add a member or change its access, body {mode} (read or write). member is a user's ID, email or name, or @everyone.
DELETE /api/chats/admin/shared-folders/{id}/members/{member} administrator Take a member's access away.
GET /api/chats/admin/shared-folders/events?limit= administrator The audit, newest first.
# Create a folder, share it with everyone (read) and with one person (edit).
curl -X POST ".../api/chats/admin/shared-folders" -d '{"name": "Team"}'
# {"id":42,"name":"Team","path":"/shared/Team","pages":0,
#  "members":[{"principal":"<your id>","name":"Ada","mode":"write"}]}
curl -X PUT ".../api/chats/admin/shared-folders/42/members/%40everyone" -d '{"mode": "read"}'
curl -X PUT ".../api/chats/admin/shared-folders/42/members/marta@example.com" -d '{"mode": "write"}'
# Remove it, then bring it back.
curl -X DELETE ".../api/chats/admin/shared-folders/42"          # {"folder":{...},"undo_rev":118}
curl -X POST ".../api/chats/admin/shared-folders/restore" -d '{"rev": 118}'

The LocalAI Assistant has the same operations as tools (see [LocalAI Assistant]({{%relref "features/localai-assistant" %}})). In a cluster the folders and the grants live in the shared workspace database and the audit in the auth database, so every frontend sees the same state.

Files need a place to live. The choice is made at start and is explicit: with a database the tree goes there, and without one it goes in a JSON index.

Setup Tree File contents
Authentication on, with a SQLite file The auth database (pure-Go SQLite, in any build). A folder under the data path.
Authentication on, with PostgreSQL The auth database. The data path, or the object store in a cluster.
No database, a data path set A JSON index in <data path>/chats/workspace. A folder under the data path.
No data path none none: files are off.

The JSON index is a plain folder: index.json is a snapshot and index.log is the changes since, one record per line. A change is written and synced before the call returns, a start replays the log and drops a last record that a crash cut short (the damaged log is kept next to it as index.log.damaged-<time>), and the log is folded into a new snapshot when it passes 16 MiB. Files, uploads, attachments, skills, the Trash, undo and the limits behave as they do on a database, because both stores pass the same test suite. One LocalAI process owns that folder, because its lock lives in the process: a second process on the same folder would overwrite the first one's index, so use a database when more than one frontend serves the files. There is no move between the two indexes: files kept in a JSON index are not copied into a database when you turn authentication on, and the other way round. Copy them with the files API.

When files are off, every files route and every attachment answers 409 with the reason, the list endpoint returns "files": false and files_reason, and everything else in the chats works. A chat then has no skills either, not even the built-in ones, and no load_skill: skills are files, and every built-in skill works on files or runs commands.

In a cluster the bytes go to the object store, so a file written through one frontend is read through another. That needs S3 (the storage URL setting); docker-compose.distributed.yaml sets it up with Garage. Without it the object store is a folder of each frontend, so files are off in that cluster, with a reason that says so, instead of every frontend keeping its own copy.

Storage

Setup Where conversations live
Authentication on (SQLite or PostgreSQL) The auth database.
No database, a data path set JSON files under <data path>/chats. They survive a restart.
No database and no data path (--data-path=-) Memory only. They are lost on restart, and files are off.

When chats live in memory, the chat page says so, and the list endpoint returns "persistent": false.

If a server stops in the middle of a model request, the turn ends as interrupted. A message you sent while that turn ran and that the request had already taken is waiting again, and "Send now" starts it.

A sub-agent conversation is read-only: it runs with the tools its parent chose, and a message to it answers 409.

The JSON files are for one LocalAI process. A cluster needs PostgreSQL, as [distributed mode]({{% relref "features/distributed-mode" %}}) already does.

Clusters

In [distributed mode]({{% relref "features/distributed-mode" %}}) any frontend can serve any request about any chat.

State mode: shared. Chats, their messages, the waiting messages and the claim that lets one turn run at a time live in PostgreSQL. Live events travel over the message bus, so a client connected to one frontend sees a turn that another frontend runs. Events are delivered at most once: a client that missed some reads the transcript.

Sub-agents. The runs are chats in the shared store, linked to their parent. The background limit, the list of runs and "stopping a chat stops its sub-agents" all read the store, so they hold when the turns of one chat run on different frontends. A stop reaches a run on another frontend over the bus. The tool subset of a running sub-agent is kept in the memory of the frontend that runs it, which is where it runs. A sub-agent started from a saved agent keeps nothing in memory (stateless): the agent is named on its chat record, and its tools are read from the store at each turn, as for any chat with an agent. The chat's saved agents switch is stored with the chat, and the permission is checked again at each call, so every frontend gives the same answer.

Settings and edits. The settings, the title and the model of a chat, and the cut that regenerate and edit make, are stored with the chat. A change of title or model, a regenerate and an edit take the same turn claim as a message, so they are refused with 409 while a turn runs on any frontend. A video that comes with a message is held in the memory of the frontend that runs the turn, for that turn's first request.

MCP connectors. The records of your MCP connectors are shared in PostgreSQL. The open MCP sessions are per instance: each frontend opens its own, and a local command (stdio) is refused in a cluster.

Where a turn runs. The frontend that wins the claim runs the whole turn. The model calls it makes are routed to inference nodes, and its MCP tool calls are sent to agent workers, exactly as for any other request. Agent workers do not run chats: they have no database.

From any frontend you can send a message, edit or cancel a waiting message, read the transcript, stop the turn and stream events. A stop travels over the bus to the frontend that runs the turn.

If a frontend dies. A running turn records a heartbeat every 15 seconds. One frontend, chosen by a PostgreSQL advisory lock, ends turns that have not beaten for a minute. Such a turn ends as interrupted, its waiting messages stay, and send-now starts them. A standalone process with a database does the same once at startup, for turns its previous run left behind.

The JSON files are not supported in a cluster, as the Storage table says.

Saved agents

An agent is a saved set of instructions and tool grants that a chat can start with. See Saved agents, triggers and inbox.

The LocalAI assistant

An admin can start a chat that has the tools to manage the instance (install models and backends, edit model configs, check status). It starts from the Home page with /assistant, or over the API with assistant: true in POST /api/chats. It is an ordinary chat in the list after that. Only that chat has the tools, never its sub-agents. The localai_assistant_enabled setting turns the feature on and off. See LocalAI Assistant.

Chats in the web UI

The web UI talks to this API, so a chat keeps running when you close the tab and opens again from any browser.

  • Home starts a chat. Home (/app) is the front door. Its message box is the one of the chat page, so everything a new chat offers works there before the chat exists:

    • pick the model (each row says whether it is loaded, what it can do and how it fits this machine) and a saved agent;
    • switch tools, add-ons and skills in the Tools and Skills menus. They are a draft: the chat starts with them (switches in POST /api/chats);
    • attach images, audio and other files with the buttons, or paste an image. They go with the first message;
    • dictate, use the shortcuts row above the box, and type / for the Home actions;
    • Enter sends and Shift+Enter adds a line.

    The text you type on Home stays in the browser across a reload until you send it (staged files stay only until the page closes). Send creates the chat, sends the message with its files and opens the chat at /app/chats/<id>. If the message cannot be sent, the new chat is removed and the text and files stay on Home. Home also lists your newest chats under Jump back in.

  • The sidebar lists your most recent chats under the main entries, with New chat (the + beside Recent chats, which opens Home) and All chats. When the sidebar is collapsed, only the All chats icon shows. There is no separate Chats entry: Home is where a chat starts.

  • All chats (/app/chats/all) is your chat history: open it from the sidebar or from All chats next to Jump back in on Home. Type to find a chat by its name or by what was said in it (the server search); the line under a match shows where it matched. Open, rename (F2), copy, export as Markdown or delete one, clear them all, or import a JSON export. A deleted chat can be brought back for a few seconds with Undo; then the server deletes it.

  • Old addresses. /app/chats with no chat id opens Home. A ?model= in the address selects that model on Home, so /app/chats?model=<id> and the older /app/chat/<model> still start a chat on that model.

The chat page

The chat page (/app/chats/<id>) shows one chat.

  • Conversations. Ctrl+K (Cmd+K on a Mac), or Chats in the header, opens the same list as All chats over the chat, to move to another chat without leaving the page.
  • A new chat. New chat in the More menu, in the Ctrl+K list and /new in the message box open Home. A chat with a saved agent starts from the agent's row on the Agents page: the chat page opens empty with the agent chosen, and its Tools and Skills menus are a draft for the new chat, as on Home. Picking another model in an open chat moves the chat to it from the next turn (not while a turn runs). /assistant starts a chat with the LocalAI management tools (administrators, when the assistant is on).
  • Chat settings (the sliders icon in the header) holds the settings above: a system prompt, temperature, top P, top K, context size and reasoning effort. Save settings sends them all; they apply from the next turn.
  • While the model works you can keep typing and attach files. A message you send waits over the message box and reaches the model at its next step; edit or cancel it until then. Esc or Stop ends the turn; waiting messages stay for Send now. Up in an empty box edits the last waiting message, or puts your last message back in the box.
  • Drafts stay with their chat. What you type or attach in a chat stays there when you open another chat, and comes back when you return; a new chat starts empty. The browser keeps the text of each draft (not the files) across a reload, and drops it when you send it or delete the chat. A waiting message always goes to the chat you wrote it in, also when you open another chat before the server answers.
  • On a message: Edit (your message) replaces it and runs the chat again from it, Regenerate (the last answer) answers again, and Branch copies the chat up to that message into a new chat. The keys are E, R and B on a focused message.
  • More exports the chat as Markdown or JSON and imports a JSON export as a new chat. Chats that the old chat page kept in this browser are offered for import once (POST /api/chats/import/browser); the browser's copy is removed when none failed.
  • Under the message box the foot shows the context meter and the speed of the last model request (from the usage events). It turns amber above 80 percent of the window and red above 90.
  • Tools, Skills and Sub-agents menus over the message box switch tools and skills for this chat (see above; a skill is switched by its id, and the Skills menu has Select all, Disable all and a search), and list the sub-agents of the chat with their transcript and a Stop for a running one. The Tools menu says whether the Sandbox add-on runs commands isolated, directly on this machine, or not at all. When the chat cannot run commands, the Tools menu opens with a notice, "Commands can't run here", and the reason: no add-on that runs code is installed, the Sandbox is off for this chat, or it cannot run on this server. An administrator gets a link to install the Sandbox or to its add-on page; anyone else is told to ask an administrator. A chip with the same words sits next to the Tools menu (on a phone, only its icon) and opens the notice. Hide removes the chip and leaves one line in the Tools menu; this browser remembers it until the reason changes. The Tools menu shows for this notice even when there is nothing to switch. Changes an add-on tool made show under the tool line with Undo when the add-on can undo them; a call the add-on refused (not_permitted, resource_unreachable, not_running, invalid_request) says why. MCP Apps show live under their tool call.
  • The preview pane on the right shows what the assistant made. It opens by itself when a turn creates or changes a file, or when an answer holds a result: a whole HTML page, an SVG, a code block of 25 lines or more, or a block the answer presents as a file. A short snippet never opens it. It opens beside the thread from 1280 px and over it below that; on a phone it never opens by itself. It does not take the focus from the message box, and a screen reader hears "Preview opened: budget.xlsx". Close it and it stays closed in that chat (this browser remembers it) until you open it again.
  • Chips under an answer name the files its turn made: "Created budget.xlsx · Open preview" or "Changed ...". A chip opens the pane on its file. Files over the message box opens and closes the pane.
  • What the pane shows. The selected file or code block on top, with Download, Copy (text and code) and Open in Files, then the chat's files in two groups, Created and changed and You gave it, each with its size and turn. Spreadsheets, CSV and TSV files show as a table with a tab per sheet and the first 200 rows; Word documents and presentations as their text (the formatting is not shown); PDF files in the browser's viewer; HTML files run in the sandbox of [Pages]({{%relref "features/pages" %}}) with no network and no bridge; images, Markdown, JSON, code, audio and video with their own views; any other type as a card with Download. An add-on that previews a type (the files.preview slot) replaces the built-in view for it. Drop files on the pane to attach them to your next message. A file the model received as an image or video is marked In context.
  • Code blocks in an answer have Copy and Download, and a block that is a result has Open preview.
  • A trigger the model proposes shows as a card under its tool line with Create trigger, Edit (name and schedule or folder) and Dismiss.
  • A change of a trigger the model asks for shows as a card under its tool line: an edit lists each field before and after with Apply and Dismiss, a delete shows the trigger with Delete and Keep, and a pause or a resume, which applies at once, is a receipt with Undo. See Changes from a chat.
  • Shortcuts over the message box are the prompts of your LocalAI/shortcuts.json, or the default starters without it. A click puts one in the box; Edit shortcuts changes the list. See Make LocalAI yours.
  • Find (Ctrl+Shift+F), focus mode, copying the chat as Markdown and the keyboard shortcuts work as before.

Administrators find every user's agents, triggers and connectors under Operate > Settings > Chats of all users. The agent tasks and jobs have a page of their own, Agents > Tasks (/app/agent-tasks).

Troubleshooting a failed reply

When a turn fails, the thread shows one banner: "The reply failed", the reason in plain words, what to do about it, a Retry button and, under Technical detail, the error text with addresses, paths and keys removed. The full cause is in the server log. The banner names the cause when LocalAI can tell:

Reason in the banner What it means What to do
The model "x" could not be started: its backend is not installed The model needs a backend that is not installed. Install the backend from the Backends page (the banner links to it for administrators), or pick another model.
The model "x" failed to start a moment ago and is resting A load failed and LocalAI waits before it tries again. Try again after the number of seconds the banner gives.
The model "x" could not be started The backend started but the model did not load. Check the model's configuration and the backend log.
The model "x" was not found No model has that name. Pick another model, or install it from the Models page.
The upstream refused the key A cloud-proxy model's provider answered 401 or 403. Check the API key in the model's settings.
The upstream does not accept this request at that address The provider answered 404 or 405. Set upstream_url to the full endpoint, for example .../v1/chat/completions.
The upstream address could not be reached The host name does not resolve, or nothing answers there. Check the address in the model's settings.
The provider behind the model returned an error The provider or a proxy answered with a server error several times in a row. Retry in a moment, then check the provider.
The model took too long to answer / The turn was cancelled A timeout, or the turn was stopped. Retry, or use a smaller or faster model.

A failure that the page learns about only after a reload shows the banner without a reason, and only for a day after the turn. A new turn clears the banner.

Moving from the old chat

The old chat page, the agents, the agent jobs and the old skills were replaced by the chats. At the first start the server converts what they left; see Moving from the old chat and agents.

API reference

The routes are listed under the chats tag at /swagger/index.html, and GET /api/instructions/chats returns a machine-readable summary.