diff --git a/CHANGELOG.md b/CHANGELOG.md
index adf7fb4..0de13e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@
All notable changes to `pmx-canvas` are documented here. This project follows
[Semantic Versioning](https://semver.org/).
+## Unreleased
+
+### Added
+
+- AX-enabled HTML nodes opened as standalone browser surfaces receive short-lived, node-scoped
+ grants, so their existing `window.PMX_AX` controls and live state work outside the canvas iframe
+ without weakening node capability validation.
+
## [0.6.2] - 2026-09-09
### Changed
diff --git a/skills/pmx-canvas/SKILL.md b/skills/pmx-canvas/SKILL.md
index 9221036..0143364 100644
--- a/skills/pmx-canvas/SKILL.md
+++ b/skills/pmx-canvas/SKILL.md
@@ -462,10 +462,10 @@ Prefer `canvas_query { action: "search" }` over parsing the full layout.
- Hosted MCP-app/ext-app nodes such as Excalidraw require the in-canvas host bridge and are not
standalone **Open as site** targets. URL-backed viewers and bundled web artifacts remain
openable.
-- A standalone html surface (`/api/canvas/surface/:id` opened as a site) is a VISUAL view: it
- renders the same content and theme, but `window.PMX_AX` is not injected without the canvas
- iframe's per-mount nonce, so AX buttons only work inside the in-canvas node (0.4.4 Codex note).
- Do not tell a user a standalone tab's controls will steer the agent.
+- An AX-enabled standalone html surface (`/api/canvas/surface/:id` opened as a site) receives a
+ short-lived, node-scoped control grant. Its `window.PMX_AX` bridge can emit only the capabilities
+ enabled on that node, and the server re-validates every interaction. State is refreshed while the
+ tab remains open. Reload after a daemon restart because in-memory standalone grants are revoked.
- A hosted ext-app (Excalidraw) node in a **WebKit** host panel (e.g. the GitHub Copilot app's
embedded WKWebView) historically could render as a black tile — a host compositor paint race
on the nested iframe, **not** a broken node (the session is healthy, `sessionStatus` is
diff --git a/skills/pmx-canvas/references/ax-html-control-surface.md b/skills/pmx-canvas/references/ax-html-control-surface.md
index a2b44f7..bfee8cb 100644
--- a/skills/pmx-canvas/references/ax-html-control-surface.md
+++ b/skills/pmx-canvas/references/ax-html-control-surface.md
@@ -93,3 +93,14 @@ ceiling). Flip an existing node on with `canvas_node({ action: "update", id, axC
Allowed `type`s are gated per node capability (see the node-capability matrix in
`SKILL.md`). Emits are clamped to the surface's own node; the server re-validates every
interaction — the bridge is convenience, not a trust boundary.
+
+## Standalone browser control
+
+Opening an AX-enabled HTML node through `/api/canvas/surface/:nodeId` now creates a short-lived,
+node-scoped browser grant. The page receives the same `window.PMX_AX` contract and a refreshed AX
+state snapshot even though its CSP sandbox keeps an opaque origin. The grant cannot target another
+node or exceed that node's configured capabilities, and every interaction still passes through
+the normal server-side capability validation.
+
+Standalone grants live in server memory for 12 hours. Reload the page after the daemon restarts or
+when a grant expires. HTML nodes without enabled AX capabilities remain visual-only.
diff --git a/src/server/html-surface.ts b/src/server/html-surface.ts
index 5266da3..28b9ff9 100644
--- a/src/server/html-surface.ts
+++ b/src/server/html-surface.ts
@@ -171,6 +171,62 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
`;
}
+/**
+ * Top-level "Open as site" bridge. Standalone surfaces have no parent canvas
+ * to relay postMessage traffic, and their CSP sandbox gives them an opaque
+ * origin. A short-lived server grant therefore scopes direct CORS requests to
+ * this node while applyAxInteraction still enforces the node capability ceiling.
+ */
+export function buildStandaloneAxBridge(axToken: string, nodeId: string): string {
+ const token = JSON.stringify(axToken);
+ const node = JSON.stringify(nodeId);
+ return ``;
+}
+
/**
* Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX
* state and keeps it live via nonce-validated `ax-update` messages from the parent
@@ -247,6 +303,8 @@ export interface HtmlSurfaceOptions {
axBridge?: boolean;
/** Nonce authorizing iframe → parent AX emits; embedded in the bridge. */
axToken?: string;
+ /** Server-minted grant for an AX-enabled top-level standalone surface. */
+ standaloneAxToken?: string;
/** Node id stamped on emitted interactions. */
nodeId?: string;
/**
@@ -282,7 +340,11 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
const presentationBridge = options.presentation
? buildPresentationEscapeBridge(sanitizeToken(options.presentationExitToken))
: '';
- const axBridge = options.axBridge ? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId)) : '';
+ const axBridge = options.axBridge
+ ? options.standaloneAxToken
+ ? buildStandaloneAxBridge(sanitizeToken(options.standaloneAxToken), sanitizeToken(options.nodeId))
+ : buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId))
+ : '';
// Read-side AX state bridge (seed + live push). `` is escaped so a work-item
// title containing "" can't break out of the inline script.
const axStateBridge = options.axBridge
diff --git a/src/server/server.ts b/src/server/server.ts
index 21995aa..9b849d2 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -61,7 +61,7 @@ import { findOpenCanvasPosition } from './placement.js';
import { mutationHistory } from './mutation-history.js';
import { buildAgentContextPreamble } from './agent-context.js';
import { buildCanvasAxSurfaceSnapshot } from './ax-context.js';
-import { resolveNodeAxCapabilities } from './ax-interaction.js';
+import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
import { canvasThemeScheme, isCanvasTheme } from '../shared/themes.js';
import { canOpenNodeAsSurface } from '../shared/surface.js';
@@ -1398,6 +1398,93 @@ function surfaceRedirect(target: string): Response {
return new Response(null, { status: 302, headers: { Location: target, 'Cache-Control': 'no-store' } });
}
+const STANDALONE_AX_GRANT_TTL_MS = 12 * 60 * 60 * 1_000;
+const standaloneAxGrants = new Map();
+
+function mintStandaloneAxGrant(nodeId: string): string {
+ const now = Date.now();
+ for (const [token, grant] of standaloneAxGrants) {
+ if (grant.expiresAt <= now) standaloneAxGrants.delete(token);
+ }
+ const token = randomUUID();
+ standaloneAxGrants.set(token, { nodeId, expiresAt: now + STANDALONE_AX_GRANT_TTL_MS });
+ return token;
+}
+
+function validateStandaloneAxGrant(token: string, nodeId: string): boolean {
+ const grant = standaloneAxGrants.get(token);
+ if (!grant || grant.nodeId !== nodeId || grant.expiresAt <= Date.now()) {
+ if (grant) standaloneAxGrants.delete(token);
+ return false;
+ }
+ const node = canvasState.getNode(nodeId);
+ const capabilities = node ? resolveNodeAxCapabilities(node) : null;
+ if (node?.type !== 'html' || !capabilities?.enabled || capabilities.allowed.length === 0) {
+ standaloneAxGrants.delete(token);
+ return false;
+ }
+ return true;
+}
+
+function standaloneAxResponse(data: unknown, status = 200): Response {
+ return Response.json(data, {
+ status,
+ headers: {
+ 'Access-Control-Allow-Origin': 'null',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Cache-Control': 'no-store',
+ Vary: 'Origin',
+ },
+ });
+}
+
+async function handleStandaloneAxRequest(req: Request, url: URL): Promise {
+ if (req.method === 'OPTIONS') return standaloneAxResponse({ ok: true });
+ if (req.headers.get('origin') !== 'null') {
+ return standaloneAxResponse(
+ { ok: false, code: 'invalid-origin', error: 'Standalone AX requires an opaque sandbox origin.' },
+ 403,
+ );
+ }
+
+ const match = url.pathname.match(/^\/api\/canvas\/surface-ax\/([^/]+)\/(state|interaction)$/);
+ if (!match)
+ return standaloneAxResponse({ ok: false, code: 'invalid-route', error: 'Unknown standalone AX route.' }, 404);
+ const nodeId = decodeURIComponent(match[1]);
+ const action = match[2];
+ const token = url.searchParams.get('token') ?? '';
+ if (!validateStandaloneAxGrant(token, nodeId)) {
+ return standaloneAxResponse(
+ { ok: false, code: 'invalid-grant', error: 'Standalone AX grant is invalid or expired.' },
+ 403,
+ );
+ }
+
+ if (action === 'state' && req.method === 'GET') {
+ return standaloneAxResponse(buildCanvasAxSurfaceSnapshot());
+ }
+ if (action !== 'interaction' || req.method !== 'POST') {
+ return standaloneAxResponse({ ok: false, code: 'method-not-allowed', error: 'Method not allowed.' }, 405);
+ }
+
+ const body = await readJson(req);
+ if (body === null)
+ return standaloneAxResponse({ ok: false, code: 'invalid-json', error: 'Request body must be valid JSON.' }, 400);
+ const { result, events } = applyAxInteraction(
+ canvasState,
+ {
+ type: body.type,
+ sourceNodeId: nodeId,
+ payload: body.payload,
+ sourceSurface: 'html-node',
+ },
+ 'browser',
+ );
+ for (const event of events) emitPrimaryWorkbenchEvent(event.event, event.payload);
+ return standaloneAxResponse(result, result.ok ? 200 : result.status);
+}
+
// Permit only absolute http(s) URLs and root-relative same-origin paths. Blocks
// `javascript:`/`data:` and protocol-relative `//host` open-redirects.
function isSafeSurfaceRedirect(target: string): boolean {
@@ -1459,6 +1546,8 @@ function handleNodeSurface(pathname: string, url: URL): Response {
const present = url.searchParams.get('present') === '1';
const axCaps = resolveNodeAxCapabilities(node);
const axEnabled = axCaps.enabled && axCaps.allowed.length > 0;
+ const embeddedAxToken = url.searchParams.get('axToken') ?? '';
+ const standaloneAxToken = axEnabled && !embeddedAxToken ? mintStandaloneAxGrant(node.id) : undefined;
const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id;
const doc = buildHtmlSurfaceDocument(html, {
theme,
@@ -1467,7 +1556,8 @@ function handleNodeSurface(pathname: string, url: URL): Response {
presentation: present,
presentationExitToken: url.searchParams.get('presentToken') ?? undefined,
axBridge: axEnabled,
- axToken: url.searchParams.get('axToken') ?? undefined,
+ axToken: embeddedAxToken || undefined,
+ standaloneAxToken,
nodeId: node.id,
// Seed the read-side bridge with the current AX state (only for AX surfaces).
...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
@@ -3189,6 +3279,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
if (server) {
return typeof server.port === 'number' ? loopbackBaseUrl(server.port) : null;
}
+ standaloneAxGrants.clear();
// An explicit `options.workspaceRoot` wins. Otherwise honor PMX_CANVAS_WORKSPACE_ROOT
// (Finding I escape hatch) before falling back to the launch cwd, so a host that
@@ -3328,6 +3419,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
return handleIframeProbe();
}
+ if (url.pathname.startsWith('/api/canvas/surface-ax/')) {
+ return handleStandaloneAxRequest(req, url);
+ }
+
if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) {
return handleNodeSurface(url.pathname, url);
}
@@ -3428,6 +3523,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
}
export function stopCanvasServer(): void {
+ standaloneAxGrants.clear();
stopGateTtlSweeper();
agentPresence.reset();
humanPresence.reset();
diff --git a/tests/unit/server-api.test.ts b/tests/unit/server-api.test.ts
index 78eb689..e0289dd 100644
--- a/tests/unit/server-api.test.ts
+++ b/tests/unit/server-api.test.ts
@@ -2620,6 +2620,95 @@ describe('canvas server HTTP API', () => {
expect(on).toContain('ax-test');
});
+ test('standalone AX surface uses a scoped grant for state and interactions', async () => {
+ canvasState.addNode({
+ id: 'surf-ax-standalone',
+ type: 'html',
+ position: { x: 0, y: 0 },
+ size: { width: 400, height: 300 },
+ zIndex: 1,
+ collapsed: false,
+ pinned: false,
+ data: { html: 'x', axCapabilities: { enabled: true, allowed: ['ax.work.create'] } },
+ });
+
+ const surface = await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone`);
+ const html = await surface.text();
+ expect(html).toContain('data-pmx-canvas-standalone-ax-bridge');
+ const token = html.match(/const PMX_AX_TOKEN = "([^"]+)";/)?.[1];
+ expect(token).toBeTruthy();
+
+ const state = await fetch(
+ `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/state?token=${encodeURIComponent(token as string)}`,
+ { headers: { Origin: 'null' } },
+ );
+ expect(state.status).toBe(200);
+ expect(state.headers.get('access-control-allow-origin')).toBe('null');
+ expect((await state.json()) as { workItems?: unknown[] }).toHaveProperty('workItems');
+
+ const created = await fetch(
+ `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/interaction?token=${encodeURIComponent(token as string)}`,
+ {
+ method: 'POST',
+ headers: { Origin: 'null', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: 'ax.work.create', payload: { title: 'Standalone work' } }),
+ },
+ );
+ expect(created.status).toBe(200);
+ expect((await created.json()) as { ok?: boolean }).toMatchObject({ ok: true });
+ expect(canvasState.getWorkItems().some((item) => item.title === 'Standalone work')).toBe(true);
+
+ const forbidden = await fetch(
+ `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/interaction?token=${encodeURIComponent(token as string)}`,
+ {
+ method: 'POST',
+ headers: { Origin: 'null', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: 'ax.steer', payload: { message: 'not allowed' } }),
+ },
+ );
+ expect(forbidden.status).toBe(403);
+ expect((await forbidden.json()) as { code?: string }).toMatchObject({ code: 'not-allowed' });
+
+ canvasState.updateNode('surf-ax-standalone', {
+ data: {
+ ...canvasState.getNode('surf-ax-standalone')?.data,
+ axCapabilities: { enabled: false, allowed: ['ax.work.create'] },
+ },
+ });
+ const revoked = await fetch(
+ `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/state?token=${encodeURIComponent(token as string)}`,
+ { headers: { Origin: 'null' } },
+ );
+ expect(revoked.status).toBe(403);
+ expect((await revoked.json()) as { code?: string }).toMatchObject({ code: 'invalid-grant' });
+
+ canvasState.updateNode('surf-ax-standalone', {
+ data: {
+ ...canvasState.getNode('surf-ax-standalone')?.data,
+ axCapabilities: { enabled: true, allowed: ['ax.work.create'] },
+ },
+ });
+ const restartHtml = await (await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone`)).text();
+ const restartToken = restartHtml.match(/const PMX_AX_TOKEN = "([^"]+)";/)?.[1];
+ expect(restartToken).toBeTruthy();
+ stopCanvasServer();
+ const restarted = startCanvasServer({ workspaceRoot, port: 0 });
+ expect(restarted).toBeTruthy();
+ baseUrl = restarted!;
+ const expiredOnRestart = await fetch(
+ `${baseUrl}/api/canvas/surface-ax/surf-ax-standalone/state?token=${encodeURIComponent(restartToken as string)}`,
+ { headers: { Origin: 'null' } },
+ );
+ expect(expiredOnRestart.status).toBe(403);
+ expect((await expiredOnRestart.json()) as { code?: string }).toMatchObject({ code: 'invalid-grant' });
+
+ const embedded = await fetch(`${baseUrl}/api/canvas/surface/surf-ax-standalone?axToken=embedded-token`).then(
+ (response) => response.text(),
+ );
+ expect(embedded).not.toContain('data-pmx-canvas-standalone-ax-bridge');
+ expect(embedded).toContain('embedded-token');
+ });
+
test('surface route falls back to content, 404s when html node is empty', async () => {
canvasState.addNode({
id: 'surf-content',