File size: 12,644 Bytes
ee9fcd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# Canvas Extensions

## Status

Implementation plan and v1 contract. The frontend vertical slice may ship behind
Agent Server capability detection while the backend endpoints are implemented.

## Product definition

Canvas Extensions are installable packages that change Agent Canvas itself.
They contribute UI and local product behavior such as routed pages, conversation
panels, renderers, slots, and themes. Skills and plugins change the agent;
Canvas Extensions change the app.

The Customize area remains the single inventory for Skills, Plugins, MCP, and
Canvas Extensions. The inventory item is named **Extensions**; "addon" is an
informal alias only.

## Decisions

1. **The active Agent Server owns extensions.** An extension is installed on the
   computer or container running the Agent Server. Its source, resolved revision,
   files, manifest, and enabled state live there. Canvas only discovers and loads
   extensions from the currently active backend. Switching backends replaces the
   active extension set.
2. **Extension code is trusted, same-realm code.** There is no iframe, worker
   sandbox, or granular permission system. Once enabled, an extension has the
   same ambient browser authority as Canvas code. Shadow DOM may be offered later
   as optional style isolation, but never as a security boundary.
3. **Install and enable are separate.** Installation always produces a disabled
   extension. An agent may install or update an extension, but the user returns
   to Customize -> Extensions and explicitly enables it. In v1 this is a product
   consent invariant, not proof of human presence against an agent that can call
   the same authenticated APIs. A future backend policy may allow agent-driven
   enablement.
4. **Enablement is hot.** Enabling loads and activates the extension without an
   app or Agent Server restart. Disabling unmounts registered surfaces and calls
   lifecycle cleanup. Because code is trusted same-realm JavaScript, cleanup is
   best-effort; an extension can create global effects the host cannot revoke.
5. **Distribution follows plugins, not their runtime.** Install coordinates are
   `source`, optional `ref`, and optional `repo_path`, resolved and pinned by the
   Agent Server. A repository may contain multiple extensions and other artifact
   types under subpaths. Backend-local paths are interpreted on the backend
   machine, never in the frontend process.
6. **Updates preserve enablement.** Refresh resolves and installs a new revision
   atomically and keeps the prior enabled state. The UI shows the resulting
   resolved revision. Since v1 is a trusted-code model, there is no misleading
   permission-diff approval gate. The staged check/apply flow currently exists
   only at the Agent Server service layer; until it is exposed over HTTP, the
   Customize UI offers no Refresh action.

## Trust disclosure

Before enabling, Canvas says plainly that the extension can access and modify the
Canvas page and can make authenticated requests available to the current browser
session. The review screen shows source, requested ref, resolved revision,
manifest metadata, and contributed surfaces. It does not show fictitious
fine-grained permissions.

Install and update are still meaningful trust actions because they select the
code revision stored by the backend. Enable is the explicit point at which Canvas
executes that code.

## Package contract

The manifest filename is `canvas-extension.json`.

```json
{
  "schema_version": 1,
  "name": "example-dashboard",
  "display_name": "Example dashboard",
  "version": "0.1.0",
  "description": "A backend-specific project dashboard.",
  "entrypoint": "dist/extension.js",
  "contributes": {
    "pages": [
      {
        "id": "dashboard",
        "title": "Dashboard",
        "path": "/dashboard",
        "nav_label": "Dashboard"
      }
    ]
  }
}
```

Rules for v1:

- `name` and contribution IDs use lowercase letters, digits, and hyphens.
- Page `path` values are absolute kebab-case routes (leading `/`); Canvas
  mounts them relative to `/extensions/{name}`.
- `entrypoint` is a path inside the installed package.
- The entrypoint is one self-contained browser ESM bundle. It must not contain
  unresolved bare imports or external chunks; dependencies, CSS, and small
  assets are bundled or embedded by the authoring template.
- The backend validates that the manifest, entrypoint, and any future asset path
  remain inside the installed extension root.
- Host compatibility fields will be added before marketplace distribution. The
  initial schema is intentionally small while the page ABI is proven by a
  separately built sample extension.

The v1 module exports `activate`:

```ts
export function activate(host: CanvasExtensionHost): void | (() => void) {
  return host.registerPage("dashboard", ({ container, path, navigate }) => {
    container.textContent = `Extension route: ${path}`;
    return () => container.replaceChildren();
  });
}
```

`CanvasExtensionHost` is versioned independently from the manifest. The first
host API contains:

- `apiVersion: "1"`
- immutable extension/backend metadata
- `registerPage(id, mount)` for page factories declared by the manifest
- `navigate(path)` using Canvas base-path-aware routing
- `agentServer.request(...)`, an authenticated request helper targeting the
  extension's owning backend

The runtime fetches the bundle as authenticated text and imports it through a
temporary Blob URL. A direct `<script src>` or `import(backendUrl)` cannot carry
`X-Session-API-Key`, so it is not the v1 loading path. Importing the bundle does
not activate it; Canvas calls `activate` only for an enabled installation.

## Agent Server API

The API deliberately mirrors plugin distribution management while remaining a
separate runtime:

| Method   | Path                                              | Purpose                                                   |
| -------- | ------------------------------------------------- | --------------------------------------------------------- |
| `GET`    | `/api/canvas-extensions/installed`                | List installed extensions and parsed manifests            |
| `POST`   | `/api/canvas-extensions/install`                  | Install from git or a backend-local path; always disabled |
| `GET`    | `/api/canvas-extensions/installed/{name}`         | Read one installation                                     |
| `PATCH`  | `/api/canvas-extensions/installed/{name}`         | Set enabled state                                         |
| `DELETE` | `/api/canvas-extensions/installed/{name}`         | Uninstall                                                 |
| `GET`    | `/api/canvas-extensions/installed/{name}/bundle`  | Return the entrypoint as JavaScript text                  |

A refresh endpoint (`POST /api/canvas-extensions/installed/{name}/refresh`) is
planned but not part of the current router; the service-layer staged check/apply
flow backs it and is tracked separately.

The list endpoint wraps its results in a `canvas_extensions` array.

Install request:

```json
{
  "source": "github:owner/repository",
  "ref": "main",
  "repo_path": "extensions/example-dashboard",
  "force": false
}
```

Installed response:

```json
{
  "name": "example-dashboard",
  "version": "0.1.0",
  "description": "A backend-specific project dashboard.",
  "enabled": false,
  "source": "github:owner/repository",
  "resolved_ref": "6f5f9a...",
  "repo_path": "extensions/example-dashboard",
  "installed_at": "2026-08-01T12:00:00Z",
  "install_path": "/home/user/.openhands/canvas-extensions/installed/example-dashboard",
  "manifest": {}
}
```

The `manifest` field (parsed `canvas-extension.json`, including
`contributes.pages`) is not returned by the current router yet; until it ships,
Canvas treats it as optional and renders installations without page
contributions or a display name.

The backend must prevent install requests from setting `enabled: true`. The
frontend's enable control calls the patch endpoint. Backend authorization and
multi-user installation scope follow the Agent Server deployment's existing
session trust model; v1 does not introduce a separate approval credential.

Until the API exists, Canvas treats HTTP 404 as "this backend does not support
Canvas Extensions". Customize shows an upgrade message, while the global runtime
stays silent. It must not collapse unsupported, unreachable, and an empty
inventory into the same state.

## Frontend runtime

The runtime is keyed by backend ID, organization scope, and connection revision.
This prevents an enabled bundle or cached inventory from one backend appearing
on another after a switch or reconnect.

For each enabled installation:

1. Fetch the authenticated entrypoint text from the owning backend.
2. Import the self-contained ESM bundle from a Blob URL.
3. Validate `activate` and its host API version.
4. Create an extension-scoped registry and call `activate`.
5. Admit registrations only when their IDs were declared in the manifest.
6. Render each surface inside an error boundary owned by Canvas.
7. On disable, uninstall, update, or backend switch, unmount every surface,
   invoke the extension disposer, and remove its registry entries.

The initial route shape is
`/extensions/{extension-name}/{declared-page-path}`. `/extensions` itself is the
Customize inventory. All routing goes through React Router so `VITE_BASE_PATH`
continues to work.

## Delivery plan

### Slice 1: contract and management

- Land this spec and shared TypeScript manifest/installation types.
- Add a backend-keyed service and query hooks for list, install, enable/disable,
  uninstall, and authenticated bundle fetch.
- Add Customize -> Extensions with an unsupported-backend state, source install
  form, source/revision/contribution review, and explicit enable control.
- Keep the global runtime silent when the active backend returns 404.

### Slice 2: routed page proof

- Add the versioned host API, module loader, lifecycle registry, and error
  boundary.
- Add dynamic page routes and left-rail navigation for enabled page
  contributions.
- Build a standalone fixture extension with the same scaffold authors will use.
- Verify enable, hot activation, navigation, disable cleanup, update, backend
  switching, session-key-authenticated bundle loading, and `VITE_BASE_PATH`.

### Slice 3: theme proof

- Define a code-free theme contribution with a strict Canvas token schema.
- Validate theme manifests on the backend and surface installed themes in
  Settings.
- Keep theme selection browser/user scoped while availability follows the active
  backend; fall back cleanly when switching away from the providing backend.

### Slice 4: conversation surfaces

- Replace the closed conversation tab union with namespaced runtime IDs and
  centralized persistence admission.
- Add extension tabs/panels, then host-owned header/footer/badge slots.
- Define mobile behavior, ordering, conflicts, missing-extension fallback, and
  per-conversation lifecycle context before opening each surface to authors.

### Slice 5: visualizers and authoring loop

- Add augment/replace visualizer registrations with explicit event matching,
  precedence, and built-in fallback.
- Publish the stable extension SDK types, bundler template, validation command,
  and sample repository.
- Only then ship `/build-canvas-extension`, including backend-local versus remote
  workspace guidance, temporary install, manual enable instructions, and git
  publication/update flow.

## Verification

Each slice adds service and runtime unit tests. The routed page slice also adds a
mock-LLM end-to-end spec (`tests/e2e/mock-llm/canvas-extensions/`) that drives
the production build through install → enable → page render → disable →
uninstall. Until the pinned agent-server ships the endpoints, the spec serves
the API contract from `src/fixtures/canvas-extensions/demo-page` through
Playwright route interception; once it does, the stub is removed and the same
steps run against the real backend.
Changing the mock-LLM feature layout or mapping requires updating `AGENTS.md` in
the same change. Before merge, run `npm run lint`, `npm test`, `npm run build`,
and `npm run build:lib`.

## Explicit non-goals for v1

- iframe/worker isolation or enforceable fine-grained permissions
- arbitrary lifecycle scripts during installation
- automatic enablement by agents
- marketplace ranking or publisher signing
- conversation tabs, slots, or visualizer replacement in the first vertical
  slice
- treating `@openhands/extensions` as an installable runtime format