Canvases
A right-hand rail in the session detail page where agents render structured output as interactive artifacts. Pluggable enough that anyone can add a canvas in 3 minutes by dropping a JSON file. Constrained enough that no third-party JavaScript runs in the supervisor dashboard.
{workspacePath}/.ao/canvases/{id}.json. The dashboard polls every 5s and renders the file in a right-side panel.core-git-diff canvas from git diff origin/<default> for every session — no agent integration needed.markdown · diff · table · stats1. The problem
An AO session is a tmux pane with a coding agent inside it. Today, when an agent emits structured output — a diff, a test summary, a cost breakdown, an error trace, a plan — it scrolls past in the terminal and the human supervisor has to know it happened, scroll back, and parse the unstructured shell output. The richer the agent's output, the worse this gets:
- A test runner produces 200 passing rows the user has to scroll past to find the 3 failures.
- A code reviewer produces a 1500-token markdown report that wraps in the terminal and is impossible to navigate.
- A cost tracker prints token counts on every call; by the end of the session you can't reconstruct the total.
- The agent's diff is the highest-value artifact and it's buried under build chatter.
Cursor solved this with canvases — interactive panels that render alongside the chat. Canvases brings the same idea to AO sessions, shaped for an open-source plugin ecosystem.
2. Architecture
Why files instead of an SDK
- Zero coupling. Agents are CLIs running in their own processes. They don't link AO libraries. A file write from a shell hook works for every agent — Claude Code, Codex, Aider, OpenCode, future ones — without an integration step.
- Matches the existing pattern. AO already uses
{workspacePath}/.ao/activity.jsonlfor agent-to-dashboard activity signaling. Canvases reuses the same convention. - Survives restarts. Agent crashes, dashboard restarts, even
ao stop— the JSON file is still there for the next read.
Why pull instead of push
The dashboard already has a WebSocket mux (MuxProvider.tsx) and an SSE channel for sessions. v0.1 deliberately doesn't use either — a single 5-second REST poll is the simplest thing that works, has no failure modes (no reconnect logic, no event ordering, no message replay), and can be replaced with a push channel in v0.3 without changing the on-disk format. Visibility-aware: pauses when the tab is hidden.
3. Schema and what renders
Four built-in renderer types. The schema is a Zod discriminated union on type; all variants share the same envelope.
Common envelope
{
"version": 1,
"id": "a-z0-9-, 1-64 chars", // "core-" prefix is reserved
"type": "markdown" | "diff" | "table" | "stats",
"title": "Display title",
"createdAt": "ISO 8601",
"updatedAt": "ISO 8601", // sort key, descending
"source": "optional label",
"payload": { /* per-type, see below */ }
}
| Type | Use for | Payload |
|---|---|---|
markdown |
Notes, summaries, READMEs, post-mortems | { markdown: string } — supports headings, bold, italic, code, fenced blocks, lists. No HTML pass-through. |
diff |
Patches, code reviews, refactor previews | { files: [{ path, oldPath?, status, hunks: [{ header, lines }] }] } |
table |
Test results, dep lists, anything tabular | { columns: [{key, label, align?}], rows: [{ [key]: string|number|bool|null }] } |
stats |
Cost, durations, KPIs, pass/fail counts | { metrics: [{ label, value, tone?, delta? }] } — tone: good · warn · bad · neutral |
Sample stats payload
{
"version": 1,
"id": "test-run",
"type": "stats",
"title": "vitest",
"createdAt": "2026-05-06T10:00:00Z",
"updatedAt": "2026-05-06T10:02:14Z",
"source": "agent",
"payload": {
"metrics": [
{ "label": "Pass", "value": 847, "tone": "good" },
{ "label": "Fail", "value": 3, "tone": "bad" },
{ "label": "Skipped", "value": 12, "tone": "warn" },
{ "label": "Duration","value": "2m 14s" }
]
}
}
4. The synthesized core-git-diff canvas
Every session gets a free diff canvas without any agent integration. Core runs:
git merge-base origin/<default> HEAD(falls back to local<default>) — this is the diff anchor.git diff <merge-base>— captures committed and uncommitted changes against the working tree.git ls-files --others --exclude-standard— finds untracked files.- For each untracked file,
git diff --no-index NUL/&dev/null <file>— synthesizes "fully added" hunks. - Concatenate, parse the unified diff, render via
CanvasDiff.
The canvas is reserved under id core-git-diff. Agent file canvases that try to use the core- prefix are rejected by the reader, so an agent can't shadow this trusted artifact.
5. How to extend it
Three tiers of effort, smallest first.
Emit JSON
~3 minutes · zero code change in AOWrite a file matching one of the 4 schemas to {workspacePath}/.ao/canvases/{id}.json. The dashboard polls every 5 s and renders it. This is what 90% of agents will do.
Real examples:
- Test runner agent →
tablewith name / status / duration columns - Codex review agent →
markdownwith structured findings - Cost tracker →
statswith token counts and dollar estimates - Lint runner →
tablewith file / rule / message columns - Security scanner →
markdownwith severity-grouped findings - Build dashboard →
statswith build time, bundle size, asset count
Implement CanvasProducer on a plugin
~30 lines · v0.2 (queued)
Synthesize canvases from session data without an agent emitting them. Implement on existing agent / scm / tracker plugin slots:
interface CanvasProducer {
listCanvases(session: Session, project: ProjectConfig): Promise<CanvasArtifact[]>;
}
A future scm-github plugin uses this for "PR status" stats. tracker-linear uses it for "linked issues" tables. v0.1 declares the interface in core but doesn't invoke it; v0.2 wires it up. No dashboard changes, no schema changes.
Ship a renderer plugin
~½ day plugin author · 1.5–3 eng-weeks core work · v0.4For data that genuinely doesn't fit the 4 existing renderers (flame graphs, Gantt charts, network topologies), publish your own renderer as @aoagents/ao-plugin-canvas-{name}. The plugin author publishes a compiled package with two entrypoints — a Node-safe schema and a browser-safe React renderer. AO discovers plugins at startup, validates payloads through the plugin's Zod schema (in core, Node), and bundles the renderer into the dashboard at web build time.
Package shape
@aoagents/ao-plugin-canvas-flamegraph/
├── package.json # exports: { ".": "./dist/index.js", "./renderer": "./dist/renderer.js" }
├── dist/
│ ├── index.js # manifest + Zod payload schema (Node-safe)
│ └── renderer.js # React component, default export (browser-safe)
Plugin authors publish compiled ESM with declarations — not raw TSX. The renderer must be browser-safe (no Node APIs, React as peerDependency); the schema entrypoint must be Node-safe.
How AO wires it together
One discovery step produces three artifacts:
plugin discovery
→ Node schema registry // core uses to validate at runtime
→ generated web renderer map // next bundles statically
→ optional generated TS union // web/internal ergonomics only
- Discovery (AO startup, Node). Plugin registry walks installed
@aoagents/ao-plugin-canvas-*packages, loads each manifest +payloadSchema. Detects type-id collisions; refuses to start on conflict. - Schema registry (runtime, Node).
canvas-log.tsreceives aCanvasSchemaRegistry. Built-ins validate via the closedCanvasArtifactSchema; plugin canvases validate viatype → payloadSchemalookup. Unknown type → rejected. - Generated web renderer map (build time). A generator writes
packages/web/src/generated/canvas-renderers.tswith static imports of each plugin's/rendererentrypoint. Next.js can't bundle dynamic plugin paths — only static imports. The barrel file gives Next a clean import graph. - Renderer dispatch (runtime, browser).
CanvasRail's switch handles built-in types as today. Thedefaultbranch looks upcanvasPluginRenderers[canvas.type]. Missing entry → "Unsupported canvas type" placeholder. Renderer crash → caught by an error boundary, doesn't take down the rail.
Type model — closed union + plugin branch
// Core types stay strict for built-ins, with a single fallback for plugin types.
type BuiltInCanvasArtifact =
| { type: "markdown"; payload: { markdown: string }; ... }
| { type: "diff"; payload: { files: CanvasDiffFile[] }; ... }
| { type: "table"; payload: { ... }; ... }
| { type: "stats"; payload: { ... }; ... };
type PluginCanvasArtifact = {
id: string;
type: string; // any canvasType registered by a plugin
title: string;
createdAt: string;
updatedAt: string;
source?: string;
payload: unknown; // plugin's Zod schema validates in core before serving
};
type CanvasArtifact = BuiltInCanvasArtifact | PluginCanvasArtifact;
Any API boundary that switches exhaustively on canvas.type must add an unknown / plugin branch. Runtime registries are the architecture; the TS union is a convenience layer.
Promote a renderer plugin into core
rare · core PR · ecosystem consensusOnce a renderer plugin has multiple production callers and the type is genuinely general (not specific to your stack), propose promoting it into core's built-in set via PR. The bar here is "this is now standard infrastructure", not "this is a new idea worth trying". Tier 3 is for trying ideas; Tier 4 is for blessing what already worked.
npm install is the trust gate); no remote-loaded JS at runtime (plugins discovered from node_modules at startup, renderers bundled at web build time). Be honest about what this means: a canvas renderer plugin is arbitrary dashboard code — it can read same-origin auth tokens, call same-origin APIs, keylog within the app, alter UI state. Build-time bundling removes the remote-loading risk; it does not reduce blast radius. Installing a canvas renderer plugin grants full dashboard code execution — the install path will surface this as a warning. Iframe sandboxing is deferred until v0.4 ships and a real need surfaces; half-baked iframe support is worse than no sandbox.
5b. Validation rules
Canvases that fail validation are dropped silently and logged. Rules core enforces today:
versionmust be1.typemust be a knownCanvasType(one of the four built-ins, or — in v0.4 — a registered plugin canvas type).idmust match[a-z0-9][a-z0-9-]{0,63}.- The
core-id prefix is reserved for canvases synthesized by AO core. File canvases using this prefix are dropped at the reader. - Total serialized size capped at 256 KB per canvas.
- Per-session count capped at 32 canvases (oldest by
updatedAtevicted). - Payload must structurally match the type's Zod schema.
- For
diffcanvases specifically: max 200 files / 50 hunks per file / 1000 lines per hunk (added in pass 13 to prevent DOM blowup).
5c. Canvas-id namespacing
Two separate concerns, often conflated:
id— the filesystem identity of a canvas file ({workspacePath}/.ao/canvases/{id}.json) and its stable UI identity for animations / collapse state. Reserved prefix:core-for canvases synthesized by AO core (core-git-diff, etc). File canvases usingcore-are dropped by the reader.canvasType— the renderer dispatch key. Maps to a built-in renderer or a plugin renderer. Does not reserveids. A Claude agent can legitimately produce atype: "flamegraph"canvas with idauth-perf-2026-05-06without being the flamegraph plugin author. Producers and renderers are separate concerns.
Recommended (not enforced): plugins suggest a producer-scoped id prefix to avoid wild collision — e.g. flamegraph plugin's example agent emits flamegraph-* ids. Enforced: only core-* reservation and per-session id collision (last-writer-wins by updatedAt).
6. Built with paranoia
The feature went through 13 codex review passes. 18 distinct corner-case fixes landed pre-merge.
Security
js/redos) replaced with lastIndexOflstat instead of stat — symlink to /dev/zero can't bypass the size capcore- id prefix — agent can't shadow the synthesized canvas.ao/ excluded from synthesized diffs — AO metadata doesn't leakNUL on Windows for git diff --no-index null sentinelCorrectness
session.projectId — not prefix-matchingorigin/<base> preferred over stale local refs for merge-base...HEAD) — uncommitted changes show upRobustness
workspacePath returns empty array, not 404+++/--- content lines, strips marker prefix7. File map
| File | What it owns |
|---|---|
core/types.ts | CanvasArtifact, CanvasType, CanvasProducer, CanvasDiffFile, CanvasTableColumn, CanvasStatMetric |
core/canvas-schema.ts | Zod discriminated union for runtime validation |
core/canvas-log.ts | readCanvases() (file reader, validates, size-caps, drops core-) + synthesizeGitDiffCanvas() + diff parser |
api/sessions/[id]/canvases | GET endpoint — merges file canvases + synthesized, sorts by updatedAt |
hooks/useSessionCanvases | 5s poll, sequence guard, visibility-aware pause, session reset |
components/CanvasRail | Right-side rail, collapse/expand, type-switch dispatch |
CanvasMarkdown | Minimal markdown renderer (headings, bold, italic, code, lists) |
CanvasDiff | Per-file diff blocks, collapsible, add/del/context coloring |
CanvasTable | Tabular renderer, sticky header, alignment, boolean display |
CanvasStats | Metric grid, tone-mapped colors |
SessionDetail | Mounts the rail (desktop only via !isMobile guard) |
8. Try it locally
# 1. Start the dashboard
pnpm dev
# 2. Open any session detail page in your browser
# 3. From a terminal, drop a JSON file in the session's worktree
WS=$(jq -r .worktree ~/.agent-orchestrator/projects/<your-project>/sessions/<session-id>.json)
mkdir -p "$WS/.ao/canvases"
cat > "$WS/.ao/canvases/hello.json" <<'EOF'
{
"version": 1,
"id": "hello",
"type": "stats",
"title": "Demo",
"createdAt": "2026-05-06T00:00:00Z",
"updatedAt": "2026-05-06T00:00:00Z",
"payload": {
"metrics": [
{ "label": "Tests", "value": 42, "tone": "good" },
{ "label": "Failures", "value": 0, "tone": "neutral" }
]
}
}
EOF
Within 5 seconds the canvas appears. Edit the file, save, watch it update.
9. Roadmap
| Version | Adds | Status |
|---|---|---|
v0.1 | File reader, GET endpoint, 4 renderers, synthesized git-diff, right-rail with auto-expand, 5s REST poll | Shipped |
v0.2 | CanvasProducer.listCanvases invoked on agent / scm / tracker plugins (Tier 2 above) | Queued |
v0.3 | Mux WebSocket topic for live updates, replacing 5s poll | Queued |
v0.4 | canvas-renderer plugin slot. Per the locked v0.4 plan: single build-time discovery, Node-side schema registry hydrated at startup from a build-time JSON artifact, generated packages/web/src/generated/canvas-renderers.ts with lazy React.lazy() imports + per-canvas <Suspense>, per-canvas error boundaries, built-in canvasType reservation, AST-based compatibility lint at discovery, validate-once + narrow-in-dispatcher payload typing, named-error-and-skip on plugin failures, ao plugin install CLI with auto-rebuild, install-time trust warning UI, example tiny renderer plugin, real-npm-pack CI fixture, stale-canvas cleanup tooling. Ships AFTER v0.2 and v0.3 per the plan-eng-review codex consult. | Queued (after v0.2 + v0.3) — 2.5–4 engineer-weeks of core work for the full Maximalist scope |
| Mobile | Bottom-sheet or full-screen takeover for canvases on small viewports | Deferred |
| Sandboxed iframe | Runtime-isolated escape hatch for genuinely-untrusted content (e.g. third-party HTML emitted by an agent we can't trust) | Only if a real use case shows up |
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 /
localStoragevalue. - 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.
11. Known trade-offs in v0.1
Honest acknowledgment of where the v0.1 surface has real limits:
- 5-second poll latency. Visible-tab polls every 5s, hidden tabs pause entirely. For fast-updating canvases (live test runs, streaming logs) this feels sluggish. The fix is the v0.3 mux topic — core publishes on file write, the rail subscribes per session — which gives sub-second updates without polling. Shortening the interval to 1s in v0.1 just shifts cost (10× the API calls for the same end state); the right call is to wait for push.
- Plugin renderers (v0.4) require an AO rebuild. Next.js can't bundle dynamic plugin paths — only static imports.
npm install @aoagents/ao-plugin-canvas-flamegraphalone won't make the renderer appear; the AO build has to regenerate the static-import barrel. Theao plugin installCLI ships in v0.4 (not deferred) and auto-rebuilds, so users see one command, not three. The rebuild itself is unavoidable; the CLI hides it. - Markdown renderer is intentionally limited. Headings, bold, italic, code, fenced blocks, lists, and (as of post-merge) safe HTTP/HTTPS links work. Images, HTML pass-through, tables, nested lists, and footnotes don't. Tables have a dedicated
tablecanvas type; rich layouts get a renderer plugin in v0.4. Agents that produce rich reports will hit this ceiling on the markdown canvas — that's deliberate, but real. - Read-only surface in v0.1. No write-back, no action buttons, no canvas-driven mutation of session state. This is where the highest-leverage UX would land — approve a diff, retry a failed test, kill a stuck session — but it's a different feature with a different trust model (every interactive control needs explicit consent gating, CSRF protection, auth scope review). Explorable post-v0.4 once the renderer plugin path is live and we have a real consent UI.
12. 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. - Image rendering in markdown canvases. Image URLs would need an allowlist policy (which origins are trusted? what about
data:with embedded SVG that runs JS viaonload?). The risk-to-value ratio is bad; v0.5+ might add a curated{ src, alt }form once an actual use case shows up. Until then: agents that want to render an image should attach the bytes to a session and reference it from a `markdown` canvas with workspace-relative path support (also v0.5+). - HTML pass-through in markdown. Even sanitized HTML opens the supervisor to mutation-XSS, CSS exfil, and same-origin token theft on every poll. See section 10.
- A 9th plugin slot for canvas producers. Considered and rejected — producing a canvas is session output, not infrastructure. Existing plugins opt-in via
CanvasProducer. (v0.4'scanvas-rendererslot is different: that's UI infrastructure, where a slot makes sense.)