← Home

Canvas Extensions architecture teardown

Studied from software-agent-sdk through 2002f9804 and the unmerged Agent Canvas vertical slice at a52dc90d, plus the SVG-edit example. Current-state check: 27 August 2026.

A Canvas Extension is a JavaScript bundle installed beside an Agent Server and loaded by Agent Canvas to add UI. In v1, “UI” means a page and a sidebar link. The backend stores, validates, and serves the bundle; the browser imports it and hands it a small host API.

The blunt version: this is a promising, thoughtfully defended trusted local page-loader. It is not yet a general extension platform, a safe marketplace runtime, or a capability-secured plugin system. Calling it any of those today would be architecture by aspiration.

Backend core: real

Manifest validation, disabled-by-default installs, persistence, staged refresh machinery, REST management, and bundle serving are merged in software-agent-sdk.

Frontend: branch, not product

The management UI, Blob-module loader, runtime registry, routed pages, and sidebar integration live on a feature branch, not Agent Canvas main.

Platform: mostly future tense

The public tracker reports 5 of 17 tasks complete. Typed client generation, real-backend integration, CLI, audit, policy, themes, panels, and visualizers remain open.

First: do not confuse the four extension nouns

ThingChangesRuns whereCurrent format
SkillThe agent's instructions and resourcesPrompt / agent contextSKILL.md package
PluginA bundle of agent capabilities such as skills and hooksSDK / agent runtimeplugin.json or compatible plugin format
MCP integrationTools and resources reachable through a protocolSeparate process or remote serviceMCP server configuration
Canvas ExtensionThe Canvas application UI itselfThe user's browser, in Canvas's JavaScript realmcanvas-extension.json + one browser ESM bundle

The naming is defensible only if this boundary stays crisp: skills and plugins change what the agent can do; Canvas Extensions change what the human sees and can operate. The distribution plumbing is shared with plugins. The runtime is deliberately not.

The package: tiny on purpose

my-dashboard/
├── canvas-extension.json
└── dist/
    └── extension.js       # self-contained browser ESM; no chunks or bare imports
{
  "schema_version": 1,
  "name": "my-dashboard",
  "display_name": "My dashboard",
  "version": "0.1.0",
  "entrypoint": "dist/extension.js",
  "contributes": {
    "pages": [
      { "id": "home", "title": "Dashboard", "path": "/home" }
    ]
  }
}

The bundle exports one function. It does not receive React. It receives a host object and registers an imperative DOM mount callback:

export function activate(host) {
  return host.registerPage("home", ({ container, path, navigate }) => {
    container.textContent = `Nested path: ${path}`;
    return () => container.replaceChildren();
  });
}

This choice is better than it looks. Passing an HTMLElement rather than Canvas's React internals avoids coupling every extension to the host's React version, component tree, bundler, and private design system. The price is a rougher authoring experience and weaker UI consistency, but the ABI is much more survivable.

Who owns what

software-agent-sdkThe generic fetch/install substrate, Canvas-specific manifest model, installed-state adapter, filesystem checks, staged refresh functions, and Agent Server HTTP router.
typescript-clientIt should own the generated browser contract for those routes. It does not yet; the cutover task remains open.
Agent CanvasBackend-scoped querying, install/enable UI, authenticated bundle fetch, dynamic import, host API, contribution registry, routes, mounts, and sidebar presentation.
Extension authorThe manifest and a single bundled ESM file. On enable, that code becomes as trusted as Canvas's own frontend code.

The whole mechanism

sequenceDiagram
  actor U as User
  participant UI as Agent Canvas
  participant AS as Agent Server
  participant IM as InstallationManager
  participant FS as ~/.openhands/canvas-extensions
  participant JS as Browser JS realm

  U->>UI: Install source + optional ref/subpath
  UI->>AS: POST /api/canvas-extensions/install
  AS->>IM: fetch, parse, validate, copy, record
  IM->>FS: package + .installed.json (enabled=false)
  AS-->>UI: installation + parsed manifest
  U->>UI: Enable after trust warning
  UI->>AS: PATCH installed/name { enabled: true }
  UI->>AS: GET installed/name/bundle (authenticated)
  AS->>FS: re-read manifest + re-check containment
  AS-->>UI: JavaScript text
  UI->>JS: Blob URL + dynamic import
  JS-->>UI: activate(host)
  JS->>UI: host.registerPage(declaredId, mount)
  UI->>UI: add route target + sidebar item
  U->>UI: Open /extensions/name/page
  UI->>JS: mount({ container, path, navigate })
    

Why the Blob detour? The bundle endpoint requires the Agent Server session key. A module import() cannot attach that header, so Canvas fetches text with its authenticated client, creates a temporary blob: URL, imports it, then revokes the URL.

The design patterns hiding underneath

ports & adaptersOne generic installer, many formats

InstallationManager[T] owns fetching, copying, metadata, and enablement. An InstallationInterface[T] teaches it how to load a Canvas manifest, plugin, skill, or future artifact from a directory.

manifest + registryDeclare first, register second

The manifest declares allowed page IDs. Runtime code must register one of those IDs. This keeps navigation reviewable before execution and rejects surprise contributions.

facadeA small host API

The extension receives metadata, navigation, page registration, and an Agent Server request helper instead of being handed Canvas services directly. This is an API-shaping boundary—even though it is not a security boundary.

lifecycleDisposers all the way down

activate(), registerPage(), and page mounts may return cleanup functions. Backend switches, disables, updates, and route unmounts run those functions in reverse order.

two-phase updateCheck, then apply

Refresh fetches into .staging/, validates there, then moves the old directory aside and the candidate into place. A failed second rename rolls the first one back.

scope as identityThe backend is part of state

React Query keys and the activation signature include backend ID, organization, and connection revision. Requests close over the owning backend, preventing a late active-backend switch from redirecting extension traffic.

What the design gets genuinely right

The hard truth

security The host API is not a capability boundary

The bundle runs in the same window as Canvas. It can ignore host.agentServer, read the DOM, patch globals, call fetch, inspect storage, and exfiltrate anything script can reach. Today that includes plaintext backend API keys in localStorage; #16492 calls this out directly. The separate enable step is consent UX, not human-presence enforcement: any client with the same session authority can call the PATCH route. A permission manifest cannot repair either fact.

contract drift There is no single wire contract yet

The frontend hand-writes types and uses generic client methods because the generated client work is open. The frontend declares nav_label and description on pages; the backend model does not, and Pydantic silently drops those extras. The backend accepts any integer schema_version; TypeScript pretends only 1 exists.

lifecycle activate() is not the start of execution

ES modules execute top-level code during import(), before Canvas calls activate. Disposers are voluntary. Event listeners, timers, monkey patches, DOM changes, and network calls survive disable unless the extension cleans them up correctly. “Hot disable” removes registered surfaces; it cannot revoke code.

transactions “Atomic update” overstates the guarantee

The staged swap rolls back a failed rename, which is good. But a process crash between the two renames can still strand the active install, and metadata is updated afterward. Worse, the exposed force=true install path still uses delete-then-copy from the generic manager. Metadata writes have no lock and are not atomic, so concurrent mutations can lose state or leave truncated JSON.

identity The runtime keys revisions, not bytes

Activation identity uses name, version, resolved Git ref, and declared pages—not a bundle digest. Reinstalling changed local code with the same version, or modifying installed bytes in place, need not trigger hot reload. Provenance is shown; content identity is absent.

reliability An error boundary cannot contain arbitrary JavaScript

The route catches the mount promise and reports activation failures. It cannot catch later timer callbacks or browser event handlers, undo mutations, cap CPU, stop memory growth, or restore patched globals. The implementation has a failure UI, not fault isolation.

product surface It is a page extension, singular

V1 contributes pages. Themes, conversation panels, header/footer slots, badges, visualizers, marketplace distribution, signing, CLI management, and a stable authoring kit are roadmap items. “Canvas Extensions” is the future umbrella; the implemented ABI is one registry method.

verification The two halves have not earned integration confidence

The backend has extensive focused tests and the frontend has useful mocks. The public cutover task states there are zero frontend tests against real Agent Server logic, and the multi-backend verification issue is still open. The seam most likely to drift is precisely the seam not exercised end to end.

The SVG-edit example illustrates the ambiguity. Its Canvas bundle is same-realm code, but that code creates an iframe pointing at pinned-version files on unpkg.com and grants clipboard read/write. The iframe sensibly isolates SVG-edit's CSS and keyboard handlers, yet the extension loader itself remains fully privileged. It is a good compatibility demo, not evidence of a safe extension boundary.

Architecture scorecard

DimensionGradeWhy
Repository ownershipA−Agent Server owns installed bytes; Canvas owns presentation. The missing generated client keeps it from an A.
Filesystem defenseAContainment is specific, symlink-aware, repeated at serve time, and heavily tested.
Backend/state scopingA−Backend ID, org, and connection revision are first-class. Real-backend verification is still missing.
Extension ABIB−The DOM mount contract is lean and durable; compatibility negotiation, deterministic ordering, assets, and author tooling are absent.
Update correctnessC+Thoughtful staging and rollback, undermined by the separate destructive force-install path and non-transactional metadata.
Contract disciplineD+Handwritten frontend types already disagree with the backend, the version field is not enforced, and capability detection is a 404.
Runtime isolationFThere is none by explicit design. This is acceptable only under a narrow trusted-code product promise.
Platform completenessDOne contribution type, branch-only frontend, no CLI, no marketplace trust chain, and no real vertical test.

Should same-realm JavaScript be rejected?

Not automatically. VS Code extensions, browser extensions, and desktop plugins all make different trust trades. A local, self-hosted tool can legitimately say: “this is code you trust as much as the app.” Same-realm execution buys rich integration, low latency, a tiny ABI, and freedom from an RPC serialization layer.

But the product must choose one of two honest identities:

IdentityRequired design
Trusted local extensionsKeep same-realm loading, label it developer mode, restrict installation policy, solve origin-readable secrets, add audit/integrity/recovery, and never market manifest permissions as enforcement.
Installable ecosystem / marketplaceMove third-party UI into a unique-origin iframe or similarly isolated realm. Communicate through a versioned, permission-checked RPC broker with short-lived, extension-scoped capabilities. Signing and review then become meaningful additions rather than theater.

The worst option is the middle: same-realm code with a permissions screen that creates the feeling of containment. The current spec avoids that lie. It should keep avoiding it.

What I would change, in order

P0

Do not ship broadly while backend keys remain script-readable. Resolve #16492, or make extensions an unmistakable local developer-mode feature with no marketplace story.

P0

Generate the client contract and validate it at runtime. Make the Agent Server OpenAPI schema authoritative; enforce manifest version 1; reject unknown fields; include nav_label only if both sides own it; add explicit capability discovery instead of 404 inference.

P0

Add one real vertical test. Install a fixture through the real Agent Server, confirm disabled state, enable it, fetch authenticated bytes, mount its page, switch backends, disable, and prove cleanup. Mocks should support that test, not substitute for it.

P1

Make installation transactional. Stage every install—including force reinstall—then swap. Lock per extension plus the metadata ledger; write metadata through temp-file + fsync + rename; recover interrupted swaps on startup.

P1

Give content a real identity. Persist and return a bundle SHA-256, include it in activation/cache identity, verify it when serving, and show it beside the resolved Git revision.

P1

Turn policy into code. Add maximum bundle/package sizes, reserved routes, allowed source policy, local-path policy, audit events, and deterministic contribution ordering. Keep the default “new install is disabled” inside the generic install policy instead of repairing a default-true base model afterward.

P2

Publish an authoring contract only after it survives. Provide an SDK package, bundler template, validator, source maps, CSS-token guide, compatibility negotiation, and a realistic sample that uses the Agent Server bridge—not just textContent or a remote iframe.

P2

Design isolation before marketplace distribution. A unique-origin iframe plus brokered RPC is boring compared with same-realm magic. Boring is the correct property for third-party code holding a path toward conversations, workspaces, terminals, and secrets.

The final judgment

The code has good instincts. It separates agent extensions from UI extensions. It uses a generic install adapter rather than copying plugin machinery. It treats backend identity as state, rechecks path containment at the last responsible moment, makes installation non-executing by default, and starts with one narrow contribution type.

Its weakness is not sloppy implementation. Its weakness is an unresolved product identity. The machinery is built like the beginning of an ecosystem, while the runtime trust model is suitable only for code the user would happily paste into Canvas's own source tree. Until that choice is resolved, every new contribution type multiplies authority faster than it multiplies value.

Recommendation: ship this as experimental trusted-local extensibility after the P0 contract and secret work. Learn from real page extensions. Do not call it a safe plugin platform, and do not add themes, panels, or visualizers merely to make the catalog look substantial. First make one page extension boringly installable, identifiable, recoverable, auditable, and honest.

Source trail

← notebook home  ·  tool visualizers →  ·  implementation tracker →