Skip to content

feat: HTML page format for view-only visualizations#16

Merged
alextnetto merged 11 commits into
mainfrom
keen-roentgen-e25c2a
May 15, 2026
Merged

feat: HTML page format for view-only visualizations#16
alextnetto merged 11 commits into
mainfrom
keen-roentgen-e25c2a

Conversation

@alextnetto
Copy link
Copy Markdown
Member

Summary

Adds HTML as a second first-class page format alongside A2UI. The agent gets two single-purpose tools:

  • show_ui (existing) — ask the user a question; A2UI form/picker/confirmation; structured result via check_result polling.
  • show_html (new) — show a rich visualization (report, dashboard, chart, infographic, comparison table). View-only; nothing comes back.

Rule for the agent: show_html to show, show_ui to ask.

What's in scope

  • New format field on POST /new ("a2ui" default — every existing client keeps working unchanged).
  • Server-side DOMPurify sanitization (via isomorphic-dompurify) on HTML submission.
  • 1 MB body cap for HTML; A2UI stays at 256 KB (enforced post-parse).
  • New format column on pages (added in init() with idempotent ALTER TABLE).
  • Renderer routes by format; HTML renders in a sandbox-"" iframe with strict meta-CSP, no-referrer, noindex.
  • Thin shell chrome (role="banner" "AI-generated content" label + mailto: Report link).
  • Two MCP tools (show_ui, show_html); check_result now returns format.
  • SKILL.md teaches the dichotomy.
  • OpenAPI documents the new field, the invalid_for_format and sanitized_empty 4xx codes, and updated 413 caps.

What's deliberately out

  • JavaScript execution in HTML pages. Load-bearing structural constraint — see docs/superpowers/specs/2026-05-15-html-format-design.md. JS support would require subdomain isolation; per product call, we're not making that investment. A CI test asserts the iframe's sandbox attribute stays empty.
  • Submit from HTML pages. HTML is view-only; A2UI owns input.
  • Subdomain isolation. Deferred indefinitely; tied to the JS decision.
  • External assets in HTML. Inline only — no external <script>, <link>, fonts, or images.
  • Real abuse-reporting flow. V1 ships a mailto: Report link in the chrome bar; richer intake is a separate project.

Three layers of defense for HTML

  1. Sanitizer (server) — DOMPurify + jsdom strips <script>, <iframe>, <object>, <embed>, <link>, <base>, <meta>, all on*= handlers, formaction, srcdoc, xlink:href, and dangerous URL schemes (javascript:, vbscript:, data:text/html, data:application/*). Returns output, removedTags, removedAttrs; the API logs the counts. Sanitization runs through a shared store.createHtmlPage helper so REST and in-process MCP go through identical code.
  2. CSP (renderer)default-src 'none', img-src 'self' data:, style-src 'unsafe-inline', font-src data:, form-action 'none', frame-src 'none', base-uri 'none', sandbox. Injected as a <meta http-equiv> inside the srcdoc scaffold.
  3. Iframe sandbox (renderer)sandbox="" (no tokens) → opaque origin, no scripts, no forms, no top-nav, no popups, no plugins. CI test asserts this stays empty.

If any one layer is bypassed, the next two still block code execution.

Architecture

Single API endpoint discriminates on format:

agent (MCP)         api/server.ts           Postgres
  │                    │                       │
  show_html(html) ─────┤                       │
  POST /new            │ sanitize(html)        │
  {format:"html",      │ store.createHtmlPage  │
   spec}               │ INSERT pages          │
                       │ (format=html)─────────┤
  ◀── { id, url }      │                       │

user (browser)         │                       │
  GET /<id> ───────────┤ map.get(id) ──────────┤
  ◀── { format:"html", │                       │
       spec, ... }     │                       │
                       │                       │
shell (apps/web)       │                       │
  ├─ format router     │                       │
  ├─ "a2ui"  → A2UI surface                    │
  └─ "html"  → <iframe sandbox srcdoc="...">   │

Backwards compatibility

Every existing client keeps working without changes:

  • MCP show_ui signature unchanged. Description sharpened to point at show_html.
  • POST /new accepts { spec } (no format field) and defaults to a2ui.
  • Existing A2UI pages have format = 'a2ui' via the column default + idempotent ALTER TABLE migration.
  • The renderer treats missing format as a2ui.

Rollout

Three logical commits + per-phase fix commits + final OpenAPI polish:

  • Phase 1 (5cc77a7 + 373dc45) — API + storage + sanitizer
  • Phase 2 (ae6e614 + 94936f4) — MCP tools (show_html, sharpened show_ui)
  • Phase 3 (f19f112 + ecd0dcb + 439c8a5) — Renderer + skill + OpenAPI
  • Final polish (e2a3170) — OpenAPI 4xx schemas

Deploy ordering on merge: Vercel (renderer) and Railway (API) both auto-deploy from main. Vercel finishes first (smaller bundle); worst case is a few seconds where the API returns format: "html" and an old renderer mishandles it (renders the spec as A2UI and shows an error). Refresh fixes it.

Test plan

  • npm run typecheck — green
  • npm run lint — green
  • npm run format:check — green
  • npm run test237 passing across 16 test files (all existing + ~55 new across sanitize / schemas / app / mcp tools / mcp http / csp / html-renderer)
  • Pre-push hook (typecheck + lint + format:check + test + build:mcp) passes
  • Smoke once deployed: curl -X POST $API_URL/new -H 'content-type: application/json' -d '{"format":"html","spec":"<style>body{padding:24px}</style><h1>Hello</h1>"}' returns a URL whose page renders the styled "Hello" with the chrome bar
  • Smoke once deployed: existing A2UI flow unaffected — show_ui from any existing client still works

Known follow-ups (deferred from final review)

These are doc/test gaps, not correctness bugs. Worth filing as issues against this branch post-merge:

  1. Renderer dispatch test. html-renderer.test.ts covers the pure helpers in isolation; no test exercises the actual loadPagerenderHtml → DOM-mount path inside AgentUIApp. Adding a happy-dom test against the LitElement would close the gap.
  2. ShowUiResult type name is reused for show_html's return type. Rename to CreatePageResult for clarity.
  3. htmlBody = '' silent render. If the DB ever held an empty active page, the renderer would show chrome only. Server already 400s on sanitized_empty, so unreachable today — but a client-side warning would be defensive.
  4. REPORT_EMAIL env var. Currently hard-coded to alex@blockful.io with a // TODO comment near the constant. Self-hosters will want VITE_REPORT_EMAIL.
  5. In-process MCP path logger. Uses the module logger (no request_id). REST path uses a request-scoped child logger. Aligning would give end-to-end log correlation for the MCP submission path.

Refs

alextnetto added 10 commits May 15, 2026 13:53
Brainstorm output for the planned HTML page format alongside A2UI.
View-only, no JS, sandbox+CSP+sanitizer defense-in-depth. Two MCP
tools (show_ui, show_html). No subdomain isolation; no-JS is a
structural constraint enforced by CI.
Step-by-step plan covering 16 tasks across 3 commits (API+sanitizer,
MCP tools, renderer+skill+docs) + quality gate + PR. Each task has
bite-sized TDD steps with the actual code.
POST /new accepts an optional { format: "a2ui" | "html", spec } body.
Default remains "a2ui" — every existing client keeps working unchanged.
HTML payloads up to 1 MB are sanitized server-side (DOMPurify+jsdom via
isomorphic-dompurify) before storage, then echoed in GET /:id and
GET /:id/result so renderers and tools can route. POST /:id/result on
an HTML page returns 400 invalid_for_format — HTML is view-only.

Schema: new `format` column on `pages`, NOT NULL DEFAULT 'a2ui' with
CHECK ('a2ui','html'). Added in init() with an idempotent ALTER TABLE
for existing deployments.

Three defensive layers for HTML: sanitize -> CSP (renderer) -> iframe
sandbox (renderer). This commit lands the sanitizer; renderer work in
a follow-up commit on the same branch.

Spec: docs/superpowers/specs/2026-05-15-html-format-design.md
…haustiveness)

- sanitize.ts: wrap DOMPurify hook lifecycle in try/finally so hooks always
  clear even on mid-sanitize exceptions; add comment noting the function must
  remain synchronous because hook state is module-global.
- db.test.ts: move the closed-union assertion for PageFormat to a top-level
  type-only check; the prior runtime `expect('pdf').toBe('pdf')` was
  tautological and obscured the real (compile-time) verification.
- sanitize.test.ts: add five test cases the spec lists explicitly — strip
  data:application/javascript URLs, <applet>, <frame>, <frameset>, and onload.
  All pass against the current sanitizer config; documents the contract.
- app.ts: rewrite the format guard from `!== 'a2ui'` to `=== 'html'` plus a
  TypeScript exhaustiveness check on the residual type, so adding a new
  PageFormat variant fails to typecheck and forces explicit handling.
- app.ts: rename structured log keys `removedTags`/`removedAttrs` to
  `sanitizer_removed_tags`/`sanitizer_removed_attrs` to match the codebase's
  snake_case convention for log fields.
- mcp/tools.ts: add TODO(phase-2) marker near optional `format` on
  CheckResultOutcome.
- app.test.ts: replace `expect([400, 413]).toContain(res.status)` with the
  explicit 413 assertion (bodyLimit middleware fires first).
show_html({ html }) creates an HTML page (view-only, sandbox-rendered).
The model gets two clearly distinct tools — show_ui to ask, show_html
to show — instead of a single tool with a format flag. Sharpens
show_ui's description to point at show_html for visualization use cases.

check_result now returns the page's format in structuredContent so an
agent that polls an HTML page (against guidance) at least gets the
explicit "stop polling" signal.

Both the stdio adapter (apps/mcp/server.ts) and the in-process HTTP
MCP share the tool definitions through apps/api/mcp/tools.ts —
description sync is automatic.

Spec: docs/superpowers/specs/2026-05-15-html-format-design.md
… empty-output)

Test depth: tools.test.ts now invokes the registered handlers — a
show_html test asserts structuredContent + the "do not poll" guidance,
and a check_result test asserts the HTML branch surfaces "stop polling".
The test setup uses a makeOps(overrides) helper so each test can
partially override PageOps without rewriting the full stub.

E2E: http.test.ts gains an integration test that drives show_html via
the SDK client and asserts <script> is stripped before storage, plus a
sibling test that the sanitized-empty path surfaces an MCP error.

Log naming: the in-process MCP path's HTML log now uses
sanitizer_removed_tags / sanitizer_removed_attrs — same snake_case shape
as the REST path — so dashboards and log queries see a uniform field set.

Dedup: pulled the sanitize+log+store ritual into a shared
store.createHtmlPage helper. REST POST /new and the MCP show_html op
both call it, eliminating drift between the two paths.

Magic number: the 1_000_000 HTML payload cap moved to a new
apps/api/limits.ts (HTML_MAX_BYTES). app.ts re-exports MAX_BODY_BYTES
from it; schemas.ts and mcp/tools.ts both import it directly.

Empty-output: SanitizedEmptyError is thrown when sanitize() strips the
input to nothing. REST maps it to a 400 sanitized_empty response; the
MCP transport surfaces the throw to the client as an error. Tests cover
both paths.

Nits: check_result's ternary-of-ternary is now an explicit if/else
chain. The show_html constraints paragraph is split into newline-
delimited bullets so the LLM sees clear paragraph breaks.

Bundle: rebuilt apps/mcp/server.bundle.js so stdio MCP users get the
updated tool descriptions and HTML_MAX_BYTES wiring.
The renderer routes by the new `format` field. A2UI pages keep the
existing Lit/A2UI path unchanged. HTML pages render inside an iframe
with sandbox="" (no allow-scripts, no allow-same-origin) and an
opaque origin, with a strict CSP meta tag and noindex/no-referrer
attached to the scaffold. The shell adds a thin chrome bar with an
"AI-generated content" label and a mailto: Report link.

Adds buildIframeCsp() to apps/web/csp.ts and a small html-renderer
module with pure functions (buildScaffoldedHtml, createSandboxedIframe)
so the security-critical paths are unit-tested in isolation.

SKILL.md gets a "show vs ask" section and a worked HTML example; the
front-matter description now covers both tools.

OpenAPI spec documents the new format field on POST /new and the GET
responses.

Spec: docs/superpowers/specs/2026-05-15-html-format-design.md
Plan and spec markdown files contain TypeScript pseudo-code blocks
that prettier mis-parses into semantically-broken output (e.g.
`{ a: 'x' | 'y', b }` parses as a comma expression). The docs were
author-formatted and committed as such in 7a5c0c5 and a7cb4c7.
Add docs/superpowers/{plans,specs}/ to .prettierignore so the
format:check gate stays green without rewriting the docs.
Final-review feedback caught three OpenAPI drifts:
- Error400.enum was missing `sanitized_empty` (returned by POST /new on
  HTML payloads stripped to empty by the sanitizer); added it plus a
  `format` field that surfaces on both `invalid_for_format` and
  `sanitized_empty` rejections.
- Error413 still referenced the old 262 144-byte cap and lacked the
  `format` field that the API includes on every 413 response; updated
  to A2UI 256 000 / HTML 1 000 000 and added the `format` discriminator.
- The 413 response description on POST /new restated the same caps with
  guidance on which one triggered.

No behavior change — these match what the API already does in
apps/api/app.ts (250-253, 224-232).
@vercel
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagent Ready Ready Preview, Comment May 15, 2026 10:38pm

Request Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2a3170cf5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/app.ts
// The bodyLimit middleware lets us inspect the parsed value here without
// double-paying for the read.
const serialized = JSON.stringify(spec ?? null);
if (serialized.length > A2UI_MAX_SPEC_BYTES) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce A2UI size cap using bytes, not string length

The 256 KB guard uses serialized.length, which counts UTF-16 code units rather than UTF-8 bytes. As a result, non-ASCII payloads can exceed the documented 256,000-byte A2UI cap while still passing this check (for example, multibyte characters like é undercount by ~2x). This regresses the intended limit enforcement and allows materially larger A2UI specs than the API contract advertises.

Useful? React with 👍 / 👎.

Comment thread apps/api/app.ts
// enforces it on the wire body so HTML payloads at the spec'd 1 MB ceiling
// pass through cleanly. The historical 256 KB cap for A2UI specs is enforced
// post-parse in newPageHandler. Re-exported for tests / external callers.
export const MAX_BODY_BYTES = HTML_MAX_BYTES;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Raise wire body limit above HTML spec max

Setting MAX_BODY_BYTES equal to HTML_MAX_BYTES makes the documented 1,000,000-byte HTML limit unattainable over REST, because the JSON envelope ({"format":"html","spec":"..."}) adds extra bytes before parsing. A spec that is exactly 1,000,000 bytes is rejected by bodyLimit with 413, so requests that should be valid per the declared cap fail in production.

Useful? React with 👍 / 👎.

CI started failing on `npm audit --audit-level=high` between the last
main CI run (2026-05-11) and this PR's CI (2026-05-15). 17 high-sev
advisories all traced back to protobufjs@8.0.1 via the @opentelemetry/*
chain (GHSA-66ff-xgx4-vchm "code injection via bytes field defaults in
generated toObject code", affected range >=8.0.0 <=8.0.1).

Fix: add a root-package `overrides` entry forcing every transitive
protobufjs resolution to ^8.2.0. After regenerating the lockfile, npm
consolidates the duplicate copies (one was 8.0.1, one was 7.5.7) onto
protobufjs@8.3.0 and the audit drops from 23 vulns (17 high) to 6
(0 high; 4 low + 2 moderate, all transitive through tmp/jsdom and
wireit's brace-expansion — no available fix, sub-high-sev gate).

Orthogonal to the HTML format feature this PR ships; folded in here
because it blocks the same CI gate.
@alextnetto alextnetto merged commit 69baaf8 into main May 15, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant