Skip to main content

Pages and Home

Note
Add-ons need a development build or a release newer than 4.12.1.

Make small pages in your workspace, and put pages and widgets on Home. A trigger or a file change can update them.

  • Pages

    Pages are small HTML pages in your workspace, shown in a sandboxed frame and usable on Home.

  • Home widgets and live pages

    Put widgets and pages on Home, and update them on a schedule or when a file changes.

Subsections of Pages and Home

Pages

Note
Add-ons need a development build or a release newer than 4.12.1.

A page is a small HTML page in your workspace that LocalAI shows as part of its UI. The assistant writes one when you ask (“make me a task board”), or you write it yourself. A page keeps its data in files next to it, so the assistant can read and change the same data in a chat.

A page runs in a sandboxed frame. It cannot reach the network, the rest of LocalAI, your other files or your other chats. It talks to LocalAI only through a small bridge, window.localai.

Note

Pages need the chats feature and the pages feature. An administrator always has pages. For other users it is off until an administrator switches it on in the user’s permissions (Users, then Pages (HTML pages in the workspace)). When authentication is off, the single user has it. The files of the chats must be on (see Chats).

To put a page on Home, or to keep it current with a schedule, see Home widgets and live pages.

Where a page lives

A page is a folder of your home:

pages/
  tasks/
    index.html     the page (required, at most 1 MiB)
    page.json      title, icon, description and widget (optional)
    tasks.json     data files of the page, any name, any subfolder
  • The folder name is the page name: lowercase letters, digits and dashes, 1 to 64 characters, starting with a letter or a digit (^[a-z0-9][a-z0-9-]{0,63}$). A folder with another name, or without index.html, is not a page.

  • page.json is optional:

    { "title": "Tasks board", "icon": "list-checks", "description": "What I do this week" }

    A missing or broken page.json does not stop the page; the folder name is then the title.

  • A widget block in page.json lets the page be a tile on Home (see A page on Home):

    { "title": "Clock", "widget": { "size": "small", "title": "Now" } }

In the UI

  • Pages in the sidebar opens the list of your pages. The entry shows only when you have at least one page, and the first time it appears it carries a small New label, until you open it or for a week (switch it off with Notes about new things in Account). Pinned pages show under it.
  • The list groups your pages under Yours. Each row shows the title, the description, the folder and when the page changed last and who wrote it (you or the assistant), with Open, a pin toggle (Pin to sidebar) and a menu: Open in dock (a new chat with the page beside it, see The dock), Ask the assistant to change this (a new chat with the page’s path in the message box), Show its files (the folder in Files), Rename (moves the folder) and Delete page (moves the folder to the Trash, where Files can restore it). Above eight pages, a filter field shows over the list. Ask for a new page opens a chat with “Make me a page that " in the message box.
  • With no pages, the screen offers Ask for a page, and Start from a task board or Start from a reading list, which copy a starter page into pages/task-board/ or pages/reading-list/. The copy is yours; ask the assistant to change it. Two starter pages ship with LocalAI: a task board (task-board, data in tasks.json) and a reading list (reading-list, data in reading.json). Home offers both under Or start from a ready page.
  • Open a page to see it at /app/pages/<name>. The strip above it says that the page runs as you, when and by whom it was last updated, and has Reload and Ask the assistant to change this. When the assistant changes index.html, the page reloads. When only a data file changes, the page gets a files.changed event and stays as it is. Switching between the light and dark theme reloads the page too: it gets the theme as data-theme on its <html> element.
  • Pins are kept in LocalAI/ui.json of your home, under pinnedPages (a list of page names). A pin or unpin keeps every other key of the file.
  • The first pinned page is your start page: the sidebar marks it with a Start page label (see A start page).

How the assistant makes a page

Ask in a chat, for example “Make me a page with a reading list in pages/reading/”. The assistant writes pages/reading/index.html and a data file with its file tools. The page shows up under Pages as soon as the change is saved. To change a page, open it and use Ask the assistant to change this.

One index.html with inline script and style is the simplest page. A page can also keep its script, style sheets, modules, small images and data in files next to it (see Files next to the page). A form cannot be submitted in the sandbox, so a page handles the click of a button and the Enter key itself.

The bridge

Inside the frame, window.localai has these calls. Each returns a promise. A path is relative to the page’s own folder.

CallResult
localai.files.read(path){path, content, version, size}. Text files only (UTF-8), at most 1 MiB. A failed call rejects with an error that has status (404 for a missing file, 413, 415).
localai.files.write(path, content, {base}){path, version, size, conflict_of}. Creates missing folders. Without base the file is replaced. With base (the version you read), a file that changed since is kept and your content goes to a conflict copy, named in conflict_of.
localai.files.list(dir)[{path, kind, size, version}]. dir is optional; the default is the page’s folder.
localai.chat.send(text){sent: true, chat_id} or {sent: false}. See below.
localai.on('files.changed', fn)Calls fn({paths}) when files of the page’s folder change. paths are relative to the folder. When LocalAI cannot tell what changed (for example after the window was in the background), fn gets {paths: [], resync: true}: read again what you show. Returns a function that removes the listener.
localai.on('visibility', fn)Calls fn({visible}) when the page goes out of sight (the tab is hidden, or its tile on Home scrolled away) and when it comes back. Pause timers and animations while visible is false.
localai.on('mode', fn)Calls fn({mode, size}) when a tile changes size without a reload: a tile in a slot that opens as a card, and back. localai.page and the data-mode and data-size attributes of <html> change first.
localai.page.resize(height)Asks for a height in pixels. Only the page area of Home uses it (see the page area); elsewhere it resolves to {height: null}. Without height, the bridge measures the document (the box of <html>). Resolves to {height}, the height after the limits.
localai.page{name, title, mode, size} of the page. mode is page in the full view and in the dock, widget in a tile, and area in the page area of Home; size is the tile’s size (compact, small, medium, large), empty in the full view, the dock and the page area. The page cannot set them.

Example: a counter that keeps its value in count.json.

<!doctype html>
<button id="b">Count</button>
<script>
  let count = 0
  const b = document.getElementById('b')
  const show = () => { b.textContent = 'Count: ' + count }
  localai.files.read('count.json')
    .then(f => { count = JSON.parse(f.content).count })
    .catch(() => {})   // no file yet
    .finally(show)
  b.onclick = () => {
    count++
    show()
    localai.files.write('count.json', JSON.stringify({ count }))
  }
  localai.on('files.changed', ev => {
    if (ev.paths.includes('count.json')) {
      localai.files.read('count.json').then(f => { count = JSON.parse(f.content).count; show() })
    }
  })
</script>

Your own writes also come back as files.changed. Compare the version you got from write with the one read gives to skip them.

chat.send

chat.send(text) never starts the assistant by itself. LocalAI shows the message above the page with a choice of chat: the page’s own chat (created on first use, titled Page: <title>) or one of your recent chats. Nothing is sent until you choose Send. The message is then added to the chat as your message, starting with From the page "<title>" (pages/<name>):, so you can see it in the chat before the assistant answers. Dismiss resolves the promise with {sent: false}.

The page’s own chat is marked as started by a page. Like a chat that a trigger starts, it gets no tool that acts at once and cannot create triggers, because its first message was written by a page and not by you.

Tool calls through the bridge are not available.

When a page does not work

A page that throws an error in its script, or leaves a promise unhandled, shows nothing about it: the assistant cannot run JavaScript, so it could not see the error. LocalAI now keeps the error for it.

  • The frame of the page catches window.onerror and unhandled rejections and hands them to LocalAI over the bridge. Nothing shows in the page, no badge or button is added, and nothing goes into a chat.
  • When you say a page does not work, the assistant calls its read-only tool page_errors with the page name. The tool gives the message, the line and column when known (the line counts from the top of your index.html), how often it happened and when it last happened. Without a name it lists the pages that have recent errors, newest first. The built-in localai-apps skill tells the assistant to read the error and fix it before it rewrites the page.
  • A page that throws no error but looks wrong has nothing to read. The assistant then asks you what you see.

What is kept:

RuleValue
WhereOn the server, per user and per page name, outside your files. A tile of the page on Home uses the same page name. With a database the errors are in the table studio_page_errors, which every frontend shares; without one, in chats/pages/page-errors.json under the data path.
Who recordsOnly you, when you view your own page or its tile. A viewer of a shared page records nothing.
Same errorCounted once: same message at the same file, line and column.
How manyThe 10 newest different errors of a page.
LengthA message is cut at 500 bytes and a stack at 1500.
NoiseThe browser’s own errors, such as ResizeObserver loop and Script error., are dropped.
RateThe frame sends an error once and counts repeats, so a throw in a 60 frames per second loop costs a message every few seconds. The server also limits a page to a burst of 20 reports, then one every 2 seconds.
ClearedWhen any file of the page changes (a data file the page saves counts too), so the assistant never reads the error of code that is gone.

An error message can quote data of your page. The server never writes it to a log. The assistant reads it only when it calls page_errors, and the tool result says that the text is data and not instructions.

The sandbox is as before: the frame has no network and no new right. A page cannot read the errors back, and it cannot stop the capture.

Files next to the page

A page can name the files of its own folder with relative URLs, as a web page does. Before the page runs, LocalAI reads them with your rights and puts them into the page; while it runs, fetch and XMLHttpRequest read the folder.

In the pageWhat happens
fetch('data.json'), fetch('./data/x.json?v=2')Reads the file with files.read. The answer is a real Response: 200 with the text and a content-type from the extension, or 404 for a missing file (also 413 and 415). GET and HEAD only.
XMLHttpRequestThe same, asynchronous only, responseType '', text, json, arraybuffer or blob.
<script src="app.js">The script goes into the page at the same place. With defer or async, it goes to the end of the body.
<link rel="stylesheet" href="style.css">Goes in as a <style>, with its relative @import and url().
<script type="module" src="main.js">, import './lib/x.js', import('./x.js')The modules of the folder load from blob: URLs behind an import map. Cycles work. Only string specifiers that start with ./ or ../.
<img src="logo.png">, <source>, <video poster>, CSS url()Images (png, jpg, gif, webp, avif, bmp, ico, svg) go in as data: URLs.

What does not work:

  • The network: an http:, https: or //host URL. fetch rejects with TypeError: network is off in pages; put the data in a file next to the page; a script, style sheet or image with such a URL stays blocked by the policy.
  • Files outside the page’s folder: ../, an absolute path, %2e%2e/, a backslash. They are never read.
  • Packages (import 'react'), fonts from files, audio and video files, and binary files through fetch (the bridge reads UTF-8 text only).
  • Writes through fetch: save with localai.files.write.

The files that go into the page are at most 4 MiB in all, and the images at most 2 MiB. A script, style sheet or module that does not load (missing, outside the folder, too large) shows a short notice at the bottom of the page and a message in the browser console; an image that does not load gives only the console message. When one of the files that went in changes, the page reloads, as it does when index.html changes. Data read with fetch does not reload the page: listen to files.changed.

A page cannot open a window or leave its frame. A click on a link to an http or https address (also with Ctrl, Cmd or the middle button, or the Enter key) and window.open(url) ask LocalAI instead: a bar above the page shows the site, the whole address and Open and Cancel. Open opens the address in a new tab with noopener and noreferrer; Cancel does nothing. When the host is in punycode (letters of other alphabets, such as xn--pple-43d.com), the bar also says how it reads (аpple.com), since it may imitate another site.

  • A link to #id scrolls the page to that element.
  • A link to a file of the page (other.html) opens nothing; the console says why.
  • javascript:, data:, file:, blob: and //host links and addresses with a user name or password are refused, with a message in the console.
  • The bar shows one link: a new one replaces it. A page asks at most 5 links at once, then one every 3 seconds.

window.open returns null in a page.

A page that starts a run

A page cannot call the assistant on its own, but it can write files, and a file trigger can watch a folder of the page. Each file the page writes there starts a run, which answers by writing a file the page reads back.

  1. The assistant makes the folder pages/ask/requests/ (a trigger needs a folder that exists) and proposes a trigger: {"when": {"file_arrives_in": "/pages/ask/requests"}, "prompt": "Read the request file named in this message, do what it asks, and write the answer to pages/ask/answers.json.", "writes": "pages/ask/answers.json"}. You confirm it on its card.
  2. The page writes a request: localai.files.write('requests/' + Date.now() + '.json', JSON.stringify({text})).
  3. The page listens for the answer: localai.on('files.changed', (ev) => { if (ev.paths.includes('answers.json')) show() }).

Every request is one run, so it costs tokens. A page cannot start runs in a loop: a file waits until it has not changed for 30 seconds, a file that changes again after a run began waits for the trigger’s minimum gap (one minute by default), the run’s own writes start nothing, and the trigger has a daily limit (24 runs by default). The bridge also limits writes (below).

A page on Home

A page with a widget block in its page.json can also be a tile on Home. The tile runs the same page, in the same sandbox and with the same bridge, but in its compact mode: localai.page.mode is widget, and <html> has data-mode="widget" and data-size="small|medium|large", so a style sheet can switch layout without script. The page decides how its tile looks; LocalAI does not shrink the full page.

FieldMeaning
widget.sizeThe size of the tile when home.json gives the widget no size: small (one column, one row unit: a line or a number), medium (two columns and rows: a short list) or large (the full row, three row units: a table or a chart). Any other value is medium.
widget.titleThe title of the tile, when it should be shorter than the page’s.
widget.compacttrue, or {"width": 160}: the page also has a layout for a slot (see A page in a slot). The width is in CSS pixels, 72 to 220 (120 by default); a value outside is clamped.

A page without a widget block cannot be a tile. To put a page on Home, add the block and a widget {"id": "clock", "type": "page", "page": "clock"} to LocalAI/home.json (or pick the page in Customise Home, or ask the assistant to “put it on Home”). A page of a shared folder also has "folder": <folder_id>. Home shows at most three page tiles.

A small clock that is a full page in its own view and one line on Home:

<!doctype html>
<style>
  body { font: 15px system-ui, sans-serif; margin: 0; padding: 24px; }
  #time { font-size: 64px; }
  :root[data-mode="widget"] body { padding: 12px 16px; }
  :root[data-mode="widget"] #time { font-size: 28px; }
  :root[data-mode="widget"] .more { display: none; }
</style>
<div id="time"></div>
<p class="more">The time where you are. On Home, only the time shows.</p>
<script>
  const el = document.getElementById('time')
  let timer = 0
  const tick = () => { el.textContent = new Date().toLocaleTimeString() }
  const start = () => { tick(); timer = setInterval(tick, 1000) }
  start()
  localai.on('visibility', ({ visible }) => {
    clearInterval(timer)
    if (visible) start()
  })
</script>

The tile has a bar with the page’s icon and title, Open page and a refresh button. Its frame is made only when the tile scrolls into view, and it is taken down when the tile is far off screen; nearer, or in a hidden tab, the page gets visibility false and should pause. A tile counts toward the eight live frames of the app (see Live frames and the budget of a tile). The height of each tile is fixed by its size, so nothing moves when a page loads. A tile reads its data as the full page does, with fetch or localai.files.read, and files.changed keeps it live.

In a tile, a link of the page asks before it opens, as in the full view. chat.send is refused: open the page to send a message. When a page is gone, cannot be read, or has no widget block, the tile says so with a link to Customise Home. A shared page needs your consent first: the tile shows Allow this page, which opens the trust sheet with Allow on Home, in slots and in the dock (see Shared pages in a slot and on Home).

A page in a slot

The slots of LocalAI/ui.json (the chat header, the sidebar footer and the composer toolbar, see Make LocalAI yours) show page tiles. A slot is a strip about 40 px high, so a page runs there only when its widget block has compact: <html> then has data-size="compact" and localai.page.size is compact. Draw one short line: a value and a word.

{ "title": "Weather", "widget": { "size": "medium", "compact": { "width": 140 } } }
:root[data-size="compact"] .full { display: none; }
:root[data-size="compact"] .strip { display: flex; height: 32px; align-items: center; }

A click on the tile opens it as a card under the tile (above it in the sidebar and the message box), at the size of widget.size. The card is the same frame in a bigger box: the bridge sends mode with the new size and the page keeps its state; it is never reloaded. Escape, a click outside or the close button puts it back in the strip. The card has the page’s title, Open, and the line “Sandboxed, no network”. In the strip the page is shown, not used: a click opens the card. A page without compact is a button that opens the page: in the dock beside the chat while a chat is open (see The dock), in the page view anywhere else. Open of a card does the same.

A tile in a slot reads its data as the full page does, and files.changed keeps it live. chat.send is refused in a tile. A link asks before it opens.

Live frames and the budget of a tile

The app keeps at most eight page frames running at once, for the page area and the tiles on Home, the tiles in the slots and the page in front of the dock together. A tile that gets none shows as a card with Run; a click runs it and stops the frame that started first, which then shows as a card. When a tile goes away, the next waiting tile runs.

A tile runs without a click, so it has a budget, and so does the page in the dock. Its bridge sends a beat about once a second with the time the page kept the browser busy since the last one (its long tasks, and how late the beat’s timer fired). LocalAI stops the frame and shows Reload when:

ReasonLimit
The page keeps the browser busymore than 3 seconds of work within 10 seconds
The page stops answering while it is in sightno beat for 5 seconds
The page does not startno first beat within 10 seconds

The page area of Home is watched the same way, but it must show its first beat within 5 seconds of its start, and a failure shows the tile of the page area instead of Reload in a card.

A tile out of sight (a hidden tab, a tile scrolled away) is not watched, because the browser slows its timers. Reload is manual: there is no retry loop. The full page view has no budget. The browser decides how much a frame can do before LocalAI can stop it; a frame that blocks the browser completely is stopped only when it answers again.

Shared pages in a slot and on Home

A shared page on Home, in a slot or in the dock starts as soon as the screen shows, so allowing it to open is not enough: its tile shows Allow and the trust sheet offers Allow on Home, in slots and in the dock. That consent is kept against the hash of the page like Allow, so a change of its code asks again, and Stop allowing this page removes both. Allow once runs the tile for this visit only.

A page in the page area of Home

LocalAI/home.json can name one page that fills a full-width area on Home, below the greeting, the composer and the shortcut chips and above the widgets:

{ "pageArea": "today" }

pageArea is the name of one of your pages, {"page": "today", "folder": 3} for a page of a shared folder (folder is the folder_id of the shared pages list), or null (or no key) for no page area. Unlike a tile, the page needs no widget block. Pick it in Edit layout on Home, under Page area, or ask the assistant. The save is a normal change set, so it shows in the history and can be reverted.

The page runs in the same sandbox, with the same bridge and the same budget as a tile. It hears localai.page.mode area (and <html data-mode="area">) and has no size: the area is the full width of Home. It is 240 px high until the page says how high it is, and stays so for a page that never does; the page scrolls inside it. To fit the area to its content, the page calls localai.page.resize() when its content changes:

new ResizeObserver(() => localai.page.resize()).observe(document.body)

The area follows, never lower than 160 px or higher than about 60% of the window: a longer page scrolls inside. LocalAI applies at most four heights a second (the last one asked), ignores a change of a few pixels and never reloads the frame for it. A page whose <html> or <body> fills the frame (height: 100%) measures the frame, not its content: pass the height of its content, localai.page.resize(el.offsetHeight). The frame is clipped to the area, so a page cannot cover the strip, the composer or the widgets. chat.send is refused, because the composer is on the same screen. The area counts toward the eight live frames of the app. A shared page asks first, like a shared page on Home: the area shows Allow this page until you allow it.

LocalAI draws a thin strip above the page: its name, who made it (Page by the assistant, Page by you or Page shared by an author), Runs as you, Reload and Edit, which opens a chat that asks the assistant to change the page. The page cannot change the strip.

When the page does not load

Only the area is replaced: the composer and the widgets keep working. LocalAI shows This page did not load. with the reason when:

ReasonWhat LocalAI saw
The page is missingthe page was moved, deleted, or you cannot open it any more
The value is not a pagepageArea in home.json is not a page name
A script errorthe script threw before the page’s first beat
No first paintno first beat from the page within 5 seconds
It was stoppedthe budget of a tile (busy, no answer) or an attempt to leave its frame

LocalAI decides from what it sees (the read of the page, the load of the frame, the beat of the bridge and a timer), never from what the page says about itself. The tile offers Revert, Open the file and Remove from Home. Revert undoes the last change of the page’s folder, or the last change of home.json when the value is not a page name; it is not offered for a shared page, which is not your file. Remove from Home writes "pageArea": null. Both are change sets, so the assistant sees them and you can undo them. There is no retry loop: Reload in the strip starts the page again.

An administrator can switch the page area off for everyone (the home_page_area_enabled runtime setting). The area then shows a note that says so, and the server leaves pageArea out of the home.json it serves (GET /api/chats/wellknown/home names it in withheld). The file keeps its value: a write of the whole file that does not name pageArea keeps it too, so the page comes back when the area is on again.

A start page

The first entry of pinnedPages in LocalAI/ui.json is your start page. It has its own screen, the page view at /app/pages/<name>, and a Start page label in the sidebar. The Home route does not change. The strip of the page view has a switch, Make this my first pinned page: turning it on pins the page if needed and moves it to the top of the pinned list, and turning it off moves it behind the other pins, so the next pin becomes the start page. The change keeps the other pins, and every other key of the file.

The dock

The dock is a panel beside a chat that shows one or more pages in tabs, so you can talk about a page while you see it. There is no dock until something opens one. A page gets into the dock in four ways:

  • Dock in the chat header, or Ctrl+\ (Cmd+\ on a Mac), shows and hides the dock. An empty dock, and the + of the tabs, list your pages and the shared pages to open. The + always opens that list for a new tab and puts the focus on its first page; a page of the list opens as a tab and closes the list, and Escape or the tab in front goes back to the page.
  • Open in dock on the Pages list starts a new chat with the page beside it.
  • A page button in a slot, and Open of a compact tile’s card, open the page in the dock of the open chat.
  • The assistant opens a page of your home with its open_page tool, for example after it wrote the page you asked for. It does not ask first: the page runs as you anyway when you open it. It refuses a page of a shared folder, which needs your consent in the dock first.

The strip under the tabs says where the page comes from (Page by the assistant, Page by you or the shared folder) and that it Runs as you. Ask the assistant to change this puts a request that names the page in the message box; nothing is sent until you send it. The bar has Reload, a link to the full page and a button that hides the dock. The footer says Live: updates as files change and Sandboxed, no network.

The dock and the Files pane of the chat share the right side, one at a time. Files and Dock are two buttons next to each other in the chat header: opening one hides the other, and a file the assistant writes never takes the side from an open dock (the count on Files still grows). In a window under 1280 px, while the dock is open, both buttons show only their icon.

Each chat keeps its own dock: the open pages, the page in front, whether the dock shows and its width are stored with the chat on the server, so a reload, another browser and another LocalAI frontend of the same cluster show the same dock. A new chat keeps its dock until its first message creates the chat.

Drag the left edge of the dock to change its width: at least 320 px and at most 60% of the window. A double click resets it to 452 px. With the edge focused, the arrow keys move it (Shift for bigger steps), Home and End go to the bounds and Enter resets it. The thread, the chat header and the message box narrow for the dock; it covers none of them. On a phone the dock is a bottom sheet that stops under the chat header: drag its handle up for full screen and down to close, or tap it to switch between the two heights.

A docked page runs in the same sandbox as everywhere, with the same bridge and the same budget as a tile (see Live frames and the budget of a tile). The page in front counts toward the eight live frames of the app. A page you open takes a frame at once and stops the frame that started first; a page that comes back with a reload waits for a free one and shows Run it here until then. localai.page.mode is page, as in the full view, with no size. chat.send from a docked page shows the message in the dock; it goes to this chat, as your message, only when you press Send. The message lies over the top of the page, so the page does not move, and Send works half a second after it shows. A page that asks more than 3 times at once waits, as in Limits. A shared page runs in the dock only after you allowed it on Home, in the slots and in the dock (Allow on Home, in slots and in the dock), because the dock starts it again without a click after a reload.

Limits

LimitValue
index.html1 MiB
A file that the bridge reads or writes1 MiB, UTF-8 text
Files put into the page (scripts, styles, modules, images)4 MiB in all, images 2 MiB
Path256 bytes, 8 folders deep
Writes20 at once, then one every 2 seconds, per page
chat.send3 at once, then one every 20 seconds, per page; 4000 characters
Pages in a listing200

A page writes data files only. A write of a code or document file is refused with the error code forbidden and the message “a page can write data files only (such as .json or .txt); ask the assistant to change the code of the page”. That covers the extensions .js, .mjs, .cjs, .css, .html, .htm, .svg, .xml, .xhtml and .wasm (in any case, in the page’s folder and its subfolders), a file named index and page.json. Because a page loads its own scripts and styles, a page that could rewrite them would run new code on the next load. The server enforces this on the page’s bridge; you and the assistant write the same files through the Files page and the file tools. A shared page follows the same rule, and a consent is kept against the code you allowed, which a page cannot change. Debounce writes in a page that saves while the user types.

The rate limits are kept by each frontend. With several frontends, a page gets one budget on each. They protect against a page that loops, not against a person.

The sandbox

  • The frame is <iframe sandbox="allow-scripts"> without allow-same-origin. The page has an opaque origin: it cannot read LocalAI’s cookies, storage or DOM, cannot navigate the LocalAI window, open pop-ups or submit forms.

  • The page is loaded with this content security policy in front of its own markup:

    default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
    img-src data:; font-src data:; connect-src 'none'; frame-src 'none';
    form-action 'none'; base-uri 'none'; object-src 'none'

    The network is blocked for fetch, XMLHttpRequest, WebSocket, external scripts, styles, fonts and images, and nested frames. Images work as data: URLs. Links open only after you confirm them (see Links). A page with ES modules of its folder also has blob: in script-src: its modules load from blob: URLs that the frame makes itself, which reach no network. fetch and XMLHttpRequest of the page are replaced by the bridge reads of Files next to the page.

  • A page can still navigate its own frame to another address. LocalAI sees the second load, closes the bridge and shows “The page tried to open another address” with Reload. The request of that navigation has left the browser by then, so do not run a page you do not trust with data you would not share.

  • The bridge is a MessageChannel that LocalAI hands to the frame when it loads. Every call is checked by the server with your rights: a path that leaves the page’s folder (.., an absolute path, a backslash, a control character) is refused. The workspace has no symbolic links, so a link cannot lead out of the folder either.

  • A page runs as you: what it writes is saved as your change, and shows in the file history as “Page : save ”. Undo works as for any other change.

Shared pages

A shared folder is a folder outside your home that an administrator gave you access to, read-only or read-write (a workspace grant). An administrator creates the shared folders and chooses their members in the Shared tab of Files or through the API (see Shared folders). Its pages are the folders pages/<name>/ with an index.html, as in your home. They show on the Pages screen under Shared with you, with their author (the account that last wrote index.html), the folder, and a badge: Not allowed yet, Changed since you allowed it or Allowed. A user without a grant on the folder sees nothing of it.

A shared page runs as you, so it runs only after you allow it. Opening one the first time shows a sheet that says who wrote it, where it lives, what it can do (read and write files in its own folder, write a message into a chat that you see before the assistant answers), what it cannot do (read your other files or chats, reach the network, use tools or change your settings), and one limit of the sandbox in plain words: a page can load another address in its own frame, which sends one request out before the page stops. Cancel has the focus; Escape is Cancel. View source shows index.html as text.

ChoiceResult
AllowKept for this version of this page. It runs now and each time you open it.
Allow onceIt runs for this visit. You are asked again next time. A visit ends after 12 hours.
Allow on Home, in slots and in the dockShown by a tile on Home or in a slot, the page area and the dock. Kept for this version, and it also lets the page run there without a click.
CancelNothing runs. The frame says “This page has not run”, with Review and allow and View source.

A consent is kept against the page (the node of its folder, so a rename does not lose it and a page deleted and made again asks again) and the hash of its code: index.html, page.json and the files of code that LocalAI puts into the page: every <script src>, <link rel="stylesheet">, relative CSS @import and ES module import of the folder, followed through the whole module graph (the code_files of the page in the API). A file of code that the page names and that is missing counts too, so adding it later also asks again. When the author changes any of them, the next open asks again and says what changed: the size of each file and the lines of index.html, or which files of code there are when only one of them changed. A page that is open when its author changes it keeps running the version you allowed, and its strip says that it changed, with Review; a file of code it then asks for is refused (409), so new code never runs under the old consent. The check ignores the case of the name, as the files do: Index.html or JS/App.js is the same file of code as index.html or js/app.js. The UI puts no text file into a shared page that is not in its code_files.

The data files of a page are not in the hash: a page saves them all the time, and images are only pictures. A page whose code reads its data and runs it as code does what its data says; you trust its author for that when you allow it. The files go into the page only after you allow it, with the same consent check as the bridge. To find the files of code, the server reads the page’s markup and modules; each frontend keeps what it found per file content in a small cache, which only saves reads (the same content gives the same files on every frontend).

A consent for one page never lets another page run, even one with the same code, and it is yours only. Stop allowing this page in the page’s menu revokes it. Every call of a shared page’s bridge checks the consent on the server, so a page that you stop allowing, in this tab or in another one, stops at its next call.

A shared page uses the same bridge and limits as your own, with your own grant on the folder: in a folder you can only read, its writes are refused (“you can only read this shared folder”). It reads, writes and lists only its own folder. A message it sends goes to your chat and says which shared page it comes from.

The consents are kept on the server, not in your files: the assistant, a page and a trigger run can all write your files, so a consent there could be written by the code it is meant to hold back. With a database (authentication on), they are in the tables studio_page_consents and studio_page_consent_events, which every frontend shares; without one, in chats/pages/consents.json under the data path. Each Allow, Allow once and revoke is also written to the consent audit (studio_page_consent_events: who, which page and folder, which hash, when) and to the log.

An administrator can turn shared pages off at run time with shared_pages_enabled in the runtime settings (or start with LOCALAI_DISABLE_SHARED_PAGES=true). Then the list still shows the pages, dimmed, with “Shared pages are off on this server. An administrator turned them off.”; a page does not open; and the bridge refuses every call, so an open shared page stops at its next call. Revoking a consent still works. The files are kept.

API

The UI uses these routes. They need the chats and pages features (the dock routes need only chats).

RouteUse
GET /api/chats/pagesList your pages, each with its widget block when it has one.
GET /api/chats/pages/{name}A page with its index.html as html.
GET /api/chats/pages/{name}/files?dir=files.list
GET /api/chats/pages/{name}/file?path=files.read. With encoding=base64 the file must be an image (by extension) and content is its bytes in base64, with media_type. The UI uses it to put images into the page; the bridge reads text only.
PUT /api/chats/pages/{name}/filefiles.write, body {path, content, base}
POST /api/chats/pages/{name}/chatchat.send after you chose Send, body {text, chat_id, model}
POST /api/chats/pages/{name}/errorsAn error that the script of your own page threw, body {message, stack, file, line, column, count}. The UI sends it; the assistant reads the errors with page_errors. 200 with recorded (false for browser noise), 429 when reported too fast. There is no route to read them back and none for shared pages.
GET /api/chats/{id}/dockThe dock of a chat: {open, tabs: [{page, folder}], active, width}, or null. The chat’s detail (GET /api/chats/{id}) carries it as dock.
PUT /api/chats/{id}/dockReplace the dock of a chat: at most 6 tabs, each page once (folder is the folder_id of a shared page, absent for your own), active the index of the tab in front, width 0 (the default) or 320 to 4096. A dock with no tab clears it. 400 out of bounds, 409 for a sub-agent run. The chat’s event stream sends dock when it changes.
GET /api/chats/page-startersThe starter pages.
POST /api/chats/page-starters/{id}/copyCopy a starter into pages/.
GET /api/chats/shared-foldersYour shared folders, with your mode and how many pages each has. folder_id of a shared page is the id of its folder.
GET /api/chats/shared-pagesThe pages of your shared folders: folder, folder_id, author_id, author_name, writable, hash, code_files, widget, consent (none, allowed, changed), and enabled.
GET /api/chats/shared-pages/{folder_id}/{name}A shared page with html, lines and consent (state, and for the version you allowed allowed_at, size, meta_size, lines, and embed when you allowed it on Home, in the slots and in the dock).
PUT /api/chats/shared-pages/{folder_id}/{name}/consentAllow a version, body {hash, once, embed}. 409 when the page changed since you read it. A visit (once) answers with a token. embed also allows it on Home, in the slots and in the dock (a lasting consent, so not with once: 400); a lasting consent to the same version keeps embed.
DELETE /api/chats/shared-pages/{folder_id}/{name}/consentStop allowing it.
GET .../files, GET and PUT .../file, POST .../chatThe bridge of a shared page, as above. Each call sends X-LocalAI-Page-Hash (the version that runs) and, for a visit, X-LocalAI-Page-Visit. Without a consent: 403 page_not_allowed. A read of a file of code by a frame of an older version: 409.

With shared pages off, every shared-pages route but the list and the revoke answers 403 shared_pages_off.

curl -s http://localhost:8080/api/chats/pages -H "Authorization: Bearer $KEY"
# {"pages":[{"name":"tasks","path":"/pages/tasks","title":"Tasks board",
#   "source":"own","author":"assistant","updated_at":"2026-10-29T09:00:00Z",
#   "updated_by":"you","rev":42,"size":5120}]}

curl -s -X PUT http://localhost:8080/api/chats/pages/tasks/file \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"path":"tasks.json","content":"[]"}'

The answers use the error types of the chats API: 400 for a bad name or path, 403 when the feature is off or a page tries to write code (anything but a data file), 404 for a page or file that does not exist (also another user’s), 413 for a file over 1 MiB, 415 for a file that is not text and 429 for too many writes or messages.

Home widgets and live pages

Note
Add-ons need a development build or a release newer than 4.12.1.

Home can show widgets. A widget is a small tile with a list, a number or one of your pages. You can change what Home shows, and a trigger can keep a tile current.

Note

This needs the chats and their files. The Pages feature, and the Triggers feature, are off for new users on a server with accounts. An administrator turns them on in the permissions of each user. Administrators always have them. The server option LOCALAI_DISABLE_TRIGGERS turns triggers off for everyone.

Kinds of tile

KindShowsData
listA list of items, each with a label and an optional noteA JSON file in your home
metricA number, a note and a small line chartA JSON file in your home
chatsYour last chatsNo file needed
pageOne of your pages, in compact modeThe page itself

Data comes from files, not from live services. A page has no network access. A line chart is the only chart type of the data widgets.

Add, remove and reorder

  1. Open Home and choose Edit layout.
  2. Add a widget, remove one, or change the order.
  3. Save.

You can also ask the assistant: “Show my open tasks on Home”. It writes the data file and adds the widget to LocalAI/home.json. Home is per user. There is no shared Home for a group. Home has at most 12 widgets, and at most 3 of them can be page tiles. You cannot change the size of a tile from Home. A page sets its own size in its page.json.

Make a page a tile

A page can be a tile when its page.json has a widget block with a size of small, medium or large. Pick the page in Edit layout, or ask the assistant to put it on Home. See A page on Home.

Keep a tile current

A trigger starts a run of the assistant. The run rewrites a file, and the tile reads it.

  • A schedule trigger runs daily, on weekdays, weekly, monthly, once, or from a cron expression. A schedule trigger runs at most once every 5 minutes, however it is made.
  • A file trigger runs when a file arrives in a folder. A page can write that file. See A page that starts a run.

There is no trigger that a web request can start from outside.

Example:

  1. Ask: “Every morning at 8, list my open tasks from my notes on Home.”
  2. The assistant writes data/tasks.json, adds a list widget for it, and proposes a trigger: daily at 08:00, writes data/tasks.json.
  3. Review the trigger card and choose Create.
  4. Each morning the trigger runs one chat turn for you and rewrites the file. The tile shows the new list.

Each run uses a model turn, so it costs tokens. See Saved agents, triggers and inbox.

Limits to know

  • A tile shows its last valid data and marks it as stale when a file breaks its schema.
  • A trigger run is an ordinary chat turn. It can fail like one. Try your own setup with a short schedule first.