From fe791adef76705043da592759ef5c9079c9afcb2 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:19:03 +0530 Subject: [PATCH] docs(proposals): web pages the model writes, for the agent and Studio Proposes letting a model at the agent's bar write a web page as HTML, through a new agent tool and skill and Studio's Web page button, run in a viewer frame with no network. ADR 0066 records the decision. --- ...e-html-the-model-writes-without-network.md | 27 ++++ docs/adr/README.md | 1 + docs/proposals/model-written-web-pages.md | 140 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 docs/adr/0066-web-pages-are-html-the-model-writes-without-network.md create mode 100644 docs/proposals/model-written-web-pages.md diff --git a/docs/adr/0066-web-pages-are-html-the-model-writes-without-network.md b/docs/adr/0066-web-pages-are-html-the-model-writes-without-network.md new file mode 100644 index 0000000000..026415ae1c --- /dev/null +++ b/docs/adr/0066-web-pages-are-html-the-model-writes-without-network.md @@ -0,0 +1,27 @@ +# ADR 0066: Web pages are HTML the model writes, run in a frame that has no network + +- **Status:** Proposed, with the [model-written web pages proposal](../proposals/model-written-web-pages.md) +- **Date:** 2026-10-21 +- **Supersedes:** for Studio's html format with a model at the agent's bar, and for the agent's web pages, the rule in [ADR 0010](0010-studio-builders-not-sandboxes.md) that a model emits structured content and a builder renders it +- **Source:** maintainer decision, 21 Oct 2026 (no permalink) + +## Context + +Studio's html format has the model return plain-text sections, escaped into a fixed template, so a page cannot carry a script ([ADR 0010](0010-studio-builders-not-sandboxes.md), [studio](../architecture/studio.md)). The agent uses the same path through `create_artifact`, so every page is unstyled text however capable the model. Word and PDF already split by the model's measured level: a model at the agent's bar writes a script, one below it Markdown for a builder ([model capabilities](../architecture/model-capabilities.md)). [ADR 0028](0028-model-written-code-runs-with-approval.md) lets model-written code run, and the agent's documents are model-written scripts ([ADR 0039](0039-document-scripts-run-without-approval.md)). + +A page is built from the user's sources, which can carry instructions aimed at the model. The viewer's frame has an opaque origin, so a script cannot reach the app, but it can still make requests, open popups and navigate itself, each of which can carry what the page read off the machine. + +## Decision + +- A web page may be HTML the model writes: any markup, CSS, script and inline SVG. The agent's pages are, at most 5 MB. Studio's are when `writes_script()` says the model writes the file, as for Word and PDF, at most 300 KB; below that, Studio keeps the template. +- SurfSense stores a page as written, plus one CSP ``, and runs it only in a sandboxed frame. +- A page has no network. Its CSP allows only inline script and style and `data:` or `blob:` images, fonts and media; the viewer's frame drops `allow-popups`; Electron refuses a subframe's navigation to another origin. No CDN and no font host is allowed, since a request's URL can carry data. This holds every html artifact, the template's included. +- One design guide is read by both paths, the agent's skill and Studio's prompt. +- Whether the template stays for models below the bar is decided by measuring the ladder's models on the HTML prompt. + +## Consequences + +- Pages can be designed, interactive and animated. The agent's are versioned, screenshotted and limited to three failed saves a turn; Studio's are checked for structure only, three attempts. +- A page cannot load a library or web font; it inlines what it needs. +- Links inside a page do not open from the viewer until a later change routes them through the app. +- A model may write a poor or broken page. The agent sees its screenshots; Studio sees only the structural checks, as its Word scripts do. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7f21beb197..e1d24db005 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -93,3 +93,4 @@ The Date is the day the decision was first written down. Source links are permal | 0063 | [A plugin's connection is a transport and one sign-in, detected from the server](0063-a-plugins-connection-is-a-transport-and-one-sign-in.md) | Accepted | | 0064 | [Agent runtimes reach plugins only through SurfSense's gateway, and never hold a plugin's credential](0064-agent-runtimes-reach-plugins-through-surfsenses-gateway.md) | Accepted | | 0065 | [A turn's plugin tools are sized by the model's window, not counted, and Claude Code searches its own](0065-a-turns-plugin-tools-are-sized-by-the-window-not-counted.md) | Accepted | +| 0066 | [Web pages are HTML the model writes, run in a frame that has no network](0066-web-pages-are-html-the-model-writes-without-network.md) | Proposed | diff --git a/docs/proposals/model-written-web-pages.md b/docs/proposals/model-written-web-pages.md new file mode 100644 index 0000000000..0255bfaa93 --- /dev/null +++ b/docs/proposals/model-written-web-pages.md @@ -0,0 +1,140 @@ +--- +status: proposed +code: + - surfsense_local/backend/modules/agent/tool_endpoint/ + - surfsense_local/backend/modules/agent/skills/surfsense-html/ + - surfsense_local/backend/modules/agent/previews/ + - surfsense_local/backend/modules/artifacts/ + - surfsense_local/backend/worker/studio/made_file/ + - surfsense_local/backend/worker/studio/web/html/ + - surfsense_local/electron/src/main/docx-snapshot/ + - surfsense_local/frontend/src/features/studio/viewers/html-viewer.tsx + - surfsense_local/frontend/src/features/agent/ +--- + +# Web pages the model writes + +> A web page becomes HTML the model writes itself, with its own CSS, script and inline SVG, on the two paths Word and PDF already have. In an agent thread the agent writes it, taught by a new skill, `surfsense-html`, and saves it as a versioned Studio artifact with a new tool, `surfsense_save_page`. Studio's Web page button splits as its Word button does: a model at the agent's bar writes the page in one call, and any other keeps today's template. The only limits are size and that a page cannot reach the network. Basic (Q&A) chats make no artifacts and do not change. + +Today both paths end in Studio's [html pipeline](../architecture/studio.md): the model returns a title and plain-text sections, and every value is escaped into a template with no style and no script, so the most capable models in the app produce an unstyled page. Word and PDF already let a strong model write the file: Studio's buttons through [`writes_script()`](../architecture/studio.md#word-and-pdf), the agent through [script documents](../architecture/agent.md#documents-the-agent-makes). Web pages follow the same split. The decision it rests on is [ADR 0066](../adr/0066-web-pages-are-html-the-model-writes-without-network.md). + +## Where a page comes from + +| | Word and PDF today | Web page with this | +|---|---|---| +| Basic (Q&A) chat | none | none | +| Studio, model below the bar | Markdown, rendered by a builder | JSON sections, escaped into the template (unchanged) | +| Studio, model at the bar | the model writes a script | the model writes the page | +| Agent thread | `surfsense_render_document`: a script, versions, previews | `surfsense_save_page`: the page, versions, screenshots | + +Word needs a script because python-docx builds the file. A page is already the file, so nothing the model wrote runs on the machine; it runs only in the viewer's frame. + +## What changes + +| | Today | With this | +|---|---|---| +| Agent asks for a web page | `create_artifact(html)`, Studio's escaped template | writes `outputs/.html`, then `surfsense_save_page` | +| Who writes the markup | SurfSense's template | the agent: any HTML, CSS, script, inline SVG | +| Versions | none; each call is a new page | v1, v2… on one page, as script documents | +| What the agent sees after | "Studio started" | `Rendered artifact , version `, the page's text, and up to four screenshots | +| Viewer frame | `sandbox="allow-scripts allow-popups"` | `allow-scripts` only, a no-network CSP, frame navigation blocked | +| Studio's Web page button | escaped template for every model | the page written by a model at the bar; the template below it | +| `create_artifact(html)` from the agent | starts a Studio job | refused, pointing at the skill and the tool, as Word is | + +## The path + +1. **Prompt.** [`prompts/agent.md`](../../surfsense_local/backend/modules/agent/prompts/agent.md) gains one line beside the documents line: for a web page, load `surfsense-html`, write the file under `outputs/`, save it with `surfsense_save_page`; to change it, edit the file and save with its `artifact_id`. +2. **Skill.** `skills/surfsense-html/SKILL.md`, SurfSense's own text: the contract (one complete HTML document, everything inline, images as `data:` URIs, no network, at most 5 MB), grounding as the documents skill words it, editing, checking the screenshots, and the stop rule. It points at the [design guide](#the-design-guide) beside it for how a page looks and moves. opencode allows it by name in [`opencode_config.py`](../../surfsense_local/backend/modules/agent/opencode_config.py); Claude Code's `surfsense` plugin picks up any folder under `skills/` and rewrites tool names for it. +3. **Write.** The agent writes the page with its own tools: opencode's, or Claude Code's `Write` and `Edit`, which its gate already holds to `outputs/`. A later change is an `Edit` of that file, not the whole page sent again as an argument. +4. **Save.** `surfsense_save_page(path, title, artifact_id?)`, below. +5. **Index.** The Studio job reads the stored page and indexes its text, as a made file's job does. +6. **Screenshots.** Electron draws the page in a hidden window and the result carries up to four images. +7. **View.** Studio's HTML viewer shows it in the hardened frame. + +## The tool + +`surfsense_save_page`, `waits=True`, wrapped by `stop_after_three_failures` with the renders' shared limit ([`failed_renders.py`](../../surfsense_local/backend/modules/agent/tool_endpoint/failed_renders.py)). + +- **`path`** is relative to the thread's folder and must resolve, links followed, inside `outputs/`, as Claude Code's gate holds writes ([`gate.py`](../../surfsense_local/backend/modules/agent/engines/claude_code/gate.py)). It ends in `.html` or `.htm`. +- **Refused before anything is stored:** a path outside `outputs/` or missing; over 5 MB; not UTF-8; no markup; no embedding model chosen (indexing needs one, as every Studio path does). Each refusal is a tool error naming the fix. A refusal is not a failed run. +- **Notes, not refusals:** each external URL in `src`, `href`, `@import` or `url()` is listed as "blocked by the viewer", as the hosted verifier did. Links (``) are left out of the notes. +- **Same title.** A call without `artifact_id` whose title matches a page this thread saved is refused naming that page's id, through [`same_title_documents.py`](../../surfsense_local/backend/modules/agent/tool_endpoint/same_title_documents.py), whose format check widens to `html`. +- **Version.** v1 of a new page, or the next version of `artifact_id`'s root. Only a page this tool saved continues: a Studio-made page, or another format, is refused with "make a new page". +- **Store.** The bytes are written at once, as [`made_file.py`](../../surfsense_local/backend/modules/artifacts/made_file.py)'s `keep_made_file` does, with the CSP `` (below) inserted as the first child of `` (or a `` added). The artifact's metadata holds `made_by: "save_page"`, the `version`, and `spec: {"kind": "html"}`; the HTML is the file, not a copy in metadata. +- **Index.** Studio's job takes the made-file branch. [`made_file/pipeline.py`](../../surfsense_local/backend/worker/studio/made_file/pipeline.py) gains `html` beside `pdf`, `docx`, `pptx` and `xlsx`: its text comes from a new extractor on the standard library's `html.parser` that keeps headings, paragraphs, list items, table cells and `alt` text, drops `