Manifest validation, disabled-by-default installs, persistence, staged refresh machinery, REST management, and bundle serving are merged in software-agent-sdk.
Canvas Extensions architecture teardown
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.
The management UI, Blob-module loader, runtime registry, routed pages, and sidebar integration live on a feature branch, not Agent Canvas main.
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
| Thing | Changes | Runs where | Current format |
|---|---|---|---|
| Skill | The agent's instructions and resources | Prompt / agent context | SKILL.md package |
| Plugin | A bundle of agent capabilities such as skills and hooks | SDK / agent runtime | plugin.json or compatible plugin format |
| MCP integration | Tools and resources reachable through a protocol | Separate process or remote service | MCP server configuration |
| Canvas Extension | The Canvas application UI itself | The user's browser, in Canvas's JavaScript realm | canvas-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
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
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.
The manifest declares allowed page IDs. Runtime code must register one of those IDs. This keeps navigation reviewable before execution and rejects surprise contributions.
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.
activate(), registerPage(), and page mounts may return cleanup functions. Backend switches, disables, updates, and route unmounts run those functions in reverse order.
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.
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
- Install is not execute. New and manually discovered Canvas Extensions are forced disabled, and the UI asks before first enable.
- Filesystem containment is checked twice. Textual
.., absolute entrypoints, missing files, directories, dangling links, symlink cycles, and symlink escapes are rejected. The bundle path is resolved again immediately before serving. - Backend switching is modeled, not patched. Backend identity is present in query keys, activation identity, and authenticated request clients.
- Contribution namespaces are simple. Routes live under
/extensions/{extension-name}/..., so two extensions can both contribute/dashboardwithout colliding. - The first contribution type is narrow. Proving pages before themes, conversation panels, slots, and event visualizers is the correct delivery order.
- The spec tells the truth about trust. It explicitly says same-realm code is trusted and cleanup is best-effort. That honesty is rare and valuable.
The hard truth
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.
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.
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.
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.
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.
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.
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.
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
| Dimension | Grade | Why |
|---|---|---|
| Repository ownership | A− | Agent Server owns installed bytes; Canvas owns presentation. The missing generated client keeps it from an A. |
| Filesystem defense | A | Containment is specific, symlink-aware, repeated at serve time, and heavily tested. |
| Backend/state scoping | A− | Backend ID, org, and connection revision are first-class. Real-backend verification is still missing. |
| Extension ABI | B− | The DOM mount contract is lean and durable; compatibility negotiation, deterministic ordering, assets, and author tooling are absent. |
| Update correctness | C+ | Thoughtful staging and rollback, undermined by the separate destructive force-install path and non-transactional metadata. |
| Contract discipline | D+ | Handwritten frontend types already disagree with the backend, the version field is not enforced, and capability detection is a 404. |
| Runtime isolation | F | There is none by explicit design. This is acceptable only under a narrow trusted-code product promise. |
| Platform completeness | D | One 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:
| Identity | Required design |
|---|---|
| Trusted local extensions | Keep 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 / marketplace | Move 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
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.
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.
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.
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.
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.
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.
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.
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
- software-agent-sdk #4361 — manifest validation and entrypoint containment
- software-agent-sdk #4364 — persistence and disabled-by-default behavior
- software-agent-sdk #4374 — staged refresh and swap
- software-agent-sdk 2002f9804 — REST router, bundle delivery, parsed manifests
- Agent Canvas a52dc90d — frontend vertical slice
- tracking epic #16289 — current phased plan and completion state
- Agent Canvas #16492 — origin-readable backend API keys
- software-agent-sdk #4354 — generated client and capability discovery gap