Agent Orchestrator · feature writeup · v0.1

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.

PR #1653 v0.1 — file-based + synthesized git diff 4 renderer types 13 codex review passes v0.4: renderer plugins
Problem
Structured agent output (diffs, test results, costs) scrolls past in the terminal and the user loses it.
Solution
Agents write JSON to {workspacePath}/.ao/canvases/{id}.json. The dashboard polls every 5s and renders the file in a right-side panel.
Free path
AO synthesizes a core-git-diff canvas from git diff origin/<default> for every session — no agent integration needed.
Renderers
markdown · diff · table · stats
Trade-off
Expressive data, constrained UI. Anyone supplies any data; nobody ships React into the dashboard.

1. 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:

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

Agent Claude / Codex / Aider in tmux pane .ao/canvases/{id}.json workspace-local 256 KB cap, 32 per session ao-core readCanvases() + Zod validate + size cap REST API GET /api/sessions/ [id]/canvases writes reads serves git diff origin/<base> core synthesizes core-git-diff free, no agent action useSessionCanvases 5s poll, seq-guarded CanvasRail switch on type: md / diff / table / stats User (browser) SessionDetail.tsx desktop only renders
Two write paths (agent file, core synthesizer) merge through one read-side that validates, polls, and renders.

Why files instead of an SDK

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 */ }
}
TypeUse forPayload
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:

  1. git merge-base origin/<default> HEAD (falls back to local <default>) — this is the diff anchor.
  2. git diff <merge-base> — captures committed and uncommitted changes against the working tree.
  3. git ls-files --others --exclude-standard — finds untracked files.
  4. For each untracked file, git diff --no-index NUL/&dev/null <file> — synthesizes "fully added" hunks.
  5. 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.

TIER 1

Emit JSON

~3 minutes · zero code change in AO

Write 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 → table with name / status / duration columns
  • Codex review agent → markdown with structured findings
  • Cost tracker → stats with token counts and dollar estimates
  • Lint runner → table with file / rule / message columns
  • Security scanner → markdown with severity-grouped findings
  • Build dashboard → stats with build time, bundle size, asset count
TIER 2

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.

TIER 3

Ship a renderer plugin

~½ day plugin author · 1.5–3 eng-weeks core work · v0.4

For 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
  1. 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.
  2. Schema registry (runtime, Node). canvas-log.ts receives a CanvasSchemaRegistry. Built-ins validate via the closed CanvasArtifactSchema; plugin canvases validate via type → payloadSchema lookup. Unknown type → rejected.
  3. Generated web renderer map (build time). A generator writes packages/web/src/generated/canvas-renderers.ts with static imports of each plugin's /renderer entrypoint. Next.js can't bundle dynamic plugin paths — only static imports. The barrel file gives Next a clean import graph.
  4. Renderer dispatch (runtime, browser). CanvasRail's switch handles built-in types as today. The default branch looks up canvasPluginRenderers[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.

TIER 4

Promote a renderer plugin into core

rare · core PR · ecosystem consensus

Once 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.

The contract — and the honest trust statement. Three rules across all four tiers: anyone supplies any data in any supported type (no permission needed); anyone ships a new type via plugin (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:

5c. Canvas-id namespacing

Two separate concerns, often conflated:

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.

Codex passes
13
Bugs fixed
18
CodeQL findings
1
P1 in final
0

Security

ReDoS regex (CodeQL js/redos) replaced with lastIndexOf
lstat instead of stat — symlink to /dev/zero can't bypass the size cap
Reserved core- id prefix — agent can't shadow the synthesized canvas
No HTML pass-through in markdown renderer — XSS-safe by construction
.ao/ excluded from synthesized diffs — AO metadata doesn't leak
NUL on Windows for git diff --no-index null sentinel

Correctness

Per-effect cancellation + sequence-guarded poll responses — no stale wins
Authoritative session.projectId — not prefix-matching
origin/<base> preferred over stale local refs for merge-base
Untracked-file synthesis with file count + byte budget caps
Non-regular files (FIFO, socket) skipped — git diff can't block
Working-tree diff (not ...HEAD) — uncommitted changes show up

Robustness

Partial-stdout recovery on oversized diffs — truncate, don't disappear
Empty workspacePath returns empty array, not 404
Empty hunk lines emitted as context — non-standard generators don't misalign
Diff parser preserves +++/--- content lines, strips marker prefix
Size caps: 256 KB / canvas, 32 / session, 200 files / 5000 lines / diff
Visibility-aware polling pause — hidden tab doesn't burn API calls

7. File map

FileWhat it owns
core/types.tsCanvasArtifact, CanvasType, CanvasProducer, CanvasDiffFile, CanvasTableColumn, CanvasStatMetric
core/canvas-schema.tsZod discriminated union for runtime validation
core/canvas-log.tsreadCanvases() (file reader, validates, size-caps, drops core-) + synthesizeGitDiffCanvas() + diff parser
api/sessions/[id]/canvasesGET endpoint — merges file canvases + synthesized, sorts by updatedAt
hooks/useSessionCanvases5s poll, sequence guard, visibility-aware pause, session reset
components/CanvasRailRight-side rail, collapse/expand, type-switch dispatch
CanvasMarkdownMinimal markdown renderer (headings, bold, italic, code, lists)
CanvasDiffPer-file diff blocks, collapsible, add/del/context coloring
CanvasTableTabular renderer, sticky header, alignment, boolean display
CanvasStatsMetric grid, tone-mapped colors
SessionDetailMounts 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

VersionAddsStatus
v0.1File reader, GET endpoint, 4 renderers, synthesized git-diff, right-rail with auto-expand, 5s REST pollShipped
v0.2CanvasProducer.listCanvases invoked on agent / scm / tracker plugins (Tier 2 above)Queued
v0.3Mux WebSocket topic for live updates, replacing 5s pollQueued
v0.4canvas-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
MobileBottom-sheet or full-screen takeover for canvases on small viewportsDeferred
Sandboxed iframeRuntime-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:

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 renderUse
Formatted text with bold / italic / headings / code / listsmarkdown (the v0.1 parser handles all of this safely — no HTML pass-through)
Code changesdiff
Structured rowstable
Metric cardsstats
Custom UI (flame graph, Gantt, network diagram)canvas-renderer plugin (v0.4)
Genuinely arbitrary, untrusted markupSandboxed 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. Known trade-offs in v0.1

Honest acknowledgment of where the v0.1 surface has real limits:

12. Deliberate non-goals