Skip to content

Proposal: Canvas — declared, instanced, agent-openable UI surfaces #163

@colbylwilliams

Description

@colbylwilliams

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.

  1. 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.
  2. 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.
  3. 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/inputRequestedsession/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:

  1. 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.
  2. 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.
  3. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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:

  1. 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.
  2. Bridge implementation work in downstream hosts is then unblocked and proceeds in their own repositories.
  3. 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: #162sessionFs/* 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions