You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AHP has no Canvas concept on the wire. There is no state slot, no action family, no method, and no capability flag for it — grep -ri canvas against main returns nothing in types/, docs/, schema/, or any language client.
Several JSON-RPC agent runtimes ship a Canvas surface: declarative, interactive UI surfaces (an editor, a live browser, a terminal, or arbitrary extension-authored HTML) that the agent can open, drive, and close through a small set of tools, and that a client renders in a side panel. A runtime fronted by an AHP client has nowhere to put those surfaces today — the agent's "open a canvas" call reaches the runtime and then dies, because AHP gives it no way to reach the renderer.
This proposal adds Canvas to AHP by following the two patterns AHP already uses for exactly this class of problem:
The MCP Apps pattern — a client capability opt-in plus a small discovery surface on session state — for "here is an opaque, client-rendered UI surface and here is what the host will do for it."
The stateful per-resource channel pattern used by terminals, changesets, and the resource-watch channel — one channel per open instance, carrying that instance's live state and its lifecycle traffic.
Crucially, the runtime→provider callbacks that back a canvas (open / invoke action / close) are request/response. AHP already models bidirectional request/response: the resource* family and createResourceWatch appear in both CommandMap and ServerCommandMap and may be initiated by either peer (see types/common/messages.ts). Canvas provider callbacks map onto that existing machinery directly, instead of being simulated with correlated state.
The result is a purely additive surface: one client capability, a small session-state registry, a per-instance ahp-canvas:/<id> channel, the provider request methods, and a small set of channel actions — plus an in-band content path (canvasReadResource + the canvas/message bridge, §10) so a canvas renders and stays interactive even when the client has no direct route to the host, as in a relayed / broker-fronted deployment. Clients that do not opt in see nothing.
Motivation
What "Canvas" means
The shape below is reverse-engineered from a real, shipping JSON-RPC agent runtime, so maintainers don't have to invent the semantics. The runtime splits Canvas across three roles:
Provider — a connection that declares zero or more canvases and installs request/response callbacks for canvas.open, canvas.close, and canvas.action.invoke. A provider may be the runtime host process itself (a built-in or in-process extension) or a separate connection (e.g. an extension child process). A session may have several providers.
Runtime — maintains the aggregated registry across all connected providers, routes the agent's canvas calls to the owning provider, and tells consumers which canvases are open.
Agent — the model. Discovers canvases, and opens / drives / closes them through runtime-owned tools. The agent is opaque to AHP; it never sees the wire format.
The runtime's own wire surface, for reference shape only:
Direction
Call
Returns
Agent → Runtime
list / list-open / open / close / invoke-action
discovery + open-instance results
Runtime → Provider
canvas.open
{ url?, title?, status? } | error
Runtime → Provider
canvas.action.invoke
provider-defined value | error
Runtime → Provider
canvas.close
() | error
Runtime → Consumer
open / update / close / registry-changed events
live + durable-resume state
Three orthogonal nouns AHP has to model:
Noun
Identity
Cardinality
Lifecycle
Declaration
(extensionId, canvasId)
Many per session; varies as providers come and go
Lives as long as the declaring provider is connected
Action
(canvasId, actionName)
Many per declaration
Same as declaration
Open instance
instanceId (caller-minted)
Many per declaration
Persists until closed; on provider disconnect the runtime keeps the entry and marks it stale
Two provider locations matter to AHP, and the design hinges on separating them:
Server-side provider — a provider that lives inside the host process. The agent's open resolves host-internally; the provider returns a render URL; the client's only job is to render that URL. No AHP request leaves the host for the provider callback.
Client-side provider — the AHP renderer itself declares native canvases (e.g. editor, browser, terminal) that it both provides and renders. The agent's open must be routed to that client as a request; the client renders natively and returns render metadata.
The provider callbacks are request/response — the runtime blocks on the provider's return value (or an error envelope with code / message). The rendered content behind url is opaque to the protocol, exactly like an MCP App View. Open instances are durable: they survive session resume.
Where AHP is blocked today
A client that is the natural canvas renderer has no wire to publish its own canvases on. A desktop client that can host an editor / browser / terminal surface can't declare those anywhere. They aren't tools (the agent doesn't invoke them by name with JSON-Schema input; it opens them by id and drives them over a separate action family).
The agent's open cannot reach a client renderer/provider at all. There is no AHP channel or request by which "open this canvas" reaches the client, and no way for a client-declared provider to answer the open.
Session state carries no canvas state. A subscriber can't see what's declared or open; a reconnecting client can't rebuild it.
Canvas content has no in-band delivery path. A client is handed a url and expected to load it directly. When the host can't expose an endpoint the client can reach — a relayed or broker-fronted deployment — the content is unreachable, so the canvas renders blank even though every control message arrived.
Why a channel, and why request/response
The two things Canvas needs — (a) a durable, resumable, per-instance record of an open surface, and (b) real request/response for the provider callbacks — are both things AHP already does, just not for canvases:
(a) is exactly what TerminalState / ChangesetState / ResourceWatchState are: per-resource channel state, delivered as a snapshot on subscribe/reconnect and mutated by channel actions.
(b) is exactly what the symmetric resource* + createResourceWatch requests are: methods in ServerCommandMap that the server initiates against the client and awaits a typed result on.
Modeling Canvas any other way would mean re-deriving primitives AHP already ships.
Proposed change
1. Client capability
// types/common/commands.ts — ClientCapabilitiesexportinterfaceClientCapabilities{// …existing…/** * Client can render canvases and host client-declared canvas providers — * i.e. it can render an opaque canvas URL in an isolated surface, and it * can answer canvasOpen / canvasInvokeAction / canvasClose requests for * canvases it declares via SessionActiveClient.canvasProviders. * * Hosts SHOULD only populate SessionState.canvases / SessionState.openCanvases * and only route canvas requests to a client that declared this capability. * Clients that omit it see no canvas surface. */canvas?: Record<string,never>;}
Mirrors mcpApps exactly: a presence flag, empty object = supported, absence = not supported.
2. Discovery surface (session state)
Two small fields; the heavy per-instance state lives on the channel (§4), not here.
// types/channels-session/state.ts — SessionStateexportinterfaceSessionState{// …existing…/** * Aggregated canvas registry currently exposed to the agent — the union * of every connected provider (server-side and client-declared). * Full-replacement via `session/canvasesChanged`. */canvases?: SessionCanvasDeclaration[];/** * Lightweight catalogue of currently-open canvas instances. Each entry * carries the instance's `ahp-canvas:/<id>` channel URI so a subscriber * can subscribe to the full CanvasState and render it. Full-replacement * via `session/openCanvasesChanged`. * * Analogous to how RootState.terminals catalogues live terminals whose * full state lives on each terminal channel. */openCanvases?: OpenCanvasRef[];}// SessionActiveClient — how a client advertises the canvases it provides.exportinterfaceSessionActiveClient{// …existing…/** * Canvas declarations this client contributes. Updated atomically with * `session/activeClientChanged`, identical to the `tools` pattern — there * is no separate change action. The host folds these into * SessionState.canvases with source = { kind: 'client', clientId }. */canvasProviders?: ClientCanvasDeclaration[];}
// Supporting types — types/channels-session/state.ts/** One entry in the aggregated registry. */exportinterfaceSessionCanvasDeclaration{/** Owning provider id. Stable across declarations and instances. */extensionId: string;extensionName?: string;/** Provider-local canvas id. Unique within extensionId. */canvasId: string;displayName: string;description: string;/** JSON Schema for open input. Opaque to AHP; mirrors ToolDefinition.inputSchema shape. */inputSchema?: Record<string,unknown>;actions?: SessionCanvasAction[];/** Where the declaration came from — for routing and cleanup. */source: CanvasProviderSource;}exportinterfaceSessionCanvasAction{/** Unique within (extensionId, canvasId). */name: string;description?: string;inputSchema?: Record<string,unknown>;}/** Discriminated union — no `clientId` on the server variant (invalid states unrepresentable). */exporttypeCanvasProviderSource=|{kind: 'server'}|{kind: 'client';clientId: string};/** Lighter shape a client publishes; the host derives extensionId + source. */exportinterfaceClientCanvasDeclaration{canvasId: string;displayName: string;description: string;inputSchema?: Record<string,unknown>;actions?: SessionCanvasAction[];}/** Lightweight open-instance catalogue entry. Full state is on the channel. */exportinterfaceOpenCanvasRef{instanceId: string;/** The instance's channel URI (`ahp-canvas:/<id>`). Subscribe for CanvasState. */channel: URI;canvasId: string;extensionId: string;extensionName?: string;title?: string;availability: CanvasAvailability;}exportconstenumCanvasAvailability{Ready='ready',Stale='stale',}
Per-open-canvas-instance state. Server-assigned id; clients MUST treat it as opaque. Advertised on SessionState.openCanvases.
4. Canvas channel (ahp-canvas:/<id>)
One channel per open instance (the same "one channel per resource" convention as terminal / changeset / resource-watch).
State — snapshot on subscribe/reconnect, mutated by channel actions:
// types/channels-canvas/state.tsexportinterfaceCanvasState{instanceId: string;canvasId: string;extensionId: string;extensionName?: string;displayName?: string;/** Input the agent supplied when opening. Retained for resume/rebind. */input?: Record<string,unknown>;title?: string;status?: string;/** Renderer-targeted address for the opaque canvas content — a directly-loadable URL or a channel-served `ahp-canvas-content:` URI. See §9 (policy) and §10 (transport). */url?: string;availability: CanvasAvailability;/** Who owns the provider callbacks for this instance. */provider: CanvasProviderSource;}
Actions on the channel (carry channel: "ahp-canvas:/<id>"):
Action
Client-dispatchable
Reducer effect
canvas/updated
No
Partial-merge title / status / url / availability. null clears an optional value; absent preserves it.
canvas/closeRequested
Yes
No-op. Pure client→host signal ("user hit ✕"); the host then runs the close flow (§5). Mirrors how terminal/input is a side-effect-only client action.
canvas/message
Yes
No-op. Bidirectional opaque { payload } bridge between the rendered View and the instance's provider — the relay-carried analogue of postMessage. See §10.
5. Provider request family (server → client)
The provider callbacks are real JSON-RPC requests added to ServerCommandMap. They are sent only to a client that (a) declared capabilities.canvas and (b) is the provider of the target canvas (i.e. declared it via canvasProviders). For server-side providers the host resolves these host-internally and emits no request.
// types/channels-canvas/commands.tsexportinterfaceCanvasOpenParamsextendsBaseParams{channel: URI;// the owning session URI (ahp-session:/<uuid>)canvasId: string;extensionId: string;instanceId: string;// caller-minted handle for this panelinput?: Record<string,unknown>;}exportinterfaceCanvasOpenResult{url?: string;title?: string;status?: string;}exportinterfaceCanvasInvokeActionParamsextendsBaseParams{channel: URI;// ahp-session:/<uuid>instanceId: string;canvasId: string;extensionId: string;actionName: string;input?: Record<string,unknown>;}exportinterfaceCanvasInvokeActionResult{value?: unknown;// opaque, provider-defined}exportinterfaceCanvasCloseParamsextendsBaseParams{channel: URI;// ahp-session:/<uuid>instanceId: string;canvasId: string;extensionId: string;}// result: null
Registered symmetrically, following the resource* precedent:
// ServerCommandMap (server → client) — and mirrored in CommandMap for symmetry'canvasOpen': { params: CanvasOpenParams; result: CanvasOpenResult};'canvasInvokeAction': { params: CanvasInvokeActionParams; result: CanvasInvokeActionResult};'canvasClose': { params: CanvasCloseParams; result: null};
Provider errors are returned as ordinary JSON-RPC errors. Define an application error code — e.g. CanvasProviderError — whose data carries { code: string; message: string } (observed provider codes include canvas_action_no_handler and canvas_provider_unavailable). Cancellation and timeouts are ordinary request-lifecycle concerns — there is no cancellation action or in-state deadline to model.
Note
Rendering is state-driven, not request-driven. A client renders a canvas by subscribing to its ahp-canvas:/<id> channel and reading CanvasState.url — resolving it directly or over the channel per §9/§10; it never receives a "render this" request. The canvasOpen / canvasInvokeAction / canvasClose requests exist only to drive a client-declared provider. This cleanly separates "who renders" (any capable subscriber) from "who provides" (the declaring client, or the host).
6. Lifecycle
sequenceDiagram
participant Agent as Agent (opaque, in host)
participant Host as AHP Host
participant Client as AHP client (capabilities.canvas)
Note over Host,Client: initialize → client declares capabilities.canvas
Note over Host,Client: session → SessionActiveClient.canvasProviders folded into SessionState.canvases
Agent->>Host: open canvas (canvasId, instanceId, input)
alt client-declared provider
Host->>Client: canvasOpen { channel: session, canvasId, extensionId, instanceId, input }
Client-->>Host: { url?, title?, status? }
else server-side provider
Host->>Host: resolve internally → { url, title, status }
end
Host->>Host: mint ahp-canvas:/<id>, build CanvasState
Host->>Client: action session/openCanvasesChanged (catalogue gains the ref)
Client->>Host: subscribe ahp-canvas:/<id>
Host-->>Client: snapshot CanvasState → render url
Agent->>Host: invoke action (instanceId, actionName, input)
alt client-declared provider
Host->>Client: canvasInvokeAction {...} → { value? }
else server-side provider
Host->>Host: resolve internally → value
end
Host->>Client: action canvas/updated (title/status/url deltas)
Client->>Host: dispatchAction canvas/closeRequested (user hit ✕)
alt client-declared provider
Host->>Client: canvasClose {...} → null
else server-side provider
Host->>Host: resolve internally
end
Host->>Client: action session/openCanvasesChanged (ref removed)
Note over Client: unsubscribe ahp-canvas:/<id>
Loading
Provider disconnect. The host dispatches canvas/updated { availability: 'stale' } and keeps the entry. In-flight canvasOpen/canvasInvokeAction/canvasClose requests fail with an ordinary JSON-RPC error. On reconnect the provider re-opens and the host flips availability back to 'ready'.
Resume.SessionState.canvases and SessionState.openCanvases replay in the session snapshot; the client re-subscribes to each ahp-canvas:/<id> and gets a fresh CanvasState. For server-side providers whose url is ephemeral, the entry may be 'stale' (and url absent) until the provider re-opens.
7. Session actions
Two new session-channel actions (both full-replacement, mirroring session/activeClientToolsChanged and the customizations registry):
Append to the validation table in session-channel.md (session action) and add a Canvas section to a new channel page:
Action / request
Condition
Server behaviour
canvas/closeRequested
No open instance with matching instanceId
SHOULD silently ignore (parallels stale tool-call denial)
canvas/closeRequested
Dispatched by a client that is not a subscriber/renderer of the instance
SHOULD reject
canvasOpen/canvasInvokeAction/canvasClose (if a client ever initiates)
Target canvas is not a client-declared provider owned by the peer
SHOULD reject
canvasReadResource
uri is not an ahp-canvas-content: URI scoped to an open instance the peer renders, or the instance is closed
SHOULD reject
canvas/message
No open instance with matching instanceId, or peer is not a subscriber/renderer of it
SHOULD reject
9. URL / render-target policy
CanvasState.url is a plain string; the renderer enforces policy. The spec SHOULD require renderers to implement at least the first two:
Scheme allow-list. Refuse schemes outside an explicit allow-list. Baseline: https, file, data, plus implementation-defined in-process schemes (vscode-webview://, tauri-app://, …). http MAY be allowed only for localhost / 127.0.0.1. The ahp-canvas-content: scheme (§10) is always permitted — the renderer resolves it over the channel rather than loading it as a network URL.
Origin isolation. Sandbox canvas content (iframe sandbox, isolated webview/web-contents) so it cannot read application-scope state or credentials.
Credential handling. Strip Authorization, Cookie, and other ambient credentials on navigation to a canvas URL.
10. Content transport & relayed deployments
Everything above puts the control plane on the wire — discovery, the provider requests (§5), the CanvasState snapshot, and lifecycle. But a canvas is only useful once its content renders, and §9 addresses that content by url. If that url is an endpoint the renderer dials directly (https://…, http://localhost, an in-process scheme), the client must be able to reach it. In a relayed deployment — the host sits behind a broker and has no address the renderer can open a socket to — a direct url is unreachable, and the canvas is blank even though every control message arrived.
So content is addressed one of two ways, and the renderer dispatches on the url scheme:
Renderer can reach the content origin. Fast path; §9 policy applies.
Channel-served
ahp-canvas-content:/<instanceId>/<path>
Reads it over the owning ahp-canvas:/<id> channel via canvasReadResource
Renderer cannot reach the host directly (relayed / broker-fronted).
ahp-canvas-content: is a content-address scheme, not a subscribable channel — it never appears in the §3 channel table; it is resolved by request against the instance's existing ahp-canvas:/<id> channel. The <instanceId> segment tells the renderer which channel to read from.
Static content — canvasReadResource (client → server). A client→host request registered in CommandMap, modeled on MCP's resources/read:
// types/channels-canvas/commands.tsexportinterfaceCanvasReadResourceParamsextendsBaseParams{channel: URI;// the owning ahp-canvas:/<id>uri: string;// an ahp-canvas-content:/<instanceId>/... URI}exportinterfaceCanvasReadResourceResult{contents: CanvasResourceContent[];}exportinterfaceCanvasResourceContent{uri: string;mimeType?: string;text?: string;// for text payloadsblob?: string;// base64 for binary payloads}
The renderer resolves the top-level ahp-canvas-content:url through this request, then serves the bytes into its sandboxed surface (a custom scheme handler / service worker / data: rehydration). Sub-resources resolve the same way: any relative or ahp-canvas-content: reference the document makes is intercepted by the renderer and satisfied by another canvasReadResource, so the surface never touches the network and needs no host endpoint it can reach.
Interactive traffic — the canvas/message bridge. Static reads cover a self-contained document; an interactive View that talks back to its provider (the relay-carried analogue of a postMessage bridge) needs a bidirectional path. The canvas/message channel action (§4) carries it both ways as an opaque { payload }: client→host is a View→provider message, host→client is a provider→View message, routed to the provider resolved per §5 (host-internal for a server-side provider). Like terminal/input and canvas/closeRequested it is a pure signal with a no-op reducer, so it never bloats channel state.
This is additive, and the direct path stays first-class. Channel-served transport is a supported content mode, never a required one:
The host chooses the addressing mode per canvas from its own reachability: a host that can expose a reachable endpoint returns a direct url; a relayed host returns an ahp-canvas-content:url. A renderer that supports Canvas MUST handle both, so it never has to know which deployment it is in.
Optionally a client MAY advertise that it can reach host endpoints directly (a flag on its canvasProviders declaration or the capability), letting the host prefer a direct url when the renderer can use it.
A direct desktop client never pays the channel-transport cost; a relayed client never needs a reachable endpoint. The channel path only adds relay support — it changes nothing for direct rendering.
Implementation scope scales with ambition, so implementors can size it: a self-contained document is a single canvasReadResource round-trip; a rich app that loads its own sub-resources and holds a live bridge additionally needs the renderer to intercept its surface's loads and the host to serve/route them — both ends build that together. Neither is on the path for a host that only ever emits directly-reachable URLs.
11. Versioning
The current PROTOCOL_VERSION is 0.5.1. This surface is additive; introduce it at the next version (e.g. 0.6.0):
Add ACTION_INTRODUCED_IN entries for session/canvasesChanged, session/openCanvasesChanged, canvas/updated, canvas/closeRequested, canvas/message.
Track the new canvasOpen / canvasInvokeAction / canvasClose (server→client) and canvasReadResource (client→server) request methods per the repo's method-versioning convention; they are only exercised when both peers negotiate the version and the client declares capabilities.canvas.
Bump PROTOCOL_VERSION and prepend it to SUPPORTED_PROTOCOL_VERSIONS.
types/channels-session/state.ts — SessionState.canvases, SessionState.openCanvases, SessionActiveClient.canvasProviders, and the supporting declaration/ref types + CanvasAvailability / CanvasProviderSource.
types/channels-session/actions.ts + types/common/actions.ts — the two new ActionType entries + interfaces; extend the session action union.
types/channels-session/reducer.ts — two full-replacement arms.
New types/channels-canvas/ — state.ts (CanvasState), actions.ts (canvas/updated, canvas/closeRequested, canvas/message), commands.ts (the three server→client provider methods + the client→server canvasReadResource), reducer.ts (merge arm + no-op arms). Register the channel in types/index.ts and the message maps in types/common/messages.ts (canvasReadResource in CommandMap; the provider methods in ServerCommandMap, mirrored in CommandMap).
types/version/registry.ts — ACTION_INTRODUCED_IN entries + version bump.
schema/*.json — regenerate.
docs/specification/ — new canvas-channel.md (covering the ahp-canvas-content: content-address scheme and the in-band transport of §10); URI-scheme row in subscriptions.md for ahp-canvas:/<id> (the subscribable channel only); overview channel list; session-channel validation additions.
docs/guide/ — a canvases.md guide (concepts: declaration vs instance vs provider; the server-vs-client-provider split; direct vs channel-served content and relayed deployments; end-to-end flows; security policy) + sidebar entry.
types/test-cases/reducers/ — fixtures: registry replace, open-catalogue replace, canvas/updated merge (+ null-clear, absent-preserve, unknown-instance no-op), canvas/closeRequested no-op, canvas/message no-op. (canvasReadResource is a request, not an action — cover it in request/round-trip fixtures rather than reducer fixtures.)
Compatibility
Purely additive.
All new state fields are optional; all new actions/requests are unknown to older consumers and ignored per the additive-evolution rule.
No changes to existing types, methods, or transport.
Renderer capability is negotiated via capabilities.canvas on initialize — a host offers the canvas surface only to clients that declared it. A client that never declares it, or a host that never populates canvas state, behaves exactly as today.
Considered alternatives
Model provider callbacks as session state + a correlated action pair (requestCreated / requestCompleted / requestCancelled with an in-state deadline and a per-target completion-direction rule). This simulates request/response on top of one-way action notifications. Rejected: AHP already has native bidirectional request/response (ServerCommandMap, the symmetric resource* + createResourceWatch), so the simulation adds a correlation protocol, a shared-state timestamp, and a validation matrix to re-implement something the transport already does. It also broadcasts every in-flight request payload to every session subscriber.
Put the full open-instance state (and all in-flight requests) directly on SessionState. Rejected: it forces every session subscriber — including clients that never render a canvas — to carry the canvas reducer arms and receive all canvas traffic. The per-instance channel + capability opt-in scopes the state and traffic to the clients that render it, matching terminals/changesets/resource-watch.
Tunnel canvases through customizations. Mechanical fit but semantic mismatch — customizations are agent-augmentation sources (plugins, skills, MCP servers) with their own parse/load lifecycle, and conflating them loses the per-instance state Canvas needs.
Leave Canvas as a vendor _meta extension. Fine as a prototype vehicle, not a protocol answer: every client that renders canvases would grow bespoke handling, defeating the point of the protocol.
Open questions
One channel per instance vs. one canvas channel per session. This proposal uses one channel per open instance (matching terminal/changeset/resource-watch). A single per-session ahp-canvas:/<sessionId> channel carrying all instances is simpler to subscribe to and closer to the mcp:// "one side-channel per owner" shape, at the cost of per-instance subscription granularity. Which convention do maintainers prefer?
Render targeting for server-side providers. When several capabilities.canvas clients are connected, which renders a server-side-provider canvas? Options: all capable subscribers render; the host pins a preferred renderer on the OpenCanvasRef; or the requesting client is pinned. (MCP Apps sidesteps this because the App is bound to a specific tool call on a specific client.)
Typed View → provider calls. The canvas/message bridge (§10) already gives a rendered View an opaque channel to its provider. Open question: should a View also get the typed, request/response canvasInvokeAction in the client→server direction (a structured call with a return value, closer to mcp://'s tools/call), or is the opaque bridge sufficient? Confirm before adding a client→server canvasInvokeAction form.
Where the open-instance catalogue lives — SessionState.openCanvases (as proposed) vs a RootState-style catalogue vs a uriTemplate (like changesets). Session-scoped placement is the most natural, but maintainers may prefer consistency with one of the others.
canvasProviders on the active client vs. a dedicated client-declaration action. This proposal updates it atomically with activeClientChanged (like tools); a dedicated change action is the alternative.
References
The mcp:// channel and MCP Apps — the capability-opt-in + discovery-surface + dedicated-channel pattern this proposal follows for an opaque, client-rendered UI surface; MCP's resources/read is the model for the in-band canvasReadResource content path (§10).
Terminal channel and Resource Watch channel — the stateful per-resource channel pattern, and (createResourceWatch) the "mint a channel URI and return it" precedent.
Summary
AHP has no Canvas concept on the wire. There is no state slot, no action family, no method, and no capability flag for it —
grep -ri canvasagainstmainreturns nothing intypes/,docs/,schema/, or any language client.Several JSON-RPC agent runtimes ship a Canvas surface: declarative, interactive UI surfaces (an editor, a live browser, a terminal, or arbitrary extension-authored HTML) that the agent can open, drive, and close through a small set of tools, and that a client renders in a side panel. A runtime fronted by an AHP client has nowhere to put those surfaces today — the agent's "open a canvas" call reaches the runtime and then dies, because AHP gives it no way to reach the renderer.
This proposal adds Canvas to AHP by following the two patterns AHP already uses for exactly this class of problem:
Crucially, the runtime→provider callbacks that back a canvas (
open/invoke action/close) are request/response. AHP already models bidirectional request/response: theresource*family andcreateResourceWatchappear in bothCommandMapandServerCommandMapand may be initiated by either peer (seetypes/common/messages.ts). Canvas provider callbacks map onto that existing machinery directly, instead of being simulated with correlated state.The result is a purely additive surface: one client capability, a small session-state registry, a per-instance
ahp-canvas:/<id>channel, the provider request methods, and a small set of channel actions — plus an in-band content path (canvasReadResource+ thecanvas/messagebridge, §10) so a canvas renders and stays interactive even when the client has no direct route to the host, as in a relayed / broker-fronted deployment. Clients that do not opt in see nothing.Motivation
What "Canvas" means
The shape below is reverse-engineered from a real, shipping JSON-RPC agent runtime, so maintainers don't have to invent the semantics. The runtime splits Canvas across three roles:
canvas.open,canvas.close, andcanvas.action.invoke. A provider may be the runtime host process itself (a built-in or in-process extension) or a separate connection (e.g. an extension child process). A session may have several providers.The runtime's own wire surface, for reference shape only:
canvas.open{ url?, title?, status? }| errorcanvas.action.invokecanvas.close()| errorThree orthogonal nouns AHP has to model:
(extensionId, canvasId)(canvasId, actionName)instanceId(caller-minted)Two provider locations matter to AHP, and the design hinges on separating them:
editor,browser,terminal) that it both provides and renders. The agent's open must be routed to that client as a request; the client renders natively and returns render metadata.The provider callbacks are request/response — the runtime blocks on the provider's return value (or an error envelope with
code/message). The rendered content behindurlis opaque to the protocol, exactly like an MCP App View. Open instances are durable: they survive session resume.Where AHP is blocked today
editor/browser/terminalsurface can't declare those anywhere. They aren'ttools(the agent doesn't invoke them by name with JSON-Schema input; it opens them by id and drives them over a separate action family).urland expected to load it directly. When the host can't expose an endpoint the client can reach — a relayed or broker-fronted deployment — the content is unreachable, so the canvas renders blank even though every control message arrived.Why a channel, and why request/response
The two things Canvas needs — (a) a durable, resumable, per-instance record of an open surface, and (b) real request/response for the provider callbacks — are both things AHP already does, just not for canvases:
TerminalState/ChangesetState/ResourceWatchStateare: per-resource channel state, delivered as a snapshot onsubscribe/reconnectand mutated by channel actions.resource*+createResourceWatchrequests are: methods inServerCommandMapthat the server initiates against the client andawaits a typed result on.Modeling Canvas any other way would mean re-deriving primitives AHP already ships.
Proposed change
1. Client capability
Mirrors
mcpAppsexactly: a presence flag, empty object = supported, absence = not supported.2. Discovery surface (session state)
Two small fields; the heavy per-instance state lives on the channel (§4), not here.
3. New URI scheme
Add to the URI-scheme table in
subscriptions.md:ahp-canvas:/<id>CanvasStateSessionState.openCanvases.4. Canvas channel (
ahp-canvas:/<id>)One channel per open instance (the same "one channel per resource" convention as terminal / changeset / resource-watch).
State — snapshot on
subscribe/reconnect, mutated by channel actions:Actions on the channel (carry
channel: "ahp-canvas:/<id>"):canvas/updatedtitle/status/url/availability.nullclears an optional value; absent preserves it.canvas/closeRequestedterminal/inputis a side-effect-only client action.canvas/message{ payload }bridge between the rendered View and the instance's provider — the relay-carried analogue ofpostMessage. See §10.5. Provider request family (server → client)
The provider callbacks are real JSON-RPC requests added to
ServerCommandMap. They are sent only to a client that (a) declaredcapabilities.canvasand (b) is the provider of the target canvas (i.e. declared it viacanvasProviders). For server-side providers the host resolves these host-internally and emits no request.Registered symmetrically, following the
resource*precedent:Provider errors are returned as ordinary JSON-RPC errors. Define an application error code — e.g.
CanvasProviderError— whosedatacarries{ code: string; message: string }(observed provider codes includecanvas_action_no_handlerandcanvas_provider_unavailable). Cancellation and timeouts are ordinary request-lifecycle concerns — there is no cancellation action or in-state deadline to model.Note
Rendering is state-driven, not request-driven. A client renders a canvas by subscribing to its
ahp-canvas:/<id>channel and readingCanvasState.url— resolving it directly or over the channel per §9/§10; it never receives a "render this" request. ThecanvasOpen/canvasInvokeAction/canvasCloserequests exist only to drive a client-declared provider. This cleanly separates "who renders" (any capable subscriber) from "who provides" (the declaring client, or the host).6. Lifecycle
sequenceDiagram participant Agent as Agent (opaque, in host) participant Host as AHP Host participant Client as AHP client (capabilities.canvas) Note over Host,Client: initialize → client declares capabilities.canvas Note over Host,Client: session → SessionActiveClient.canvasProviders folded into SessionState.canvases Agent->>Host: open canvas (canvasId, instanceId, input) alt client-declared provider Host->>Client: canvasOpen { channel: session, canvasId, extensionId, instanceId, input } Client-->>Host: { url?, title?, status? } else server-side provider Host->>Host: resolve internally → { url, title, status } end Host->>Host: mint ahp-canvas:/<id>, build CanvasState Host->>Client: action session/openCanvasesChanged (catalogue gains the ref) Client->>Host: subscribe ahp-canvas:/<id> Host-->>Client: snapshot CanvasState → render url Agent->>Host: invoke action (instanceId, actionName, input) alt client-declared provider Host->>Client: canvasInvokeAction {...} → { value? } else server-side provider Host->>Host: resolve internally → value end Host->>Client: action canvas/updated (title/status/url deltas) Client->>Host: dispatchAction canvas/closeRequested (user hit ✕) alt client-declared provider Host->>Client: canvasClose {...} → null else server-side provider Host->>Host: resolve internally end Host->>Client: action session/openCanvasesChanged (ref removed) Note over Client: unsubscribe ahp-canvas:/<id>canvas/updated { availability: 'stale' }and keeps the entry. In-flightcanvasOpen/canvasInvokeAction/canvasCloserequests fail with an ordinary JSON-RPC error. On reconnect the provider re-opens and the host flipsavailabilityback to'ready'.SessionState.canvasesandSessionState.openCanvasesreplay in the session snapshot; the client re-subscribes to eachahp-canvas:/<id>and gets a freshCanvasState. For server-side providers whoseurlis ephemeral, the entry may be'stale'(andurlabsent) until the provider re-opens.7. Session actions
Two new session-channel actions (both full-replacement, mirroring
session/activeClientToolsChangedand the customizations registry):session/canvasesChangedstate.canvases = action.canvasessession/openCanvasesChangedstate.openCanvases = action.openCanvases8. Server validation of client-dispatched actions
Append to the validation table in
session-channel.md(session action) and add a Canvas section to a new channel page:canvas/closeRequestedinstanceIdcanvas/closeRequestedcanvasOpen/canvasInvokeAction/canvasClose(if a client ever initiates)canvasReadResourceuriis not anahp-canvas-content:URI scoped to an open instance the peer renders, or the instance is closedcanvas/messageinstanceId, or peer is not a subscriber/renderer of it9. URL / render-target policy
CanvasState.urlis a plain string; the renderer enforces policy. The spec SHOULD require renderers to implement at least the first two:https,file,data, plus implementation-defined in-process schemes (vscode-webview://,tauri-app://, …).httpMAY be allowed only forlocalhost/127.0.0.1. Theahp-canvas-content:scheme (§10) is always permitted — the renderer resolves it over the channel rather than loading it as a network URL.sandbox, isolated webview/web-contents) so it cannot read application-scope state or credentials.Authorization,Cookie, and other ambient credentials on navigation to a canvas URL.10. Content transport & relayed deployments
Everything above puts the control plane on the wire — discovery, the provider requests (§5), the
CanvasStatesnapshot, and lifecycle. But a canvas is only useful once its content renders, and §9 addresses that content byurl. If thaturlis an endpoint the renderer dials directly (https://…,http://localhost, an in-process scheme), the client must be able to reach it. In a relayed deployment — the host sits behind a broker and has no address the renderer can open a socket to — a directurlis unreachable, and the canvas is blank even though every control message arrived.So content is addressed one of two ways, and the renderer dispatches on the
urlscheme:urlschemehttps:, in-process (vscode-webview://, host-native),http://localhostahp-canvas-content:/<instanceId>/<path>ahp-canvas:/<id>channel viacanvasReadResourceahp-canvas-content:is a content-address scheme, not a subscribable channel — it never appears in the §3 channel table; it is resolved by request against the instance's existingahp-canvas:/<id>channel. The<instanceId>segment tells the renderer which channel to read from.Static content —
canvasReadResource(client → server). A client→host request registered inCommandMap, modeled on MCP'sresources/read:The renderer resolves the top-level
ahp-canvas-content:urlthrough this request, then serves the bytes into its sandboxed surface (a custom scheme handler / service worker /data:rehydration). Sub-resources resolve the same way: any relative orahp-canvas-content:reference the document makes is intercepted by the renderer and satisfied by anothercanvasReadResource, so the surface never touches the network and needs no host endpoint it can reach.Interactive traffic — the
canvas/messagebridge. Static reads cover a self-contained document; an interactive View that talks back to its provider (the relay-carried analogue of apostMessagebridge) needs a bidirectional path. Thecanvas/messagechannel action (§4) carries it both ways as an opaque{ payload }: client→host is a View→provider message, host→client is a provider→View message, routed to the provider resolved per §5 (host-internal for a server-side provider). Liketerminal/inputandcanvas/closeRequestedit is a pure signal with a no-op reducer, so it never bloats channel state.This is additive, and the direct path stays first-class. Channel-served transport is a supported content mode, never a required one:
url; a relayed host returns anahp-canvas-content:url. A renderer that supports Canvas MUST handle both, so it never has to know which deployment it is in.canvasProvidersdeclaration or the capability), letting the host prefer a directurlwhen the renderer can use it.Implementation scope scales with ambition, so implementors can size it: a self-contained document is a single
canvasReadResourceround-trip; a rich app that loads its own sub-resources and holds a live bridge additionally needs the renderer to intercept its surface's loads and the host to serve/route them — both ends build that together. Neither is on the path for a host that only ever emits directly-reachable URLs.11. Versioning
The current
PROTOCOL_VERSIONis0.5.1. This surface is additive; introduce it at the next version (e.g.0.6.0):ACTION_INTRODUCED_INentries forsession/canvasesChanged,session/openCanvasesChanged,canvas/updated,canvas/closeRequested,canvas/message.canvasOpen/canvasInvokeAction/canvasClose(server→client) andcanvasReadResource(client→server) request methods per the repo's method-versioning convention; they are only exercised when both peers negotiate the version and the client declarescapabilities.canvas.PROTOCOL_VERSIONand prepend it toSUPPORTED_PROTOCOL_VERSIONS.12. Files to touch
types/common/commands.ts— addClientCapabilities.canvas.types/channels-session/state.ts—SessionState.canvases,SessionState.openCanvases,SessionActiveClient.canvasProviders, and the supporting declaration/ref types +CanvasAvailability/CanvasProviderSource.types/channels-session/actions.ts+types/common/actions.ts— the two newActionTypeentries + interfaces; extend the session action union.types/channels-session/reducer.ts— two full-replacement arms.types/channels-canvas/—state.ts(CanvasState),actions.ts(canvas/updated,canvas/closeRequested,canvas/message),commands.ts(the three server→client provider methods + the client→servercanvasReadResource),reducer.ts(merge arm + no-op arms). Register the channel intypes/index.tsand the message maps intypes/common/messages.ts(canvasReadResourceinCommandMap; the provider methods inServerCommandMap, mirrored inCommandMap).types/version/registry.ts—ACTION_INTRODUCED_INentries + version bump.schema/*.json— regenerate.docs/specification/— newcanvas-channel.md(covering theahp-canvas-content:content-address scheme and the in-band transport of §10); URI-scheme row insubscriptions.mdforahp-canvas:/<id>(the subscribable channel only); overview channel list; session-channel validation additions.docs/guide/— acanvases.mdguide (concepts: declaration vs instance vs provider; the server-vs-client-provider split; direct vs channel-served content and relayed deployments; end-to-end flows; security policy) + sidebar entry.clients/{rust,kotlin,swift,go}/…— regenerate mirrors + hand-port the reducer arms;clients/typescriptCHANGELOG.types/test-cases/reducers/— fixtures: registry replace, open-catalogue replace,canvas/updatedmerge (+null-clear, absent-preserve, unknown-instance no-op),canvas/closeRequestedno-op,canvas/messageno-op. (canvasReadResourceis a request, not an action — cover it in request/round-trip fixtures rather than reducer fixtures.)Compatibility
Purely additive.
capabilities.canvasoninitialize— a host offers the canvas surface only to clients that declared it. A client that never declares it, or a host that never populates canvas state, behaves exactly as today.Considered alternatives
requestCreated/requestCompleted/requestCancelledwith an in-state deadline and a per-target completion-direction rule). This simulates request/response on top of one-way action notifications. Rejected: AHP already has native bidirectional request/response (ServerCommandMap, the symmetricresource*+createResourceWatch), so the simulation adds a correlation protocol, a shared-state timestamp, and a validation matrix to re-implement something the transport already does. It also broadcasts every in-flight request payload to every session subscriber.SessionState. Rejected: it forces every session subscriber — including clients that never render a canvas — to carry the canvas reducer arms and receive all canvas traffic. The per-instance channel + capability opt-in scopes the state and traffic to the clients that render it, matching terminals/changesets/resource-watch.customizations. Mechanical fit but semantic mismatch — customizations are agent-augmentation sources (plugins, skills, MCP servers) with their own parse/load lifecycle, and conflating them loses the per-instance state Canvas needs._metaextension. Fine as a prototype vehicle, not a protocol answer: every client that renders canvases would grow bespoke handling, defeating the point of the protocol.Open questions
ahp-canvas:/<sessionId>channel carrying all instances is simpler to subscribe to and closer to themcp://"one side-channel per owner" shape, at the cost of per-instance subscription granularity. Which convention do maintainers prefer?capabilities.canvasclients are connected, which renders a server-side-provider canvas? Options: all capable subscribers render; the host pins a preferred renderer on theOpenCanvasRef; or the requesting client is pinned. (MCP Apps sidesteps this because the App is bound to a specific tool call on a specific client.)canvas/messagebridge (§10) already gives a rendered View an opaque channel to its provider. Open question: should a View also get the typed, request/responsecanvasInvokeActionin the client→server direction (a structured call with a return value, closer tomcp://'stools/call), or is the opaque bridge sufficient? Confirm before adding a client→servercanvasInvokeActionform.SessionState.openCanvases(as proposed) vs aRootState-style catalogue vs auriTemplate(like changesets). Session-scoped placement is the most natural, but maintainers may prefer consistency with one of the others.canvasProviderson the active client vs. a dedicated client-declaration action. This proposal updates it atomically withactiveClientChanged(liketools); a dedicated change action is the alternative.References
mcp://channel and MCP Apps — the capability-opt-in + discovery-surface + dedicated-channel pattern this proposal follows for an opaque, client-rendered UI surface; MCP'sresources/readis the model for the in-bandcanvasReadResourcecontent path (§10).createResourceWatch) the "mint a channel URI and return it" precedent.ServerCommandMap/ symmetricresource*— the existing bidirectional server→client request machinery the provider callbacks reuse.channel: URIrouting key.