Summary
AHP today has no Canvas concept on the wire. There is no state slot, no action family, no method, no schema, no capability flag — grep -ri canvas against main (commit 0871c60) returns nothing in types/, docs/, schema/, or any language client.
Downstream agent runtimes ship a Canvas surface — declarative, interactive UI surfaces provided by extensions and rendered by clients — that AHP currently cannot model end-to-end. A remote-running host fronted by an AHP client elsewhere has nowhere for the agent's canvas.open to land; the call dies inside the host process. This proposal adds the minimal wire surface to close that gap.
Concretely: add three new top-level SessionState fields, two new SessionActiveClient fields, eight new actions, a reducer + validation update, and a 0.3.0 registry entry per new action. No new commands, no new notifications, no new transport, no new initialize capability — Canvas state and events ride the existing action notification on the existing ahp-session:/<uuid> channel.
Motivation
What "Canvas" means in a Copilot CLI–style runtime
There is an existing wire shape this proposal is reverse-engineering from, so AHP maintainers don't have to invent the semantics from scratch. The shape comes from a JSON-RPC agent runtime that splits Canvas across three roles:
- Provider — an in-process or child-process extension of the runtime that declares zero or more canvases at session-create / session-resume time, and installs request/response callbacks for
canvas.open, canvas.close, and canvas.action.invoke. A session may have multiple providers.
- Runtime — the agent runtime process. Maintains the aggregated canvas registry across all connected providers, routes agent-initiated
canvas.* RPC calls to the correct provider, and emits canvas.opened / canvas.registry_changed events to every connected consumer.
- Agent — the LLM. Discovers canvases via
canvas.list, opens / closes / invokes actions via canvas.open / canvas.close / canvas.action.invoke. The agent is opaque to AHP; it never sees the wire format.
The runtime's wire surface, for reference shape only:
| Direction |
Method |
Returns |
| Agent → Runtime |
session.canvas.list |
{ canvases: DiscoveredCanvas[] } |
| Agent → Runtime |
session.canvas.listOpen |
{ openCanvases: OpenCanvasInstance[] } |
| Agent → Runtime |
session.canvas.open |
OpenCanvasInstance |
| Agent → Runtime |
session.canvas.close |
() |
| Agent → Runtime |
session.canvas.action.invoke |
{ result?: any } |
| Runtime → Provider |
canvas.open |
{ url?, title?, status? } |
| Runtime → Provider |
canvas.close |
() |
| Runtime → Provider |
canvas.action.invoke |
any (provider-defined) |
| Runtime → Consumer (event) |
session.canvas.opened |
OpenCanvasInstance-shaped |
| Runtime → Consumer (event) |
session.canvas.registry_changed |
{ canvases: DiscoveredCanvas[] } |
Three orthogonal nouns the AHP types need to model:
| Noun |
Identity |
Cardinality |
Lifecycle |
Declaration (DiscoveredCanvas) |
(extensionId, canvasId) |
Many per session; varies as providers come and go |
Lives as long as the declaring provider is connected |
Action (CanvasAction) |
(canvasId, actionName) |
Many per declaration |
Same as declaration |
Open instance (OpenCanvasInstance) |
instanceId (caller-supplied; agent-minted) |
Many per declaration |
Persists until canvas.close or provider disconnect (then availability: stale) |
Every Runtime → Provider RPC is request/response — the runtime blocks on the provider's Result<...> return value (or an error envelope with code / message). This is the part that doesn't map naturally onto AHP's existing primitives, and most of §"Open design questions" hangs off it.
End-to-end flows in the source runtime
For reference, four flow narratives a Plane-2 design has to enable. These are what the AHP additions below need to support, end-to-end.
Declare → discover. Provider opens an SDK connection declaring canvases: [...] and installing a CanvasHandler. Runtime adds the provider's declarations to the aggregated registry and emits session.canvas.registry_changed to every consumer connected to the session. The agent can then call session.canvas.list to materialise the registry. If a second provider joins later (e.g. an extension child process via joinSession()), the registry is re-emitted.
Open. Agent calls session.canvas.open({ canvasId, instanceId, extensionId?, input? }). Runtime resolves extensionId (required only when canvasId is ambiguous across providers), routes to the owning provider's canvas.open, awaits the CanvasProviderOpenResult { url?, title?, status? }, mints an OpenCanvasInstance, and returns it to the agent. It also emits session.canvas.opened to every consumer.
Invoke action. Agent calls session.canvas.action.invoke({ instanceId, actionName, input? }). Runtime looks up the open instance, routes to that provider's canvas.action.invoke, returns the provider's Value (or CanvasError) to the agent.
Close / stale recovery. Agent calls session.canvas.close({ instanceId }). Runtime routes to the provider's canvas.close and removes the instance from the open set. If a provider connection drops, open instances transition to availability: stale and action invokes fail with canvas_provider_unavailable; the agent may re-open the same instanceId, triggering a fresh canvas.open with reopen: true, the provider rehydrates state, and availability returns to ready.
Where AHP is blocked today
There are three concrete blocks. Each is what an upstream PR fixes; together they motivate the surface area below.
- An AHP active client that is the natural canvas renderer has no wire to publish renderer-capable canvases on. The Tauri-style desktop client that wants to expose an
editor, chart, terminal, or browser canvas can't put those declarations anywhere on the session state today. Adding them to activeClient.tools is wrong — they aren't tools the agent invokes by name with JSON-Schema-typed input; they're surfaces the agent opens by id and then drives over a separate action family.
- A remote agent has no way to render a canvas at all. A host running on a separate machine (e.g. a cloud daemon) fronted by an AHP client elsewhere has no AHP channel by which the agent's
canvas.open can reach the renderer. The runtime's request lands on the host, the host has no destination for it, and the call fails.
SessionState carries no canvas state. A subscribing client cannot observe what canvases are declared, what is currently open, or what is in flight. A reconnecting client cannot rebuild that state from the snapshot. Today there is no field for any of those.
Today: what AHP has
I read the current main (commit 0871c60, protocol 0.2.0). The closest analogous primitives, with citations:
| AHP primitive |
What it does today |
What Canvas can inherit |
Customizations (SessionState.customizations: Customization[]) — added in #152, the most recent "registry surface" precedent |
Container / child tree of agents, skills, prompts, rules, hooks, MCP servers. Full-replacement (session/customizationsChanged), upsert (session/customizationUpdated), remove (session/customizationRemoved), client-dispatchable toggle (session/customizationToggled). Identity: opaque session-unique id separate from descriptive uri. Container load: CustomizationLoadState for async parse feedback. |
Shape of the registry-mutation action set; id vs uri identity split; full-replacement-by-action semantics. |
Input requests (SessionState.inputRequests: SessionInputRequest[]) |
Live state for blocking elicitation. Server upsert with session/inputRequested; client-dispatchable session/inputAnswerChanged and session/inputCompleted with requestId correlation. |
The request/response correlation pattern over actions — AHP's only existing precedent for "server posts a request, waits for a client-dispatched completion." |
Tool calls (ActiveTurn.toolCalls) |
Turn-scoped state machine for agent-initiated tool invocations with toolClientId routing to the active client for client-provided tools. |
Routing model for "server picks which client handles this request"; client-dispatchable completion actions; rejection / denial pattern. |
Active-client tools (SessionActiveClient.tools: ToolDefinition[]) |
Client-provided capabilities advertised through session/activeClientChanged and updated via session/activeClientToolsChanged. Full-replacement. |
Pattern for "the connected client publishes capabilities the agent can invoke." |
What is not there today:
- No state slot for a session-scoped registry of declared-but-not-opened anything (
customizations is the closest, but conflating canvases with plugins / skills / hooks would require every customizations consumer to grow a new container type, and would lose the per-instance state Canvas needs).
- No state slot for "instances of a declared thing that are currently open and have routable identity," beyond the per-turn
toolCalls.
- No server → client request/response RPC. Every server-pushed message is a fire-and-forget action notification carried by the
action notification on the existing channels. This rules out a literal "add canvas.open as a server → client request" approach in 0.3.0 (and that's fine — the state-based correlation pattern below is more idiomatic anyway).
- No spec convention for "a renderer client" distinct from "the active client." Today the active client is the renderer for everything client-rendered.
The three planes
Canvas spans three communication planes. The proposal hinges on naming them clearly because the AHP-side design becomes obvious once they are separated.
Plane 1: Agent ──── session.canvas.* (JSON-RPC, req/resp) ────► Runtime
◄─── session.canvas.opened / registry_changed (event) ───
Runtime ──── canvas.* (JSON-RPC, req/resp) ─────► Provider
◄─── ProviderResult / Error ─────────────────
Plane 2: AHP host ──── action notification (one-way) ─────► AHP client(s)
AHP client ── dispatchAction (notification) ─────► AHP host
AHP client ── request (createSession etc) ─────► AHP host
Plane 3: Host-internal bridge — translates between Plane 1 (SDK RPC
+ callbacks) and Plane 2 (AHP state + actions), including
correlation of Plane 1's synchronous callbacks with
Plane 2's asynchronous action pairs.
The proposal is about Plane 2. It does not ask AHP to mirror Plane 1's method names 1:1 — AHP synchronises state, not RPC. Three resulting design constraints land on Plane 2:
- Direction-of-request mismatch. On Plane 1 the runtime calls into the provider; on Plane 2 there is no analogous server → client request shape. The Plane-2 design uses a
requestId-correlated action pair (mirroring how session/inputRequested → session/inputCompleted works) so the host bridge can synthesise the request/response from two actions plus a deadline. See "Proposed change" below.
- Provider identity. A provider is an SDK connection on Plane 1. On Plane 2 there is just "the host" and "other AHP clients". A canvas declaration contributed by an AHP client (e.g. a desktop with renderer capability) needs to reach the runtime as a separate provider connection that the host opens on the client's behalf. The Plane-2 state must carry enough provenance for the host to make that mapping (
source: 'server' | 'activeClient' + clientId).
- Registry-mutation timing. On Plane 1 the runtime re-emits the aggregated registry whenever a provider connection joins or leaves. On Plane 2 the bridge has to translate
activeClientChanged into "provider connection opens/closes" on Plane 1, and propagate the resulting Plane-1 registry change back into AHP state. The order matters: the AHP session/canvasRegistryChanged must be emitted after the runtime acknowledges the Plane-1 registration, otherwise clients try to render canvases the runtime doesn't yet route.
Proposed change
1. Session state extensions
// types/channels-session/state.ts
export interface SessionState {
// …existing fields…
/**
* Aggregated canvas registry currently exposed to the agent.
*
* Full-replacement: when this changes, the entire array is
* republished via `session/canvasRegistryChanged`. The host derives
* this from every active canvas provider (server-side or
* active-client) attached to the session.
*/
canvasRegistry?: SessionCanvasDeclaration[];
/**
* Snapshot of every canvas instance currently open for this session.
*
* Maintained via `session/canvasInstanceOpened`,
* `session/canvasInstanceUpdated`, and
* `session/canvasInstanceClosed`. Subscribers see open instances in
* their initial snapshot and as live deltas thereafter.
*/
openCanvases?: SessionOpenCanvas[];
/**
* Outstanding canvas open / action / close requests the host is
* waiting on a provider to complete.
*
* Lives in state — like `inputRequests` — so subscribers can see
* what is in flight and reconnect/replay is correct. Entries are
* removed once a `session/canvasRequestCompleted` carries the
* matching `requestId`, or via `session/canvasRequestCancelled`.
*/
canvasRequests?: SessionCanvasRequest[];
}
export interface SessionActiveClient {
// …existing fields…
/**
* Canvas providers this active client contributes to the session.
*
* Each entry is a single canvas declaration the client can host; a
* client with multiple canvases publishes one entry per canvas.
* When this field is populated, the host SHOULD register the client
* as a canvas provider with its backend and aggregate the
* contributions into `SessionState.canvasRegistry` with
* `source: 'activeClient'` and `clientId: this.clientId`.
*
* Updated atomically with `session/activeClientChanged`. There is
* no separate "canvasProvidersChanged" action; clients re-publish
* via the active-client channel, identical to the `customizations`
* / `tools` pattern.
*/
canvasProviders?: ClientCanvasDeclaration[];
/**
* Optional renderer-capability hint. When `true`, the host MAY route
* canvas open requests targeting server-owned providers to this
* client. Clients that don't render canvases SHOULD omit the field
* entirely.
*
* Independent of `canvasProviders` — a client can render canvases
* declared by other providers without declaring any of its own, and
* vice versa.
*/
canRenderCanvases?: boolean;
}
// New supporting types in types/channels-session/state.ts
/** Discriminant for declaration source. Parallels CustomizationType. */
export const enum CanvasProviderKind {
Server = 'server', // declared by the host process
ActiveClient = 'activeClient', // declared by SessionActiveClient
}
/** One entry in the aggregated session registry. */
export interface SessionCanvasDeclaration {
/** Owning provider identifier. Stable across declarations and instances. */
extensionId: string;
/** Optional human-readable extension name. */
extensionName?: string;
/** Provider-local canvas identifier. Unique within `extensionId`. */
canvasId: string;
/** Human-readable canvas name. */
displayName: string;
/** Short description shown to the agent in canvas catalogs. */
description: string;
/** JSON Schema for canvas open input. Opaque to AHP. */
inputSchema?: JsonValue;
/** Actions this canvas exposes. */
actions?: SessionCanvasAction[];
/** Where the declaration came from. Used for routing and cleanup. */
source: CanvasProviderKind;
/** When `source === 'activeClient'`, the contributing client's id. */
clientId?: string;
}
export interface SessionCanvasAction {
/** Action name. Provider-local; unique within (extensionId, canvasId). */
name: string;
description?: string;
/** JSON Schema for action input. Opaque to AHP. */
inputSchema?: JsonValue;
}
/** One entry in the open-instance snapshot. */
export interface SessionOpenCanvas {
/** Caller-supplied stable instance identifier. */
instanceId: string;
canvasId: string;
extensionId: string;
extensionName?: string;
/** Routing availability — `ready` or `stale`. */
availability: CanvasInstanceAvailability;
/** Input the agent supplied when opening. */
input?: JsonValue;
/** Provider-supplied display title. */
title?: string;
/** Provider-supplied status text. */
status?: string;
/** URL for web-rendered canvases (subject to renderer policy). */
url?: string;
/**
* Renderer-side handle once a client has accepted the open. Lets the
* host route subsequent actions to the right renderer. Optional —
* server-rendered canvases may have no specific bound renderer.
*/
renderer?: { clientId: string };
}
export const enum CanvasInstanceAvailability {
Ready = 'ready',
Stale = 'stale',
}
/** Live correlation state for in-flight provider callbacks. */
export interface SessionCanvasRequest {
/** Stable correlation id minted by the host. */
requestId: string;
/** What the host is asking the provider to do. */
kind: CanvasRequestKind;
instanceId: string;
canvasId: string;
extensionId: string;
/** Who the host has routed this request to. */
target: { kind: 'activeClient'; clientId: string } | { kind: 'server' };
/** Action name when `kind === 'action'`. */
actionName?: string;
/** Open-time input when `kind === 'open'`; action input when `kind === 'action'`. */
input?: JsonValue;
/**
* Server-side deadline at which the host will give up and fail the
* Plane-1 callback. Encoded as ms since epoch.
*/
deadlineMs?: number;
}
export const enum CanvasRequestKind {
Open = 'open',
Action = 'action',
Close = 'close',
}
/**
* Active-client-contributed canvas declaration. Lighter shape than
* the aggregated one — `extensionId` is derived from the client's
* identity by the host (e.g. `client:<clientId>`).
*/
export interface ClientCanvasDeclaration {
canvasId: string;
displayName: string;
description: string;
inputSchema?: JsonValue;
actions?: SessionCanvasAction[];
}
2. New actions
Eight new entries on ActionType. Naming follows the existing convention (<channel>/<noun><adjective>, noun-first, past-tense for state changes). Six are server-dispatched; two are client-dispatchable.
| ActionType |
Direction |
When |
session/canvasRegistryChanged |
Server |
Full-replacement of state.canvasRegistry. Emitted after a provider joins or leaves (server-side extension lifecycle, or activeClientChanged mutation). |
session/canvasInstanceOpened |
Server |
An open canvas was confirmed by its provider. Upserts into state.openCanvases by instanceId. Carries the full SessionOpenCanvas. Idempotent for reopen. |
session/canvasInstanceUpdated |
Server |
A provider pushed a status / title / url / availability update. Narrow partial-merge by instanceId over SessionOpenCanvasUpdate. |
session/canvasInstanceClosed |
Server |
An open canvas was closed. Removes from state.openCanvases by instanceId. Cascade-removes any pending canvasRequests targeting it. |
session/canvasRequestCreated |
Server |
Host emitted a Plane-2 request to the routed provider and is waiting on completion. Adds to state.canvasRequests. Visible to all subscribers (lets a recovering client see what's mid-flight). |
session/canvasRequestCompleted |
See note |
The targeted provider reports the result of a request. result and error are mutually exclusive. Removes from state.canvasRequests. |
session/canvasRequestCancelled |
Server |
Host cancelled a request — typically because the targeted provider disconnected or the deadline elapsed. Removes from state.canvasRequests. |
session/canvasInstanceCloseRequested |
Client |
A client (typically a renderer's "close button") asks the host to close an instance. The host translates this into a Plane-1 canvas.close and ultimately a canvasInstanceClosed. |
Who completes a canvasRequestCreated?
The completion direction depends on the request's target.kind, because canvas providers can live either inside the host process or behind an AHP active client:
target.kind |
Provider lives in… |
canvasRequestCompleted direction |
Server validation |
'activeClient' |
The AHP active client identified by target.clientId |
Client → Server (dispatchable by that client only) |
Dispatcher's clientId === target.clientId |
'server' |
The host process itself (a server-side extension or builtin provider) |
Server → Client (server emits the completion on the action stream) |
Not client-dispatchable; server SHOULD reject any client-dispatched canvasRequestCompleted for a server-targeted request |
Both directions land on the same action type and same state mutation — state.canvasRequests shrinks by one. This keeps the reducer single-shaped while letting the host decide who actually fulfils the Plane-1 callback.
Distinction: provider vs renderer
- A provider answers
canvasRequestCreated (owns the Plane-1 callback logic for canvas.open / canvas.action.invoke / canvas.close).
- A renderer is a client that displays the resulting
SessionOpenCanvas (the URL, the title) in its UI.
The same client may be both for its own canvases. They are not the same role:
- Server-declared canvases have no renderer in the AHP sense — they return a URL that any subscribed client with
canRenderCanvases: true can display. openCanvases[i].renderer is therefore optional; when absent, any capable client may render. When the host wants to pin a specific client (e.g. the one that just dispatched the action that caused the open), it sets renderer.clientId so subsequent canvasInstanceCloseRequested validation knows who's authoritative.
- Client-declared canvases (a canvas in
activeClient.canvasProviders) always have renderer.clientId === target.clientId — that client is both the provider and the renderer.
Action payload shapes
// types/channels-session/actions.ts — additions
// ── Registry ────────────────────────────────────────────────────────
export interface SessionCanvasRegistryChangedAction {
type: ActionType.SessionCanvasRegistryChanged;
/** Full replacement of state.canvasRegistry. */
canvases: SessionCanvasDeclaration[];
}
// ── Open-instance lifecycle ─────────────────────────────────────────
export interface SessionCanvasInstanceOpenedAction {
type: ActionType.SessionCanvasInstanceOpened;
/** Full instance, upserted into state.openCanvases by instanceId. */
instance: SessionOpenCanvas;
}
/**
* Narrow patch type for `canvasInstanceUpdated`. Only these fields may
* be mutated; identity (instanceId, canvasId, extensionId,
* extensionName) and routing (renderer.clientId) are fixed at open
* time. `null` clears an optional value; absent preserves it.
*/
export interface SessionOpenCanvasUpdate {
title?: string | null;
status?: string | null;
url?: string | null;
availability?: CanvasInstanceAvailability;
}
export interface SessionCanvasInstanceUpdatedAction {
type: ActionType.SessionCanvasInstanceUpdated;
instanceId: string;
changes: SessionOpenCanvasUpdate;
}
export interface SessionCanvasInstanceClosedAction {
type: ActionType.SessionCanvasInstanceClosed;
instanceId: string;
}
/** @clientDispatchable */
export interface SessionCanvasInstanceCloseRequestedAction {
type: ActionType.SessionCanvasInstanceCloseRequested;
instanceId: string;
}
// ── Request correlation ─────────────────────────────────────────────
export interface SessionCanvasRequestCreatedAction {
type: ActionType.SessionCanvasRequestCreated;
request: SessionCanvasRequest;
}
/**
* Completion of a `canvasRequestCreated`.
*
* `result` and `error` are mutually exclusive — exactly one MUST be
* present. The valid `result.kind` matches the originating request's
* `kind`.
*
* Direction:
* target.kind === 'activeClient' → @clientDispatchable; only the
* client whose clientId matches target.clientId may dispatch.
* target.kind === 'server' → server-dispatched only; server
* SHOULD reject client-dispatched completions for server targets.
*/
export interface SessionCanvasRequestCompletedAction {
type: ActionType.SessionCanvasRequestCompleted;
requestId: string;
result?: CanvasOpenResult | CanvasActionResult | CanvasCloseResult;
error?: CanvasErrorPayload;
}
export interface CanvasOpenResult {
kind: 'open';
/** Provider-supplied render URL. Subject to renderer policy. */
url?: string;
/** Provider-supplied display title. */
title?: string;
/** Provider-supplied status text. */
status?: string;
}
export interface CanvasActionResult {
kind: 'action';
/** Opaque provider-defined value. */
value?: JsonValue;
}
export interface CanvasCloseResult {
kind: 'close';
}
export interface CanvasErrorPayload {
/** Machine-readable code, e.g. `canvas_action_no_handler`,
* `canvas_provider_unavailable`. */
code: string;
/** Human-readable message. */
message: string;
}
/** Server-only — host signals a request was abandoned. */
export interface SessionCanvasRequestCancelledAction {
type: ActionType.SessionCanvasRequestCancelled;
requestId: string;
reason: 'timeout' | 'providerDisconnected' | 'instanceClosed' | 'hostShutdown';
}
Why this shape and not per-flavour open / close / invoke action triplets (canvasOpenRequested / canvasOpened / canvasOpenFailed, etc.): all three flows are the same correlation pattern. A single canvasRequestCreated / canvasRequestCompleted / canvasRequestCancelled triple with a kind discriminant is smaller, mirrors inputRequested / inputCompleted more faithfully, and keeps the state surface flat. canvasInstanceOpened and canvasInstanceClosed are state-mutation actions (not RPC echoes) emitted after the host finalises the open / close.
canvasInstanceCloseRequested is the one client-dispatchable action that initiates a Plane-1 RPC, because a renderer needs a way to surface "user clicked X" up to the agent.
The availability enum is intentionally closed ('ready' | 'stale') rather than open-string, matching AHP convention. The bridge maps any forward-compat values from the underlying runtime to 'stale' — the only safe degraded mode.
3. Reducer semantics
Mirrors the existing customization / input-request patterns in types/channels-session/reducer.ts.
// session/canvasRegistryChanged — full replacement.
state.canvasRegistry = action.canvases;
// session/canvasInstanceOpened — upsert by instanceId.
const idx = state.openCanvases?.findIndex(c => c.instanceId === action.instance.instanceId) ?? -1;
if (idx >= 0) state.openCanvases![idx] = action.instance;
else (state.openCanvases ??= []).push(action.instance);
// session/canvasInstanceUpdated — narrow patch over `changes`.
// Only the keys present in SessionOpenCanvasUpdate may be touched.
// `null` clears an optional value; absent preserves it.
const target = state.openCanvases?.find(c => c.instanceId === action.instanceId);
if (target) {
for (const key of ['title', 'status', 'url', 'availability'] as const) {
if (key in action.changes) {
const v = action.changes[key];
if (v === null) delete target[key];
else (target as any)[key] = v;
}
}
}
// No-op when no matching instance.
// session/canvasInstanceClosed — remove by instanceId; cascade-remove
// pending requests targeting it.
state.openCanvases = state.openCanvases?.filter(c => c.instanceId !== action.instanceId);
state.canvasRequests = state.canvasRequests?.filter(r => r.instanceId !== action.instanceId);
// session/canvasRequestCreated — upsert by requestId (not blind append).
// A duplicate requestId from a recovering bridge or replay path replaces
// the existing entry rather than producing two ghosts.
const ridx = state.canvasRequests?.findIndex(r => r.requestId === action.request.requestId) ?? -1;
if (ridx >= 0) state.canvasRequests![ridx] = action.request;
else (state.canvasRequests ??= []).push(action.request);
// session/canvasRequestCompleted — remove by requestId.
// (Bridge-side: triggers the in-flight Plane-1 promise resolution.)
state.canvasRequests = state.canvasRequests?.filter(r => r.requestId !== action.requestId);
// session/canvasRequestCancelled — remove by requestId.
state.canvasRequests = state.canvasRequests?.filter(r => r.requestId !== action.requestId);
// session/canvasInstanceCloseRequested — no state mutation; pure
// client-to-host signal. Reducer is a no-op (cf. session/inputCompleted
// where the state mutation happens later as a server-side action).
4. Server validation of client-dispatched actions
Additions for the validation table in docs/specification/session-channel.md:
| Action |
Condition |
Server behaviour |
session/canvasRequestCompleted |
No canvasRequests entry with matching requestId |
Server SHOULD reject |
session/canvasRequestCompleted |
Neither result nor error set, or both set |
Server SHOULD reject |
session/canvasRequestCompleted |
Targeted request has target.kind === 'server' — completion is server-only for those |
Server SHOULD reject |
session/canvasRequestCompleted |
Dispatching client does not match canvasRequests[i].target.clientId (when target.kind === 'activeClient') |
Server SHOULD reject |
session/canvasRequestCompleted |
result.kind does not match the originating request's kind (e.g. open request answered with kind: 'action' result) |
Server SHOULD reject |
session/canvasInstanceCloseRequested |
No openCanvases entry with matching instanceId |
Server SHOULD silently ignore (parallels stale-tool-call denial) |
session/canvasInstanceCloseRequested |
Dispatched by a client that is not the renderer of the target instance (openCanvases[i].renderer?.clientId !== dispatcherClientId, when renderer is set) |
Server SHOULD reject |
5. Version registry entries
// types/version/registry.ts — additions
[ActionType.SessionCanvasRegistryChanged]: '0.3.0',
[ActionType.SessionCanvasInstanceOpened]: '0.3.0',
[ActionType.SessionCanvasInstanceUpdated]: '0.3.0',
[ActionType.SessionCanvasInstanceClosed]: '0.3.0',
[ActionType.SessionCanvasRequestCreated]: '0.3.0',
[ActionType.SessionCanvasRequestCompleted]: '0.3.0',
[ActionType.SessionCanvasRequestCancelled]: '0.3.0',
[ActionType.SessionCanvasInstanceCloseRequested]: '0.3.0',
No new entries in NOTIFICATION_INTRODUCED_IN — all canvas surface travels as actions on existing session channels.
6. URL handling
SessionOpenCanvas.url carries renderer-targeted URLs. The wire shape is a plain string; policy is enforced by the renderer. The spec should require renderers to implement at least the first two of:
- Scheme allow-list. Renderers MUST refuse schemes outside an explicit allow-list. Recommended baseline:
https, file, data, plus implementation-defined extension schemes for in-process surfaces (e.g. vscode-webview://, tauri-app://). http MAY be allowed only for localhost and 127.0.0.1.
- Origin isolation. Renderers MUST sandbox canvas content (iframe
sandbox, separate webview / web-contents context, etc.) so canvas URLs cannot read application-scope state or credentials.
- Credential handling. Renderers SHOULD strip
Authorization, Cookie, and other ambient credentials on navigation to a canvas URL.
The alternative — modelling the URL as a typed CanvasRenderTarget union ({ kind: 'https', url } | { kind: 'localProcess', socket } | ...) — is more restrictive but more brittle as new render modes appear. Worth revisiting if the opaque-URL approach proves leaky.
Files to touch
types/channels-session/state.ts — new SessionCanvas* types; extend SessionState and SessionActiveClient.
types/channels-session/actions.ts — new action interfaces, extend the per-channel action union.
types/channels-session/reducer.ts — new reducer arms per §3.
types/common/actions.ts — extend ActionType enum with the eight new entries.
types/version/registry.ts — eight new ACTION_INTRODUCED_IN entries at '0.3.0'.
schema/*.json — regenerate.
docs/guide/canvases.md (new) — concepts (registry vs instance vs request), end-to-end flows with mermaid diagrams, the three-planes framing for SDK consumers, security policy.
docs/specification/session-channel.md — append a "Canvas validation" subsection mirroring the input-request validation rules.
clients/{rust,kotlin,swift,typescript}/... — regenerate language clients.
types/test-cases/reducers/ — reducer tests covering registry-replace, instance-upsert / partial-update / close cascade, request-upsert / complete / cancel, instance-close-requested no-op.
7. Bridge contract — wire-name mapping
Not part of the AHP spec, but the contract a host-internal bridge layer implements once the spec lands. Included here so AHP maintainers can see what each new action is solving in terms of the underlying runtime call.
| Plane 1 (source runtime) |
Plane 2 (AHP) |
Provider declares via SessionConfig::with_canvases([...]) (server-side) |
Host emits session/canvasRegistryChanged with the aggregated set |
session/activeClientChanged carries activeClient.canvasProviders |
Host re-derives the aggregated set, opens a per-client provider connection on the runtime, emits session/canvasRegistryChanged |
Runtime emits session.canvas.registry_changed |
Host re-derives state.canvasRegistry, emits session/canvasRegistryChanged if changed |
Runtime calls provider canvas.open |
Host emits session/canvasRequestCreated { kind: 'open' }; awaits session/canvasRequestCompleted; on success emits session/canvasInstanceOpened and replies to Plane 1 with CanvasProviderOpenResult; on error or timeout emits session/canvasRequestCancelled and replies with CanvasError |
Runtime calls provider canvas.action.invoke |
Same as open with kind: 'action'; on success replies to Plane 1 with the value; no canvasInstanceOpened emission |
Runtime calls provider canvas.close |
Same as open with kind: 'close'; on success emits session/canvasInstanceClosed and replies to Plane 1 with () |
Runtime emits session.canvas.opened (state change on an existing instance) |
Host emits session/canvasInstanceUpdated (narrow partial merge) |
Agent calls session.canvas.listOpen |
Served from the runtime's own registry; host re-syncs state.openCanvases if drift detected |
| Provider connection drops (renderer disconnects) |
Host emits session/canvasInstanceUpdated with availability: 'stale' for every open instance owned by that provider; cancels in-flight canvasRequests targeting it via session/canvasRequestCancelled |
Client-dispatched session/canvasInstanceCloseRequested |
Host calls session.canvas.close on the runtime; the rest follows the standard close flow |
Compatibility
Purely additive.
- All new state fields are optional.
- All new actions are unknown to
0.2.0 consumers and SHOULD be ignored by them per the existing additive-evolution rule.
- No changes to existing types, methods, notifications, or transport.
- No new
initialize capabilities — renderer capability is discovered by state inspection (activeClient.canRenderCanvases), matching how the rest of AHP works today.
A 0.2.0 host that has not adopted the surface produces no canvas state and emits no canvas actions; a 0.3.0-aware client behaves identically against it. A 0.3.0 client connected to a 0.2.0 host sees no canvas state and learns there is nothing to render.
Open design questions
These are the calls a follow-up PR should explicitly request review on. Each has a proposed position and the alternative the maintainers should push back on if they disagree.
-
Correlation primitive — separate or shared with input requests?
Proposed: keep canvasRequests as its own state slice even though the request/response pattern mirrors inputRequests. The two surfaces have meaningfully different lifecycles (turn-scoped vs session-scoped) and audiences (user vs provider), and unifying them would force one to compromise.
Alternative: introduce a generic pendingRequests slice both use. Open to upstream's call.
-
Provider ownership — active-client only, or also server-scoped?
Proposed: allow both. source: 'server' for host-process / in-process-extension providers; source: 'activeClient' + clientId for client-contributed providers. Implication: when the active client disconnects, registry entries with that clientId are removed and openCanvases entries routed to that client transition to availability: 'stale'.
Alternative: restrict to active-client only, forcing every server-side provider to be expressed as a synthetic "server" client. Simpler state, but loses the natural distinction.
-
Registry-mutation timing — atomic with active-client change, or two-step?
Proposed: two-step. activeClientChanged applies immediately; the host performs Plane-1 register / deregister and emits canvasRegistryChanged once the runtime acknowledges. Open instances keep availability: 'stale' until the new client (or a reconnect) re-publishes.
Alternative: make activeClientChanged carry an implicit registry replacement. Lower latency, but couples two state slices in a way no existing action does.
-
canRenderCanvases — boolean, struct, or absent?
Proposed: a single canRenderCanvases?: boolean on SessionActiveClient, semantically "I will accept canvasRequestCreated with target.clientId === me."
Alternative A: richer struct (canvasRenderer?: { schemes: string[]; maxConcurrentInstances?: number }) — worth doing if URL-scheme negotiation is needed at the wire level.
Alternative B: infer from canvasProviders being non-empty — brittle and conflates two concerns.
-
Cancellation by the agent.
The runtime has no client-initiated cancellation for in-flight canvas.open / canvas.action.invoke today. If AHP grows session/canvasRequestCancelled as a client-dispatchable action (e.g. "the user closed the panel mid-load"), the bridge needs a story for what to do on Plane 1.
Proposed: keep canvasRequestCancelled server-only in 0.3.0. Revisit in a follow-up if real renderer use cases emerge.
-
Instance close vs. instance stale on provider disconnect.
Proposed: on provider disconnect, transition openCanvases[i].availability to 'stale' but keep the entry. The agent (or a recovering bridge) can re-issue session.canvas.open for the same instanceId to rehydrate via the underlying runtime's reopen path. Cancel any in-flight canvasRequests targeting the disconnected provider.
Alternative: hard-remove on disconnect. Simpler reducer, but loses the rebind story.
-
SessionOpenCanvas.input retention.
Proposed: keep the open-time input on SessionOpenCanvas so a recovering bridge has enough information to drive a rebind without round-tripping the agent.
Alternative: drop after open completes; force rebinds to go through a fresh agent-issued open. Smaller state at the cost of more chatter.
-
Single _meta escape hatch vs. typed surface.
Proposed: typed surface, per this issue.
Alternative: leave Canvas as a vendor _meta extension forever. Rejected because every AHP client that wants to render canvases would have to grow matching custom handling, defeating the protocol's job.
-
How does the bridge populate the source runtime's per-callback host-capabilities flag?
The source runtime's provider callbacks (canvas.open, canvas.close, canvas.action.invoke) carry a host.capabilities.canvases?: bool. This is not the same thing as SessionActiveClient.canRenderCanvases. It is what the bridge populates per provider invocation at call time, computed as: there is an active client AND activeClient.canRenderCanvases === true AND the bridge is willing to route to this provider. Captured here so the question doesn't get asked in code review; it's a bridge concern, not an AHP-spec concern.
Why not the alternatives
For completeness — the shapes considered and rejected before this proposal:
- Tunnel canvases through
customizations. Mechanical fit (registered, id-keyed, tree-shaped) but semantic mismatch — customizations are agent-augmentation sources (plugins, skills, MCP servers) with their own parsing / load-state lifecycle. Conflating them would force every customization consumer to handle a new container type and would lose the per-instance state Canvas needs.
- Tunnel canvases through
inputRequests. Right correlation pattern but wrong scope — input requests are turn-scoped, user-facing, and clean up on turn end. Canvas instances are session-scoped, provider-facing, and outlive turns.
- Tunnel canvases through
_meta. Acceptable as a prototype vehicle while this issue is in review; not a production answer (loses type safety, schema validation, reducer support, version-registry tracking, and reusability across AHP clients).
- Add bidirectional server → client JSON-RPC requests to AHP. A bigger protocol change than this proposal, and not needed — the state-plus-correlation pattern via
canvasRequests covers the same use case using AHP-native primitives. If a future feature genuinely needs server-initiated request/response, that would be the right time to make the change in its own RFC.
- Run a parallel WebSocket protocol just for canvas. Works for one specific client surface but doesn't generalise to mobile, remote agents, or any future AHP client.
Implementation phasing
Happy to open the PR once maintainers weigh in on the open questions above — particularly #2 (provider ownership) and #4 (canRenderCanvases shape), since they constrain the state shape directly. Suggested phasing:
- Land this issue's spec change as a PR against
types/, schema/, docs/, and the language clients. Reducer tests cover registry-replace, instance upsert / partial-update / close cascade, request upsert / complete / cancel, and instance-close-requested no-op.
- Bridge implementation work in downstream hosts is then unblocked and proceeds in their own repositories.
- Future iterations (agent-initiated cancellation, typed
CanvasRenderTarget union, richer renderer capabilities) come in their own RFCs once real usage shakes out the rough edges.
References
- #152 — Customizations redesign; the most recent "registry surface" precedent and the closest pattern Canvas should inherit from.
- Related: #141 — multi-container topology with separate tool-providing and message-sending clients; informs the provider-vs-renderer split.
- Related: #153 — host-level auth state; same theme of "what state should be per-client vs. per-session vs. per-host".
- Related: #162 —
sessionFs/* server → client request family; same downstream-runtime-pattern-doesn't-fit-AHP-yet theme, different surface.
docs/guide/customizations.md — the existing pattern this proposal's state and action shapes mirror most directly.
docs/guide/elicitation.md — the existing pattern this proposal's request/response correlation mirrors.
docs/specification/session-channel.md — where new validation rules and a canvas methods/events table need to land.
types/version/registry.ts — exhaustive ACTION_INTRODUCED_IN map this proposal extends.
Glossary
| Term |
Meaning in this proposal |
| Source runtime |
The JSON-RPC agent runtime whose Canvas wire shape this proposal is reverse-engineering from (the consumer that needs AHP to grow this surface). |
| Provider |
A connection on the source runtime that declares canvases via the runtime's session-config and installs request/response callbacks for canvas.open / canvas.close / canvas.action.invoke. May be the host process itself, or an extension hosted inside it. |
| Renderer |
An AHP client that displays the resulting SessionOpenCanvas (the URL, the title) in its UI. Distinct from provider. |
| AHP host |
The process implementing the AHP server side and also acting as the source runtime's host. |
| AHP client |
A process subscribed to an AHP host's session channels. |
| Plane 1 |
Source-runtime ↔ Provider RPC (the existing surface that motivates this proposal). |
| Plane 2 |
AHP host ↔ AHP client (the new surface this proposal designs). |
| Plane 3 |
The host-internal bridge that translates between Planes 1 and 2 and correlates synchronous Plane-1 callbacks with asynchronous Plane-2 action pairs. Not part of the AHP spec; included only because the spec must be expressive enough for the bridge to implement these flows without information loss. |
| Declaration |
A canvasId advertised by a provider that the agent can open. |
| Instance |
A single open canvas, identified by instanceId. |
| Action (on a canvas) |
A named operation a canvas exposes. Distinct from an AHP StateAction. |
Appendix — full source-runtime Canvas wire reference
Provided for ground-truth verification. Field names are wire names (camelCase). All types are marked experimental in the source runtime.
session.canvas.list (no params) → CanvasList { canvases: DiscoveredCanvas[] }
session.canvas.listOpen (no params) → CanvasListOpenResult { openCanvases: OpenCanvasInstance[] }
session.canvas.open(CanvasOpenRequest) → OpenCanvasInstance
session.canvas.close(CanvasCloseRequest) → ()
session.canvas.action.invoke(CanvasActionInvokeRequest) → CanvasActionInvokeResult
canvas.open(CanvasProviderOpenRequest) → CanvasProviderOpenResult | CanvasError
canvas.close(CanvasProviderCloseRequest) → () | CanvasError
canvas.action.invoke(CanvasProviderInvokeActionRequest) → any | CanvasError
event session.canvas.opened → SessionCanvasOpenedData
event session.canvas.registry_changed → SessionCanvasRegistryChangedData
event capabilities.changed (subset) → CapabilitiesChangedData { ui?: { canvases?: bool, ... } }
CanvasOpenRequest { canvasId, instanceId, extensionId?, input? }
CanvasCloseRequest { instanceId }
CanvasActionInvokeRequest { instanceId, actionName, input? }
CanvasActionInvokeResult { result? }
CanvasProviderOpenRequest { sessionId, extensionId, canvasId, instanceId, input?, host?, session? }
CanvasProviderCloseRequest { sessionId, extensionId, canvasId, instanceId, host?, session? }
CanvasProviderInvokeActionRequest { sessionId, extensionId, canvasId, instanceId, actionName, input?, host?, session? }
CanvasProviderOpenResult { url?, title?, status? }
DiscoveredCanvas { canvasId, extensionId, extensionName?, displayName, description, inputSchema?, actions? }
CanvasAction { name, description?, inputSchema? }
OpenCanvasInstance {
instanceId, canvasId, extensionId, extensionName?,
availability ('ready' | 'stale' | <unknown>),
input?, title?, status?, url?, reopen
}
CanvasHostContext { capabilities? }
CanvasHostContextCapabilities { canvases? }
CanvasSessionContext { workingDirectory? }
CanvasError { code, message }
// observed error codes: canvas_action_no_handler, canvas_provider_unavailable
Summary
AHP today has no Canvas concept on the wire. There is no state slot, no action family, no method, no schema, no capability flag —
grep -ri canvasagainstmain(commit0871c60) returns nothing intypes/,docs/,schema/, or any language client.Downstream agent runtimes ship a Canvas surface — declarative, interactive UI surfaces provided by extensions and rendered by clients — that AHP currently cannot model end-to-end. A remote-running host fronted by an AHP client elsewhere has nowhere for the agent's
canvas.opento land; the call dies inside the host process. This proposal adds the minimal wire surface to close that gap.Concretely: add three new top-level
SessionStatefields, two newSessionActiveClientfields, eight new actions, a reducer + validation update, and a0.3.0registry entry per new action. No new commands, no new notifications, no new transport, no newinitializecapability — Canvas state and events ride the existingactionnotification on the existingahp-session:/<uuid>channel.Motivation
What "Canvas" means in a Copilot CLI–style runtime
There is an existing wire shape this proposal is reverse-engineering from, so AHP maintainers don't have to invent the semantics from scratch. The shape comes from a JSON-RPC agent runtime that splits Canvas across three roles:
canvas.open,canvas.close, andcanvas.action.invoke. A session may have multiple providers.canvas.*RPC calls to the correct provider, and emitscanvas.opened/canvas.registry_changedevents to every connected consumer.canvas.list, opens / closes / invokes actions viacanvas.open/canvas.close/canvas.action.invoke. The agent is opaque to AHP; it never sees the wire format.The runtime's wire surface, for reference shape only:
session.canvas.list{ canvases: DiscoveredCanvas[] }session.canvas.listOpen{ openCanvases: OpenCanvasInstance[] }session.canvas.openOpenCanvasInstancesession.canvas.close()session.canvas.action.invoke{ result?: any }canvas.open{ url?, title?, status? }canvas.close()canvas.action.invokeany(provider-defined)session.canvas.openedOpenCanvasInstance-shapedsession.canvas.registry_changed{ canvases: DiscoveredCanvas[] }Three orthogonal nouns the AHP types need to model:
DiscoveredCanvas)(extensionId, canvasId)CanvasAction)(canvasId, actionName)OpenCanvasInstance)instanceId(caller-supplied; agent-minted)canvas.closeor provider disconnect (thenavailability: stale)Every Runtime → Provider RPC is request/response — the runtime blocks on the provider's
Result<...>return value (or an error envelope withcode/message). This is the part that doesn't map naturally onto AHP's existing primitives, and most of §"Open design questions" hangs off it.End-to-end flows in the source runtime
For reference, four flow narratives a Plane-2 design has to enable. These are what the AHP additions below need to support, end-to-end.
Declare → discover. Provider opens an SDK connection declaring
canvases: [...]and installing aCanvasHandler. Runtime adds the provider's declarations to the aggregated registry and emitssession.canvas.registry_changedto every consumer connected to the session. The agent can then callsession.canvas.listto materialise the registry. If a second provider joins later (e.g. an extension child process viajoinSession()), the registry is re-emitted.Open. Agent calls
session.canvas.open({ canvasId, instanceId, extensionId?, input? }). Runtime resolvesextensionId(required only whencanvasIdis ambiguous across providers), routes to the owning provider'scanvas.open, awaits theCanvasProviderOpenResult { url?, title?, status? }, mints anOpenCanvasInstance, and returns it to the agent. It also emitssession.canvas.openedto every consumer.Invoke action. Agent calls
session.canvas.action.invoke({ instanceId, actionName, input? }). Runtime looks up the open instance, routes to that provider'scanvas.action.invoke, returns the provider'sValue(orCanvasError) to the agent.Close / stale recovery. Agent calls
session.canvas.close({ instanceId }). Runtime routes to the provider'scanvas.closeand removes the instance from the open set. If a provider connection drops, open instances transition toavailability: staleand action invokes fail withcanvas_provider_unavailable; the agent may re-open the sameinstanceId, triggering a freshcanvas.openwithreopen: true, the provider rehydrates state, and availability returns toready.Where AHP is blocked today
There are three concrete blocks. Each is what an upstream PR fixes; together they motivate the surface area below.
editor,chart,terminal, orbrowsercanvas can't put those declarations anywhere on the session state today. Adding them toactiveClient.toolsis wrong — they aren't tools the agent invokes by name with JSON-Schema-typed input; they're surfaces the agent opens by id and then drives over a separate action family.canvas.opencan reach the renderer. The runtime's request lands on the host, the host has no destination for it, and the call fails.SessionStatecarries no canvas state. A subscribing client cannot observe what canvases are declared, what is currently open, or what is in flight. A reconnecting client cannot rebuild that state from the snapshot. Today there is no field for any of those.Today: what AHP has
I read the current
main(commit0871c60, protocol0.2.0). The closest analogous primitives, with citations:SessionState.customizations: Customization[]) — added in #152, the most recent "registry surface" precedentsession/customizationsChanged), upsert (session/customizationUpdated), remove (session/customizationRemoved), client-dispatchable toggle (session/customizationToggled). Identity: opaque session-uniqueidseparate from descriptiveuri. Containerload: CustomizationLoadStatefor async parse feedback.idvsuriidentity split; full-replacement-by-action semantics.SessionState.inputRequests: SessionInputRequest[])session/inputRequested; client-dispatchablesession/inputAnswerChangedandsession/inputCompletedwithrequestIdcorrelation.ActiveTurn.toolCalls)toolClientIdrouting to the active client for client-provided tools.SessionActiveClient.tools: ToolDefinition[])session/activeClientChangedand updated viasession/activeClientToolsChanged. Full-replacement.What is not there today:
customizationsis the closest, but conflating canvases with plugins / skills / hooks would require every customizations consumer to grow a new container type, and would lose the per-instance state Canvas needs).toolCalls.actionnotification on the existing channels. This rules out a literal "addcanvas.openas a server → client request" approach in 0.3.0 (and that's fine — the state-based correlation pattern below is more idiomatic anyway).The three planes
Canvas spans three communication planes. The proposal hinges on naming them clearly because the AHP-side design becomes obvious once they are separated.
The proposal is about Plane 2. It does not ask AHP to mirror Plane 1's method names 1:1 — AHP synchronises state, not RPC. Three resulting design constraints land on Plane 2:
requestId-correlated action pair (mirroring howsession/inputRequested→session/inputCompletedworks) so the host bridge can synthesise the request/response from two actions plus a deadline. See "Proposed change" below.source: 'server' | 'activeClient'+clientId).activeClientChangedinto "provider connection opens/closes" on Plane 1, and propagate the resulting Plane-1 registry change back into AHP state. The order matters: the AHPsession/canvasRegistryChangedmust be emitted after the runtime acknowledges the Plane-1 registration, otherwise clients try to render canvases the runtime doesn't yet route.Proposed change
1. Session state extensions
2. New actions
Eight new entries on
ActionType. Naming follows the existing convention (<channel>/<noun><adjective>, noun-first, past-tense for state changes). Six are server-dispatched; two are client-dispatchable.session/canvasRegistryChangedstate.canvasRegistry. Emitted after a provider joins or leaves (server-side extension lifecycle, oractiveClientChangedmutation).session/canvasInstanceOpenedstate.openCanvasesbyinstanceId. Carries the fullSessionOpenCanvas. Idempotent for reopen.session/canvasInstanceUpdatedinstanceIdoverSessionOpenCanvasUpdate.session/canvasInstanceClosedstate.openCanvasesbyinstanceId. Cascade-removes any pendingcanvasRequeststargeting it.session/canvasRequestCreatedstate.canvasRequests. Visible to all subscribers (lets a recovering client see what's mid-flight).session/canvasRequestCompletedresultanderrorare mutually exclusive. Removes fromstate.canvasRequests.session/canvasRequestCancelledstate.canvasRequests.session/canvasInstanceCloseRequestedcanvas.closeand ultimately acanvasInstanceClosed.Who completes a
canvasRequestCreated?The completion direction depends on the request's
target.kind, because canvas providers can live either inside the host process or behind an AHP active client:target.kindcanvasRequestCompleteddirection'activeClient'target.clientIdclientId === target.clientId'server'canvasRequestCompletedfor a server-targeted requestBoth directions land on the same action type and same state mutation —
state.canvasRequestsshrinks by one. This keeps the reducer single-shaped while letting the host decide who actually fulfils the Plane-1 callback.Distinction: provider vs renderer
canvasRequestCreated(owns the Plane-1 callback logic forcanvas.open/canvas.action.invoke/canvas.close).SessionOpenCanvas(the URL, the title) in its UI.The same client may be both for its own canvases. They are not the same role:
canRenderCanvases: truecan display.openCanvases[i].rendereris therefore optional; when absent, any capable client may render. When the host wants to pin a specific client (e.g. the one that just dispatched the action that caused the open), it setsrenderer.clientIdso subsequentcanvasInstanceCloseRequestedvalidation knows who's authoritative.activeClient.canvasProviders) always haverenderer.clientId === target.clientId— that client is both the provider and the renderer.Action payload shapes
Why this shape and not per-flavour open / close / invoke action triplets (
canvasOpenRequested/canvasOpened/canvasOpenFailed, etc.): all three flows are the same correlation pattern. A singlecanvasRequestCreated/canvasRequestCompleted/canvasRequestCancelledtriple with akinddiscriminant is smaller, mirrorsinputRequested/inputCompletedmore faithfully, and keeps the state surface flat.canvasInstanceOpenedandcanvasInstanceClosedare state-mutation actions (not RPC echoes) emitted after the host finalises the open / close.canvasInstanceCloseRequestedis the one client-dispatchable action that initiates a Plane-1 RPC, because a renderer needs a way to surface "user clicked X" up to the agent.The
availabilityenum is intentionally closed ('ready' | 'stale') rather than open-string, matching AHP convention. The bridge maps any forward-compat values from the underlying runtime to'stale'— the only safe degraded mode.3. Reducer semantics
Mirrors the existing customization / input-request patterns in
types/channels-session/reducer.ts.4. Server validation of client-dispatched actions
Additions for the validation table in
docs/specification/session-channel.md:session/canvasRequestCompletedcanvasRequestsentry with matchingrequestIdsession/canvasRequestCompletedresultnorerrorset, or both setsession/canvasRequestCompletedtarget.kind === 'server'— completion is server-only for thosesession/canvasRequestCompletedcanvasRequests[i].target.clientId(whentarget.kind === 'activeClient')session/canvasRequestCompletedresult.kinddoes not match the originating request'skind(e.g.openrequest answered withkind: 'action'result)session/canvasInstanceCloseRequestedopenCanvasesentry with matchinginstanceIdsession/canvasInstanceCloseRequestedopenCanvases[i].renderer?.clientId !== dispatcherClientId, whenrendereris set)5. Version registry entries
No new entries in
NOTIFICATION_INTRODUCED_IN— all canvas surface travels as actions on existing session channels.6. URL handling
SessionOpenCanvas.urlcarries renderer-targeted URLs. The wire shape is a plain string; policy is enforced by the renderer. The spec should require renderers to implement at least the first two of:https,file,data, plus implementation-defined extension schemes for in-process surfaces (e.g.vscode-webview://,tauri-app://).httpMAY be allowed only forlocalhostand127.0.0.1.sandbox, separate webview / web-contents context, etc.) so canvas URLs cannot read application-scope state or credentials.Authorization,Cookie, and other ambient credentials on navigation to a canvas URL.The alternative — modelling the URL as a typed
CanvasRenderTargetunion ({ kind: 'https', url } | { kind: 'localProcess', socket } | ...) — is more restrictive but more brittle as new render modes appear. Worth revisiting if the opaque-URL approach proves leaky.Files to touch
types/channels-session/state.ts— newSessionCanvas*types; extendSessionStateandSessionActiveClient.types/channels-session/actions.ts— new action interfaces, extend the per-channel action union.types/channels-session/reducer.ts— new reducer arms per §3.types/common/actions.ts— extendActionTypeenum with the eight new entries.types/version/registry.ts— eight newACTION_INTRODUCED_INentries at'0.3.0'.schema/*.json— regenerate.docs/guide/canvases.md(new) — concepts (registry vs instance vs request), end-to-end flows with mermaid diagrams, the three-planes framing for SDK consumers, security policy.docs/specification/session-channel.md— append a "Canvas validation" subsection mirroring the input-request validation rules.clients/{rust,kotlin,swift,typescript}/...— regenerate language clients.types/test-cases/reducers/— reducer tests covering registry-replace, instance-upsert / partial-update / close cascade, request-upsert / complete / cancel, instance-close-requested no-op.7. Bridge contract — wire-name mapping
Not part of the AHP spec, but the contract a host-internal bridge layer implements once the spec lands. Included here so AHP maintainers can see what each new action is solving in terms of the underlying runtime call.
SessionConfig::with_canvases([...])(server-side)session/canvasRegistryChangedwith the aggregated setsession/activeClientChangedcarriesactiveClient.canvasProviderssession/canvasRegistryChangedsession.canvas.registry_changedstate.canvasRegistry, emitssession/canvasRegistryChangedif changedcanvas.opensession/canvasRequestCreated { kind: 'open' }; awaitssession/canvasRequestCompleted; on success emitssession/canvasInstanceOpenedand replies to Plane 1 withCanvasProviderOpenResult; on error or timeout emitssession/canvasRequestCancelledand replies withCanvasErrorcanvas.action.invokekind: 'action'; on success replies to Plane 1 with thevalue; nocanvasInstanceOpenedemissioncanvas.closekind: 'close'; on success emitssession/canvasInstanceClosedand replies to Plane 1 with()session.canvas.opened(state change on an existing instance)session/canvasInstanceUpdated(narrow partial merge)session.canvas.listOpenstate.openCanvasesif drift detectedsession/canvasInstanceUpdatedwithavailability: 'stale'for every open instance owned by that provider; cancels in-flightcanvasRequeststargeting it viasession/canvasRequestCancelledsession/canvasInstanceCloseRequestedsession.canvas.closeon the runtime; the rest follows the standard close flowCompatibility
Purely additive.
0.2.0consumers and SHOULD be ignored by them per the existing additive-evolution rule.initializecapabilities — renderer capability is discovered by state inspection (activeClient.canRenderCanvases), matching how the rest of AHP works today.A
0.2.0host that has not adopted the surface produces no canvas state and emits no canvas actions; a0.3.0-aware client behaves identically against it. A0.3.0client connected to a0.2.0host sees no canvas state and learns there is nothing to render.Open design questions
These are the calls a follow-up PR should explicitly request review on. Each has a proposed position and the alternative the maintainers should push back on if they disagree.
Correlation primitive — separate or shared with input requests?
Proposed: keep
canvasRequestsas its own state slice even though the request/response pattern mirrorsinputRequests. The two surfaces have meaningfully different lifecycles (turn-scoped vs session-scoped) and audiences (user vs provider), and unifying them would force one to compromise.Alternative: introduce a generic
pendingRequestsslice both use. Open to upstream's call.Provider ownership — active-client only, or also server-scoped?
Proposed: allow both.
source: 'server'for host-process / in-process-extension providers;source: 'activeClient'+clientIdfor client-contributed providers. Implication: when the active client disconnects, registry entries with thatclientIdare removed andopenCanvasesentries routed to that client transition toavailability: 'stale'.Alternative: restrict to active-client only, forcing every server-side provider to be expressed as a synthetic "server" client. Simpler state, but loses the natural distinction.
Registry-mutation timing — atomic with active-client change, or two-step?
Proposed: two-step.
activeClientChangedapplies immediately; the host performs Plane-1 register / deregister and emitscanvasRegistryChangedonce the runtime acknowledges. Open instances keepavailability: 'stale'until the new client (or a reconnect) re-publishes.Alternative: make
activeClientChangedcarry an implicit registry replacement. Lower latency, but couples two state slices in a way no existing action does.canRenderCanvases— boolean, struct, or absent?Proposed: a single
canRenderCanvases?: booleanonSessionActiveClient, semantically "I will acceptcanvasRequestCreatedwithtarget.clientId === me."Alternative A: richer struct (
canvasRenderer?: { schemes: string[]; maxConcurrentInstances?: number }) — worth doing if URL-scheme negotiation is needed at the wire level.Alternative B: infer from
canvasProvidersbeing non-empty — brittle and conflates two concerns.Cancellation by the agent.
The runtime has no client-initiated cancellation for in-flight
canvas.open/canvas.action.invoketoday. If AHP growssession/canvasRequestCancelledas a client-dispatchable action (e.g. "the user closed the panel mid-load"), the bridge needs a story for what to do on Plane 1.Proposed: keep
canvasRequestCancelledserver-only in 0.3.0. Revisit in a follow-up if real renderer use cases emerge.Instance close vs. instance stale on provider disconnect.
Proposed: on provider disconnect, transition
openCanvases[i].availabilityto'stale'but keep the entry. The agent (or a recovering bridge) can re-issuesession.canvas.openfor the sameinstanceIdto rehydrate via the underlying runtime's reopen path. Cancel any in-flightcanvasRequeststargeting the disconnected provider.Alternative: hard-remove on disconnect. Simpler reducer, but loses the rebind story.
SessionOpenCanvas.inputretention.Proposed: keep the open-time
inputonSessionOpenCanvasso a recovering bridge has enough information to drive a rebind without round-tripping the agent.Alternative: drop after open completes; force rebinds to go through a fresh agent-issued open. Smaller state at the cost of more chatter.
Single
_metaescape hatch vs. typed surface.Proposed: typed surface, per this issue.
Alternative: leave Canvas as a vendor
_metaextension forever. Rejected because every AHP client that wants to render canvases would have to grow matching custom handling, defeating the protocol's job.How does the bridge populate the source runtime's per-callback host-capabilities flag?
The source runtime's provider callbacks (
canvas.open,canvas.close,canvas.action.invoke) carry ahost.capabilities.canvases?: bool. This is not the same thing asSessionActiveClient.canRenderCanvases. It is what the bridge populates per provider invocation at call time, computed as: there is an active client ANDactiveClient.canRenderCanvases === trueAND the bridge is willing to route to this provider. Captured here so the question doesn't get asked in code review; it's a bridge concern, not an AHP-spec concern.Why not the alternatives
For completeness — the shapes considered and rejected before this proposal:
customizations. Mechanical fit (registered, id-keyed, tree-shaped) but semantic mismatch — customizations are agent-augmentation sources (plugins, skills, MCP servers) with their own parsing / load-state lifecycle. Conflating them would force every customization consumer to handle a new container type and would lose the per-instance state Canvas needs.inputRequests. Right correlation pattern but wrong scope — input requests are turn-scoped, user-facing, and clean up on turn end. Canvas instances are session-scoped, provider-facing, and outlive turns._meta. Acceptable as a prototype vehicle while this issue is in review; not a production answer (loses type safety, schema validation, reducer support, version-registry tracking, and reusability across AHP clients).canvasRequestscovers the same use case using AHP-native primitives. If a future feature genuinely needs server-initiated request/response, that would be the right time to make the change in its own RFC.Implementation phasing
Happy to open the PR once maintainers weigh in on the open questions above — particularly #2 (provider ownership) and #4 (
canRenderCanvasesshape), since they constrain the state shape directly. Suggested phasing:types/,schema/,docs/, and the language clients. Reducer tests cover registry-replace, instance upsert / partial-update / close cascade, request upsert / complete / cancel, and instance-close-requested no-op.CanvasRenderTargetunion, richer renderer capabilities) come in their own RFCs once real usage shakes out the rough edges.References
sessionFs/*server → client request family; same downstream-runtime-pattern-doesn't-fit-AHP-yet theme, different surface.docs/guide/customizations.md— the existing pattern this proposal's state and action shapes mirror most directly.docs/guide/elicitation.md— the existing pattern this proposal's request/response correlation mirrors.docs/specification/session-channel.md— where new validation rules and a canvas methods/events table need to land.types/version/registry.ts— exhaustiveACTION_INTRODUCED_INmap this proposal extends.Glossary
canvas.open/canvas.close/canvas.action.invoke. May be the host process itself, or an extension hosted inside it.SessionOpenCanvas(the URL, the title) in its UI. Distinct from provider.canvasIdadvertised by a provider that the agent can open.instanceId.StateAction.Appendix — full source-runtime Canvas wire reference
Provided for ground-truth verification. Field names are wire names (camelCase). All types are marked experimental in the source runtime.