diff --git a/docs/canvases-feature.html b/docs/canvases-feature.html
index 5ca3aae4d..5334a0eb3 100644
--- a/docs/canvases-feature.html
+++ b/docs/canvases-feature.html
@@ -214,6 +214,7 @@
File map
Try it locally
Roadmap
+ Why no html canvas type?
Deliberate non-goals
@@ -623,7 +624,45 @@ EOF
-10. Deliberate non-goals
+10. Why no html canvas type?
+The most-asked design question — answered honestly here so reviewers don't have to ask.
+Two reasons, both load-bearing.
+
+Trust hierarchy inversion
+Agents are the lowest-trust surface in the system. They emit content on every poll cycle, and large parts of that content come from LLMs that can be prompt-injected. If an agent ingests a malicious URL, summarizes a poisoned document, or just hits a jailbreak, its next "render this nice summary" call could include attacker-controlled HTML. Putting that HTML into the same-origin dashboard means the attacker now has:
+
+ - Read access to every same-origin auth token / cookie /
localStorage value.
+ - Permission to call every API the user can call (kill sessions, restore them, modify config).
+ - The ability to inject scripts that survive across navigation.
+ - Keylogging on every page the dashboard renders.
+
+That's the same blast radius v0.4 renderer plugins have — but renderer plugins clear npm install review once. An agent emits a fresh canvas every poll. The trust gap is enormous.
+
+HTML sanitization is a leaky abstraction
+DOMPurify, sanitize-html, etc. are honest attempts but every year ships new bypasses — <svg> foreign objects, <math> payloads, mutation-XSS via clipboard, CSS exfiltration via attr(). A sanitizer-based HTML canvas is a 90% solution that fails open on the 10%, and the 10% is "execute arbitrary JS in your supervisor". Even GitHub, GitLab, and Slack treat HTML rendering as a multi-quarter security investment, not a feature you slot in next to markdown.
+
+What the existing types cover
+
+ | You want to render | Use |
+
+ | Formatted text with bold / italic / headings / code / lists | markdown (the v0.1 parser handles all of this safely — no HTML pass-through) |
+ | Code changes | diff |
+ | Structured rows | table |
+ | Metric cards | stats |
+ | Custom UI (flame graph, Gantt, network diagram) | canvas-renderer plugin (v0.4) |
+ | Genuinely arbitrary, untrusted markup | Sandboxed iframe canvas — credible v0.5+ option, deferred |
+
+
+
+The legitimate "I need raw HTML" path — sandboxed iframe
+An iframe with sandbox="allow-scripts" (no allow-same-origin) runs in a null origin — it can't reach your auth tokens or call APIs. Add a postMessage protocol for size negotiation and limited event-out, document the trust trade-off explicitly, and you've got a real HTML surface that's safe by construction.
+But — per codex's pass-13 advice — half-baked iframe support is worse than no sandbox. Sandbox protocols have to be designed end-to-end (CSP headers, postMessage validation, focus management, accessibility, viewport sizing) before they ship. v0.4 renderer plugins is the bigger unlock; the iframe canvas comes after, only if a real use case shows up.
+
+
+ Bottom line — not because we couldn't, but because direct HTML inverts the trust hierarchy (agents shouldn't be able to JS-execute in the supervisor) and sanitizers don't fix that. The plugin path covers most "I need a custom widget" cases at v0.4. Sandboxed iframe is the answer for genuine arbitrary HTML when we're ready to design the protocol properly.
+
+
+11. Deliberate non-goals
- Remote-loaded JS at runtime. No URL-fetched code, no agent-emitted React, no on-the-fly module fetching. v0.4 plugins are discovered at AO startup from
node_modules (Node loads each plugin's schema entrypoint) and renderers are bundled at web build time via a generated barrel of static imports. That's the OSS extensibility line: trust at install, not at runtime.
- Write APIs from the dashboard back into canvases. Read-only surface in v0.1. Bidirectional adds a whole new failure mode (auth, ordering, conflict).
diff --git a/packages/web/src/app/api/sessions/[id]/canvases/__tests__/merge.test.ts b/packages/web/src/app/api/sessions/[id]/canvases/__tests__/merge.test.ts
new file mode 100644
index 000000000..5a00f1364
--- /dev/null
+++ b/packages/web/src/app/api/sessions/[id]/canvases/__tests__/merge.test.ts
@@ -0,0 +1,63 @@
+import { describe, it, expect } from "vitest";
+import type { CanvasArtifact } from "@aoagents/ao-core";
+import { mergeCanvases } from "../merge";
+
+function md(id: string, updatedAt: string, body: string): CanvasArtifact {
+ return {
+ version: 1,
+ id,
+ type: "markdown",
+ title: id,
+ createdAt: updatedAt,
+ updatedAt,
+ payload: { markdown: body },
+ };
+}
+
+describe("mergeCanvases", () => {
+ it("returns empty when nothing to merge", () => {
+ expect(mergeCanvases(null, [])).toEqual([]);
+ });
+
+ it("includes synthesized canvas alongside file canvases", () => {
+ const synth = md("core-git-diff", "2026-05-06T10:00:00Z", "diff");
+ const file = md("notes", "2026-05-06T09:00:00Z", "notes");
+ const out = mergeCanvases(synth, [file]);
+ expect(out.map((c) => c.id)).toEqual(["core-git-diff", "notes"]);
+ });
+
+ it("on duplicate ids, keeps the NEWEST file entry (regression: PR #1653 pass-14 P1)", () => {
+ // readCanvases sorts newest-first; merge must respect that.
+ // If two files emit the same id, the user must see the newer payload.
+ const newer = md("test-results", "2026-05-06T10:00:00Z", "v2 (newer)");
+ const older = md("test-results", "2026-05-06T08:00:00Z", "v1 (older)");
+ const out = mergeCanvases(null, [newer, older]); // newest-first input
+ expect(out).toHaveLength(1);
+ expect(out[0]).toMatchObject({ id: "test-results" });
+ if (out[0]?.type === "markdown") {
+ expect(out[0].payload.markdown).toBe("v2 (newer)");
+ } else {
+ throw new Error("expected markdown canvas");
+ }
+ });
+
+ it("synthesized wins on id collision with a file canvas", () => {
+ // Even though `core-` is reserved at the reader, the merge layer also
+ // needs to resolve collisions deterministically in favor of synthesized.
+ const synth = md("core-git-diff", "2026-05-06T08:00:00Z", "synthesized");
+ const fake = md("core-git-diff", "2026-05-06T10:00:00Z", "imposter");
+ const out = mergeCanvases(synth, [fake]);
+ expect(out).toHaveLength(1);
+ if (out[0]?.type === "markdown") {
+ expect(out[0].payload.markdown).toBe("synthesized");
+ }
+ });
+
+ it("output is sorted newest-first by updatedAt", () => {
+ const a = md("a", "2026-05-06T08:00:00Z", "");
+ const b = md("b", "2026-05-06T10:00:00Z", "");
+ const c = md("c", "2026-05-06T09:00:00Z", "");
+ const out = mergeCanvases(null, [b, c, a]); // arbitrary input order
+ expect(out.map((x) => x.id)).toEqual(["b", "c", "a"]);
+ });
+});
diff --git a/packages/web/src/app/api/sessions/[id]/canvases/merge.ts b/packages/web/src/app/api/sessions/[id]/canvases/merge.ts
new file mode 100644
index 000000000..157ea7184
--- /dev/null
+++ b/packages/web/src/app/api/sessions/[id]/canvases/merge.ts
@@ -0,0 +1,30 @@
+import type { CanvasArtifact } from "@aoagents/ao-core";
+
+/**
+ * Merge synthesized + file canvases for the canvases endpoint.
+ *
+ * Semantics:
+ * 1. Synthesized canvas (e.g. `core-git-diff`) seeds the map first. The
+ * `core-` prefix is reserved at the reader, so a legitimate file canvas
+ * can't collide; if collision happens anyway the trusted synthesized
+ * version wins by being inserted first.
+ * 2. `fileCanvases` MUST be sorted newest-first by `updatedAt`. The first
+ * occurrence of each id is kept; subsequent duplicates are skipped, NOT
+ * used to overwrite.
+ *
+ * The naive `Map.set` loop did the opposite (older payload won) — see PR #1653
+ * pass-14 review.
+ *
+ * Final output is sorted by `updatedAt` descending (newest first).
+ */
+export function mergeCanvases(
+ synthesized: CanvasArtifact | null,
+ fileCanvases: CanvasArtifact[],
+): CanvasArtifact[] {
+ const merged = new Map();
+ if (synthesized) merged.set(synthesized.id, synthesized);
+ for (const c of fileCanvases) {
+ if (!merged.has(c.id)) merged.set(c.id, c);
+ }
+ return Array.from(merged.values()).sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
+}
diff --git a/packages/web/src/app/api/sessions/[id]/canvases/route.ts b/packages/web/src/app/api/sessions/[id]/canvases/route.ts
index 035761e7f..0d36521b4 100644
--- a/packages/web/src/app/api/sessions/[id]/canvases/route.ts
+++ b/packages/web/src/app/api/sessions/[id]/canvases/route.ts
@@ -5,7 +5,6 @@ import {
readCanvases,
synthesizeGitDiffCanvas,
SessionNotFoundError,
- type CanvasArtifact,
} from "@aoagents/ao-core";
import {
getCorrelationId,
@@ -13,6 +12,7 @@ import {
recordApiObservation,
resolveProjectIdForSessionId,
} from "@/lib/observability";
+import { mergeCanvases } from "./merge";
/** GET /api/sessions/:id/canvases — List canvas artifacts for a session */
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
@@ -47,13 +47,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
])
: [[], null];
- const merged = new Map();
- if (synthesized) merged.set(synthesized.id, synthesized);
- for (const c of fileCanvases) merged.set(c.id, c);
-
- const canvases = Array.from(merged.values()).sort((a, b) =>
- a.updatedAt < b.updatedAt ? 1 : -1,
- );
+ const canvases = mergeCanvases(synthesized, fileCanvases);
recordApiObservation({
config,