ADR-0082: Mini apps: the agent's tools, and HTML that loads nothing¶
Status: Accepted Date: 2026-10-18 Deciders: Alex Nodeland Amends: ADR-0078, which left apps out of the toolset, and the revisit in ADR-0080 of mini apps' tools
Context¶
lattice RFC-0005's second phase runs mini apps: one HTML file an agent writes, run for everyone who opens the thread, sandboxed on an origin of its own with no network, and no person reviewing it first (its Decision O6). Phase 1 defined the app kind and its limits, and its checks already hold that only an agent makes an app and a person changes only its state (ADR-0075); no agent was offered a tool to make one (ADR-0078). The integration, ui:mini-apps, is declared by lattice-core and off by default (ADR-0080).
The policy portalr runs an app under is what keeps it off the network. The RFC also asks artifactr.ui to refuse an app whose HTML names an external URL, as a first filter, so a model that wrote a script from a CDN or a web font learns of it at once, rather than from a blank frame.
Decision¶
ui_tools(apps=True)offerscreate_app(title, summary, html)andupdate_app(id, html, summary=None), each only whileui:mini-appsis on in the run's workspace, asked through itsprepareas forms' and dashboards' tools are. Off, as it is by default, they are left out of every request, descriptions and all.update_appmakes the next version with the HTML, and the summary when one is given; the app'sstateis kept. The RFC named the firstmake_app; the owner named itcreate_app, aftercreate_form.AGENT_TYPESstays(Form, Dashboard), soArtifactWorkspace's generic tools never make or edit an app: only these two tools do, and only while apps are on.- The tools' description is the bridge's contract, the shapes portalr's host speaks, as RFC-0005's U4 has it: JSON-RPC 2.0 over
window.parent.postMessage, on MCP Apps' method names (ui/initialize,tools/call,ui/open-link,ui/notifications/size-changed, andui/notifications/host-context-changedfrom the host), with three tools,get_state,set_state(a JSON Patch on the app's state) andsubmit(values sent as the person's submission once they confirm), and their limits in words. It never changes between requests. -
An app's HTML loads nothing, or the agent's tools refuse it, as a retry, and
ui_checksrefuse it asValidationFailedat["data", "html"], as an app is made or its HTML changed, with each place named: "the HTML loads from outside the app, which reaches no network: write what it needs inline, or as a data: URL, and open links with ui/open-link (<script src>: https://…)". The filter is not theApptype's: a type's data is validated on every read, so a filter that grows stricter would make apps that exist unreadable, and every read would pay for it.artifactr.ui._htmlreads the markup with the standard library's tokenizer, as a browser does. A URL is the app's own when it is empty, or starts with#,data:,javascript:,mailto:,tel:orabout:blank; anything else, a relative path included, would be fetched from the apps' origin or beyond, which serves nothing to an app. Scripts are not run: what one builds at run time is the policy's to refuse, and words such as a URL in a paragraph load nothing. It refuses:- a URL in an attribute that loads or goes somewhere (
src,href,action,formaction,poster,data,background,icon,manifest,xlink:href), or in one that lists URLs (srcset,imagesrcset,ping), split as a browser splits them; - a URL in CSS, a
styleelement's or attribute'surl(),@importorimage-set(); - a script's static import from another origin (
from "https://…",import("//…")), and a URL in an import map; - what a frame's
srcdocwould load, read the same way; - a
<meta http-equiv="refresh">. set_state's pace is the host's. The RFC coalesces a frame's state to at most one version every five seconds. A revision carries no time, and a check that refused a version too soon after the last would refuse a second person using the same app, so portalr's host coalesces, and stops answering a frame past 60 messages a minute. artifactr holds what a write may be: a person's edit of an app changes only itsstate, and is refused by the gate while mini apps are off (ADR-0081).- One app is switched off by archiving it. A submission to an archived app is refused as closed (ADR-0075), core refuses an edit of an archived artifact, and portalr runs none. The deployment's and a tenant's switches are the port's,
ui:mini-appsset from portalr's admin.
- a URL in an attribute that loads or goes somewhere (
Options considered¶
| Option | For | Against |
|---|---|---|
| The filter in the tools and the checks, over the markup, its CSS and a script's imports (chosen) | Every write refuses alike; a model learns what to inline; apps that exist still read when the filter changes; no dependency | Can't see what a script builds at run time, which the policy refuses |
The filter in App's validator |
One place | Run on every read; a stricter filter makes existing apps unreadable, archiving them included |
A regular expression over the whole HTML for :// |
Simpler | Refuses a URL in words or in a script's string, and misses //host and relative paths |
| No filter, the policy alone | Nothing to keep | A model learns of a CDN script only from an app that draws nothing |
| Pacing in the host (chosen) | Two people can use one app; what the RFC says | A client of artifactr's other than portalr can write faster |
| A check that refuses a state too soon after the last | Holds for every client | Revisions carry no time; refuses a second person; a check holds no lock |
Consequences¶
- Easier: an application that already wires
ui_tools(integrations=...), as stackr's template does, offers apps the moment a workspace switchesui:mini-appson, and not before. - Harder: a link in an app's markup is refused; it opens one through
ui/open-link, from its script, once the person confirms it. - Harder: a client other than portalr's host can write an app's state as often as a person may edit it.