diff --git a/.changeset/hidden-mcp-apps-core-helper.md b/.changeset/hidden-mcp-apps-core-helper.md new file mode 100644 index 00000000000..539fbda2b59 --- /dev/null +++ b/.changeset/hidden-mcp-apps-core-helper.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": none +--- + +Carry the shared `isOutsideAgentCall` helper from #8005, whose changeset supplies the release note. diff --git a/packages/core/src/action-caller.spec.ts b/packages/core/src/action-caller.spec.ts new file mode 100644 index 00000000000..231196c33d4 --- /dev/null +++ b/packages/core/src/action-caller.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { isOutsideAgentCall } from "./action-caller.js"; +import type { ActionCaller, ActionRunContext } from "./action.js"; + +const callers = { + frontend: { outside: false, reason: "the app's own UI" }, + tool: { outside: false, reason: "the app's own agent without delegation" }, + http: { + outside: true, + reason: + "HTTP alone does not prove a human UI click; action routes verify UI capability separately", + }, + cli: { + outside: true, + reason: + "scripts and the development CLI do not prove an in-app human interaction", + }, + automation: { + outside: true, + reason: "scheduled or triggered execution is not an interactive human call", + }, + mcp: { outside: true, reason: "a direct MCP tool call" }, + a2a: { outside: true, reason: "a direct A2A action call" }, + webmcp: { outside: true, reason: "a browser agent's WebMCP call" }, + "mcp-widget": { outside: true, reason: "a card's scoped read capability" }, + "mcp-widget-write": { + outside: true, + reason: "a card's scoped write capability", + }, +} satisfies Record; + +const protocols = { + a2a: "the default A2A chat handoff retains tool as the caller", + mcp: "ask_app runs the app agent as tool on an outside agent's behalf", + "provider-api": + "external provider provenance does not establish an in-app human call", +} satisfies Record, string>; + +describe("isOutsideAgentCall", () => { + for (const [caller, { outside, reason }] of Object.entries(callers)) { + const ctx = { caller: caller as ActionCaller }; + + it(`classifies ${caller}: ${reason}`, () => { + expect(isOutsideAgentCall(ctx)).toBe(outside); + }); + + it(`treats ${caller} with automation context as outside`, () => { + expect( + isOutsideAgentCall({ + ...ctx, + automation: { triggerId: "trigger-1", triggerName: "Scheduled run" }, + }), + ).toBe(true); + }); + + for (const [protocol, reason] of Object.entries(protocols)) { + it(`treats ${caller} over ${protocol} as outside: ${reason}`, () => { + expect( + isOutsideAgentCall({ + ...ctx, + networkProtocol: protocol as ActionRunContext["networkProtocol"], + }), + ).toBe(true); + }); + } + } + + it("fails closed without context or a recognized caller", () => { + expect(isOutsideAgentCall()).toBe(true); + expect(isOutsideAgentCall({} as ActionRunContext)).toBe(true); + expect( + isOutsideAgentCall({ caller: "future-caller" } as ActionRunContext), + ).toBe(true); + }); +}); diff --git a/packages/core/src/action-caller.ts b/packages/core/src/action-caller.ts new file mode 100644 index 00000000000..ccf74e55def --- /dev/null +++ b/packages/core/src/action-caller.ts @@ -0,0 +1,14 @@ +import type { ActionRunContext } from "./action.js"; + +/** Classify provenance for approval policy; this does not authenticate a caller. */ +export function isOutsideAgentCall( + ctx?: Pick, +): boolean { + // CLI defaults to outside because coding agents drive it; Alice's decision is pending. + // Automation has no trusted origin in ctx yet; #8070's mcpManaged is job frontmatter. + return ( + ctx?.networkProtocol !== undefined || + ctx?.automation !== undefined || + (ctx?.caller !== "frontend" && ctx?.caller !== "tool") + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 357e727e912..8871c4a3ab2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,7 @@ export { type ActionMcpAppResourceConfig, type ActionMcpAppResourceMeta, } from "./action.js"; +export { isOutsideAgentCall } from "./action-caller.js"; export * from "./user-profile/index.js"; export { embedApp, diff --git a/packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap b/packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap new file mode 100644 index 00000000000..17847a913d0 --- /dev/null +++ b/packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap @@ -0,0 +1,275 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`explicit connector inline apps > advertises each inline tool and its UI resource on the default production catalog 1`] = ` +{ + "analytics": [ + { + "_meta": { + "openai/outputTemplate": "ui://analytics/open-traffic-dashboard/shell-v65", + "openai/toolInvocation/invoked": "Traffic dashboard ready", + "openai/toolInvocation/invoking": "Opening Traffic dashboard", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://analytics/open-traffic-dashboard/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://analytics/open-traffic-dashboard/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Open traffic dashboard", + }, + "name": "open-traffic-dashboard", + }, + ], + "calendar": [ + { + "_meta": { + "openai/outputTemplate": "ui://calendar/manage-event-draft/shell-v65", + "openai/toolInvocation/invoked": "Review calendar invite ready", + "openai/toolInvocation/invoking": "Opening Review calendar invite", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://calendar/manage-event-draft/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://calendar/manage-event-draft/shell-v65", + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Manage event draft", + }, + "name": "manage-event-draft", + }, + ], + "content": [ + { + "_meta": { + "openai/outputTemplate": "ui://content/share-local-file-document/shell-v65", + "openai/toolInvocation/invoked": "Share document ready", + "openai/toolInvocation/invoking": "Opening Share document", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://content/share-local-file-document/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://content/share-local-file-document/shell-v65", + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Share local file document", + }, + "name": "share-local-file-document", + }, + ], + "design": [ + { + "_meta": { + "openai/outputTemplate": "ui://design/generate-screens/shell-v65", + "openai/toolInvocation/invoked": "Design generation session ready", + "openai/toolInvocation/invoking": "Opening Design generation session", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://design/generate-screens/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://design/generate-screens/shell-v65", + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Generate screens", + }, + "name": "generate-screens", + }, + { + "_meta": { + "openai/outputTemplate": "ui://design/export-coding-handoff/shell-v65", + "openai/toolInvocation/invoked": "Design agent handoff ready", + "openai/toolInvocation/invoking": "Opening Design agent handoff", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://design/export-coding-handoff/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://design/export-coding-handoff/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Export coding handoff", + }, + "name": "export-coding-handoff", + }, + ], + "slides": [ + { + "_meta": { + "openai/outputTemplate": "ui://slides/clone-context-slide/shell-v65", + "openai/toolInvocation/invoked": "Cloned native slide ready", + "openai/toolInvocation/invoking": "Opening Cloned native slide", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://slides/clone-context-slide/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://slides/clone-context-slide/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Clone context slide", + }, + "name": "clone-context-slide", + }, + ], + "slides-2": [ + { + "_meta": { + "openai/outputTemplate": "ui://slides-2/clone-context-slide/shell-v65", + "openai/toolInvocation/invoked": "Cloned native slide ready", + "openai/toolInvocation/invoking": "Opening Cloned native slide", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://slides-2/clone-context-slide/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://slides-2/clone-context-slide/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Clone context slide", + }, + "name": "clone-context-slide", + }, + ], +} +`; diff --git a/packages/core/src/mcp/advertised-catalog.harness.ts b/packages/core/src/mcp/advertised-catalog.harness.ts index 14a120ab8c2..40ca73fed68 100644 --- a/packages/core/src/mcp/advertised-catalog.harness.ts +++ b/packages/core/src/mcp/advertised-catalog.harness.ts @@ -25,6 +25,7 @@ export const LOAD_TIMEOUT_MS = 600_000; export interface AdvertisedTool { name: string; + _meta?: Record; annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; @@ -273,7 +274,11 @@ function createCloseHooks() { export async function listAdvertised( config: MCPConfig, - request: { fullCatalog: boolean; fullSurface: boolean }, + request: { + fullCatalog: boolean; + fullSurface: boolean; + inlineMcpApps?: boolean; + }, ): Promise<{ tools: AdvertisedTool[]; instructions: string }> { const server = await createMCPServerForRequest( config, @@ -285,7 +290,7 @@ export async function listAdvertised( }, { transport: "http", - inlineMcpApps: false, + inlineMcpApps: request.inlineMcpApps ?? false, ...(request.fullCatalog ? { fullCatalog: true } : {}), ...(request.fullSurface ? { fullSurface: true } : {}), }, @@ -300,6 +305,7 @@ export async function listAdvertised( const tools = (await client.listTools()).tools.map((tool) => ({ name: tool.name, annotations: { ...(tool.annotations ?? {}) }, + ...(request.inlineMcpApps ? { _meta: tool._meta } : {}), })); const instructions = client.getInstructions() ?? ""; await client.close(); diff --git a/packages/core/src/mcp/hidden-inline-apps.spec.ts b/packages/core/src/mcp/hidden-inline-apps.spec.ts new file mode 100644 index 00000000000..4b4996890fd --- /dev/null +++ b/packages/core/src/mcp/hidden-inline-apps.spec.ts @@ -0,0 +1,140 @@ +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport } from "@modelcontextprotocol/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { + LOAD_TIMEOUT_MS, + listAdvertised, + loadAppCatalogs, + type AppCatalog, +} from "./advertised-catalog.harness.js"; +import { createMCPServerForRequest } from "./build-server.js"; + +vi.mock("../server/better-auth-instance.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../server/better-auth-instance.js") + >()), + getAuthSecret: () => "fake-test-only-inline-app-approval-secret", +})); +vi.mock("./approval-store.js", () => ({ + createMcpApprovalGrant: vi.fn(async () => {}), + consumeMcpApprovalGrant: vi.fn(async () => true), +})); + +const INLINE_ACTIONS: Record = { + analytics: ["open-traffic-dashboard"], + calendar: ["manage-event-draft"], + content: ["share-local-file-document"], + design: ["generate-screens", "export-coding-handoff"], + slides: ["clone-context-slide"], + "slides-2": ["clone-context-slide"], +}; + +let apps: AppCatalog[]; +beforeAll(async () => { + apps = await loadAppCatalogs(Object.keys(INLINE_ACTIONS)); +}, LOAD_TIMEOUT_MS); + +describe("explicit connector inline apps", () => { + it( + "advertises each inline tool and its UI resource on the default production catalog", + async () => { + vi.stubEnv("AGENT_MODE", "production"); + vi.stubEnv("AGENT_PROD_CODE_EXECUTION", "off"); + try { + const catalogs: Record = {}; + expect(apps.map((app) => app.appId).sort()).toEqual( + Object.keys(INLINE_ACTIONS).sort(), + ); + for (const app of apps) { + const { tools } = await listAdvertised(app.surfaces[0]!.config, { + fullCatalog: false, + fullSurface: false, + inlineMcpApps: true, + }); + catalogs[app.appId] = INLINE_ACTIONS[app.appId]!.map((name) => { + const tool = tools.find((candidate) => candidate.name === name); + expect(tool, `${app.appId}/${name}`).toBeDefined(); + expect(tool?._meta?.ui, `${app.appId}/${name}`).toMatchObject({ + resourceUri: expect.stringMatching(/^ui:\/\//), + }); + return tool; + }); + } + expect(catalogs).toMatchSnapshot(); + } finally { + vi.unstubAllEnvs(); + } + }, + LOAD_TIMEOUT_MS, + ); + + it.each([ + ["content", "share-local-file-document", { id: "local-file-test" }], + ["design", "export-coding-handoff", { id: "design-test" }], + ] as const)( + "refuses %s/%s without host approval before running it", + async (appId, name, args) => { + const app = apps.find((candidate) => candidate.appId === appId)!; + const config = app.surfaces[0]!.config; + const entry = config.actions[name]!; + const run = vi.fn(async () => ({ id: "unexpected-write" })); + const actions = { ...config.actions, [name]: { ...entry, run } }; + const server = await createMCPServerForRequest( + { ...config, actions, productionActions: actions }, + { + userEmail: "approval-contract@example.com", + orgDomain: undefined, + orgId: null, + identityAssurance: "user", + oauthScopes: ["mcp:read", "mcp:write"], + }, + { transport: "http", inlineMcpApps: true }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "no-approval-host", version: "1.0.0" }); + try { + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("actionApproval"); + expect(JSON.stringify(result.content)).toContain( + "did not declare the required capability", + ); + expect(run).not.toHaveBeenCalled(); + } finally { + await client.close(); + await server.close(); + } + }, + LOAD_TIMEOUT_MS, + ); + + it("keeps Content form submission delegated", () => { + const app = apps.find((candidate) => candidate.appId === "content")!; + expect( + app.surfaces[0]!.catalogs.default.map((tool) => tool.name), + ).not.toContain("submit-content-database-form"); + }); + + it("keeps Design's external export instructions within its advertised tools", () => { + const app = apps.find((candidate) => candidate.appId === "design")!; + const names = app.surfaces[0]!.catalogs.default.map((tool) => tool.name); + const instructions = app.instructions; + for (const name of ["export-png", "export-coding-handoff"]) { + expect(instructions).toContain(name); + expect(names).toContain(name); + } + for (const name of [ + "export-html", + "export-zip", + "export-design-as-figma-svg", + ]) { + expect(instructions).not.toContain(name); + } + }); +}); diff --git a/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts b/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts index 063029e5aaf..ad9ece3c110 100644 --- a/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts +++ b/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts @@ -115,8 +115,6 @@ const NOT_TOOL_NAMES: Record> = { }, }; -const FULL_CATALOG_ONLY = - "advertised only on the full catalog; owners to reword or advertise"; const PAGE_LOCAL_WEBMCP = "a page-local WebMCP tool the text itself marks as a fallback for browser-capable hosts"; @@ -132,13 +130,6 @@ const ADVERTISED_ONLY_CONDITIONALLY: Record> = { ), design: { ...prose(PAGE_LOCAL_WEBMCP, "get-visual-edit-prompt"), - ...prose( - FULL_CATALOG_ONLY, - "export-html", - "export-zip", - "export-coding-handoff", - "export-design-as-figma-svg", - ), ...prose( "Builder app tools: they act only on designs backed by a Builder app, so the default catalog leaves them out", "send-builder-message", diff --git a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json index 906742f1794..b898e4e786f 100644 --- a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json +++ b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json @@ -301,7 +301,6 @@ "duplicate-booking-link", "event-action-helpers", "event-search", - "manage-event-draft", "navigate", "provider-api-request", "remove-external-calendar", @@ -493,7 +492,6 @@ "set-document-discoverability", "set-document-property", "set-image-alt-text", - "share-local-file-document", "stage-builder-revision", "stage-builder-source-bulk-update", "start-comment-ai-request", @@ -588,7 +586,6 @@ "export-html", "export-png", "export-zip", - "generate-screens", "get-design.schema", "go-to-main-component", "hydrate-figma-paste-images", @@ -746,7 +743,6 @@ "slides": [ "add-slide-comment", "apply-design-system", - "clone-context-slide", "clone-creative-context-deck", "create-deck-from-template", "create-design-system", diff --git a/scripts/ci-test-lanes.test.ts b/scripts/ci-test-lanes.test.ts index e93decafd42..26ae979ab9c 100644 --- a/scripts/ci-test-lanes.test.ts +++ b/scripts/ci-test-lanes.test.ts @@ -101,6 +101,7 @@ test("runs the catalog contract specs for any workspace source change", () => { const specs = [ "src/mcp/advertised-tool-annotations.spec.ts", "src/mcp/instructions-name-advertised-tools.spec.ts", + "src/mcp/hidden-inline-apps.spec.ts", ]; for (const changed of [ "templates/mail/actions/send-email.ts", diff --git a/scripts/ci-test-lanes.ts b/scripts/ci-test-lanes.ts index 187c0fc1532..c87738a202e 100644 --- a/scripts/ci-test-lanes.ts +++ b/scripts/ci-test-lanes.ts @@ -235,6 +235,7 @@ export function requiresFullCoreFastTests(paths: readonly string[]): boolean { const CATALOG_CONTRACT_CORE_SPECS = [ "src/mcp/advertised-tool-annotations.spec.ts", "src/mcp/instructions-name-advertised-tools.spec.ts", + "src/mcp/hidden-inline-apps.spec.ts", ] as const; const NON_CODE_ASSET_RE = /\.(?:css|scss|sass|less|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|eot|mp3|mp4|wav|webm|mov)$/iu; diff --git a/templates/analytics/AGENTS.md b/templates/analytics/AGENTS.md index 672d86495e5..b485e7359dc 100644 --- a/templates/analytics/AGENTS.md +++ b/templates/analytics/AGENTS.md @@ -30,6 +30,12 @@ Use local docs only (no web research): `pnpm action docs-search --query " - For MCP, use allowlisted cataloged actions; use `ask_app` for interpretation, source selection, multi-step, or unsupported actions. - Replay-key MCP write: `update-analytics-public-key` appends origins; the first origin restricts replay. +## Direct inline action + +| Action | Purpose | +| --- | --- | +| `open-traffic-dashboard` | Open the read-only first-party traffic dashboard inline | + ## Sessions and state - `list-session-recordings` filters scoped replays. Use `paginated: true` for sorted pages with a real total and app counts. The Sessions triage Lab adds `didEvents`, `didNotEvents`, `slow`, and `unused` filters. Get names/counts from `list-session-event-names` and event health from `list-event-catalog`; Analytics' index covers sessions only since its coverage start. Never query BigQuery for these views. diff --git a/templates/analytics/actions/mcp-action-contract.spec.ts b/templates/analytics/actions/mcp-action-contract.spec.ts index edb703e9453..766979265d7 100644 --- a/templates/analytics/actions/mcp-action-contract.spec.ts +++ b/templates/analytics/actions/mcp-action-contract.spec.ts @@ -38,6 +38,7 @@ const actions: Record = Object.fromEntries( ); const DIRECT_MCP_READS = [ + "open-traffic-dashboard", "account-deep-dive", "builder-blog-articles", "data-source-status", diff --git a/templates/analytics/actions/open-traffic-dashboard.ts b/templates/analytics/actions/open-traffic-dashboard.ts index 7b4de43f0ce..58f6ddcb4c7 100644 --- a/templates/analytics/actions/open-traffic-dashboard.ts +++ b/templates/analytics/actions/open-traffic-dashboard.ts @@ -15,6 +15,7 @@ function trafficDashboardDeepLink(): string { } export default defineAction({ + mcpTool: true, description: "Open the first-party traffic dashboard in the real Analytics app. Use this directly when the user asks to see their traffic dashboard, site traffic, app traffic, or first-party analytics dashboard inline in ChatGPT or Claude. Do not call view-screen, ask_app, or broad resource discovery first for this known dashboard.", schema: z.object({}), diff --git a/templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md b/templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md new file mode 100644 index 00000000000..d2a70f78c73 --- /dev/null +++ b/templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can open the Traffic dashboard inside their chat. diff --git a/templates/calendar/AGENTS.md b/templates/calendar/AGENTS.md index 3b7cc0b83f7..d4e7e736ae5 100644 --- a/templates/calendar/AGENTS.md +++ b/templates/calendar/AGENTS.md @@ -25,6 +25,7 @@ Use local docs only (no web research): `pnpm action docs-search --query " | Action | Purpose | | --- | --- | +| `manage-event-draft` | Manage unsent invite drafts; outside agents need approval for delete-all; never sends invitations | | `list-events` | Read a schedule with account and source coverage | | `create-event` | Create on an owned Google account's primary calendar | | `update-event` | Edit an event using its id and accountEmail | diff --git a/templates/calendar/actions/manage-event-draft.ts b/templates/calendar/actions/manage-event-draft.ts index 9b1aaf490d1..bc25463b3fe 100644 --- a/templates/calendar/actions/manage-event-draft.ts +++ b/templates/calendar/actions/manage-event-draft.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { readAppState, writeAppState, @@ -59,6 +59,15 @@ function setIfPresent( } export default defineAction({ + mcpTool: true, + needsApproval: (args, ctx) => + args.action === "delete-all" && isOutsideAgentCall(ctx), + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, description: "Create, update, or delete an unsent calendar invite draft. Opening a draft shows a visible placeholder on the calendar with the event detail editor so the user can review it before creating/sending.", schema: z.object({ diff --git a/templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md b/templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md new file mode 100644 index 00000000000..a7e42bf7d79 --- /dev/null +++ b/templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can open invite drafts in Calendar for review, with approval required before deleting all drafts. diff --git a/templates/calendar/server/lib/calendar-connector-catalog.spec.ts b/templates/calendar/server/lib/calendar-connector-catalog.spec.ts index 0d39ce4633d..4feba8c4101 100644 --- a/templates/calendar/server/lib/calendar-connector-catalog.spec.ts +++ b/templates/calendar/server/lib/calendar-connector-catalog.spec.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vitest"; +import manageEventDraft from "../../actions/manage-event-draft"; import { CALENDAR_CONNECTOR_CATALOG } from "./calendar-connector-catalog"; describe("Calendar MCP connector catalog", () => { - it("exposes only deterministic event inventory reads", () => { + it("opts unsent drafts into MCP beside the event inventory allowlist", () => { + expect(manageEventDraft.mcpTool).toBe(true); + expect(manageEventDraft.mcpAnnotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false, + }); + }); + + it("keeps the shared inventory allowlist read-only", () => { expect(CALENDAR_CONNECTOR_CATALOG).toEqual(["list-events"]); expect(CALENDAR_CONNECTOR_CATALOG).not.toContain("search-events"); expect(CALENDAR_CONNECTOR_CATALOG).not.toContain("get-event"); diff --git a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts index d80332e1acf..3e18880c5d5 100644 --- a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts +++ b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts @@ -1,5 +1,9 @@ import { createRequire } from "node:module"; +import { + actionCallIntent, + preparedCallNeedsApproval, +} from "@agent-native/core/action"; import { createMCPServerForRequest, type MCPConfig, @@ -28,6 +32,7 @@ const mocks = vi.hoisted(() => ({ rsvpEvent: vi.fn(), getUserSetting: vi.fn(), approval: vi.fn(), + deleteDrafts: vi.fn(), })); const { approvalAuth, approvalStore } = vi.hoisted(() => ({ @@ -64,6 +69,12 @@ vi.mock("@agent-native/core/event-bus", () => ({ registerEvent: vi.fn(), })); vi.mock("@agent-native/core/tracking", () => ({ track: vi.fn() })); +vi.mock("@agent-native/core/application-state", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@agent-native/core/application-state") + >()), + deleteAppStateByPrefix: mocks.deleteDrafts, +})); vi.mock("../onboarding.js", () => ({})); vi.mock("../../.generated/actions-registry.js", () => ({ default: {} })); vi.mock("./google-calendar.js", () => ({ @@ -81,12 +92,14 @@ vi.mock("./event-video-conferencing.js", () => ({ import createEvent from "../../actions/create-event.js"; import deleteEvent from "../../actions/delete-event.js"; import listEvents from "../../actions/list-events.js"; +import manageEventDraft from "../../actions/manage-event-draft.js"; import respondToEvent from "../../actions/respond-to-event.js"; import rsvpEvent from "../../actions/rsvp-event.js"; import updateEvent from "../../actions/update-event.js"; import "../plugins/agent-chat.js"; const actions = { + "manage-event-draft": manageEventDraft, "list-events": listEvents, "create-event": createEvent, "update-event": updateEvent, @@ -122,6 +135,7 @@ type ListedTool = { async function clientFor( userEmail = ownerEmail, oauthScopes = ["mcp:read", "mcp:write"], + supportsApproval = true, ) { const server = await createMCPServerForRequest( { @@ -147,8 +161,10 @@ async function clientFor( { name: "calendar-write-test", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } }, ); - client.registerCapabilities({ elicitation: { form: {} } }); - client.setRequestHandler("elicitation/create", mocks.approval); + if (supportsApproval) { + client.registerCapabilities({ elicitation: { form: {} } }); + client.setRequestHandler("elicitation/create", mocks.approval); + } closeClients.push(async () => { await client.close(); await server.close(); @@ -178,6 +194,7 @@ describe("Calendar direct MCP writes", () => { mocks.createEvent.mockResolvedValue({ id: "event-1" }); mocks.updateEvent.mockResolvedValue({}); mocks.rsvpEvent.mockResolvedValue(undefined); + mocks.deleteDrafts.mockResolvedValue(3); mocks.approval.mockResolvedValue({ action: "accept", content: { decision: "deny" }, @@ -197,6 +214,7 @@ describe("Calendar direct MCP writes", () => { "create_embed_session", "list-events", "list_apps", + "manage-event-draft", "open_app", "respond-to-event", "update-event", @@ -246,6 +264,61 @@ describe("Calendar direct MCP writes", () => { expect(askAgent).not.toHaveBeenCalled(); }); + it("refuses bulk draft deletion when the MCP host cannot request approval", async () => { + const client = await clientFor(ownerEmail, undefined, false); + const result = await client.callTool({ + name: "manage-event-draft", + arguments: { action: "delete-all" }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("actionApproval"); + expect(JSON.stringify(result.content)).toContain( + "did not declare the required capability", + ); + expect(mocks.deleteDrafts).not.toHaveBeenCalled(); + }); + + it("limits draft approval to outside bulk deletion", async () => { + for (const action of ["create", "update", "delete", "delete-all"]) { + const call = await actionCallIntent(manageEventDraft, { + action, + id: "draft-test", + }); + if (!call.valid) throw call.error; + expect( + await preparedCallNeedsApproval(manageEventDraft, call, { + caller: "tool", + }), + ).toBe(false); + expect( + await preparedCallNeedsApproval(manageEventDraft, call, { + caller: "mcp", + }), + ).toBe(action === "delete-all"); + } + }); + + it("deletes all drafts only after the MCP client approves the call", async () => { + mocks.approval.mockResolvedValue({ + action: "accept", + content: { decision: "approve" }, + }); + const client = await clientFor(); + const result = await client.callTool({ + name: "manage-event-draft", + arguments: { action: "delete-all" }, + }); + expect(result.isError).not.toBe(true); + expect(mocks.approval).toHaveBeenCalledOnce(); + expect(mocks.deleteDrafts).toHaveBeenCalledWith("calendar-draft-"); + expect(result.content).toEqual([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Deleted 3 calendar event draft(s)"), + }), + ]); + }); + it.each([undefined, "single", "all"] as const)( "RSVPs with scope %s without delegating", async (scope) => { diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index add5e40c900..3a178536fef 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -34,6 +34,7 @@ Every action has a schema; use `tool-search` for comments, sharing, Collections, | `view-screen` / `navigate` / `refresh-list` | Read context, move UI, refresh after out-of-band writes | | `list-documents` / `search-documents` / `get-document` / `pull-document` | Browse metadata, search, read, or flush live state before reading | | `get-blocks-field-word-count` | Count one exact collection field; omit `propertyId` for primary body | +| `share-local-file-document` | Create/refresh a shareable copy, possibly already shared; outside-agent calls need approval | | `create-document` / `edit-document` / `update-document` / `delete-document` | Create, revision-edit, update, or move a page and children to Trash | | `import-content` / `undo-content-import` | Import Markdown as pages; undo trashes unedited imports | | `list-content-trash` / `get-trashed-document` | Find/read authorized Trash without restoring | diff --git a/templates/content/actions/mcp-action-contract.spec.ts b/templates/content/actions/mcp-action-contract.spec.ts index d73af54427f..8cdb2c09643 100644 --- a/templates/content/actions/mcp-action-contract.spec.ts +++ b/templates/content/actions/mcp-action-contract.spec.ts @@ -1,3 +1,7 @@ +import { + actionCallIntent, + preparedCallNeedsApproval, +} from "@agent-native/core/action"; import { describe, expect, it } from "vitest"; import { mcpToolInputSchema } from "../../../packages/core/src/mcp/tool-input-schema.js"; @@ -44,6 +48,7 @@ import restoreContentDatabase from "./restore-content-database.js"; import restoreDocument from "./restore-document.js"; import searchDocuments from "./search-documents.js"; import setDocumentProperty from "./set-document-property.js"; +import shareLocalFileDocument from "./share-local-file-document.js"; import syncGitHubFolder from "./sync-github-folder.js"; import updateComment from "./update-comment.js"; import updateContentDatabaseView from "./update-content-database-view.js"; @@ -55,6 +60,7 @@ import viewScreen from "./view-screen.js"; describe("Content action-owned agent catalogs", () => { const directMcpActions = { + "share-local-file-document": shareLocalFileDocument, "create-content-database": createContentDatabase, "configure-document-property": configureDocumentProperty, "update-content-database-view": updateContentDatabaseView, @@ -100,6 +106,44 @@ describe("Content action-owned agent catalogs", () => { "manage-content-database-migration": manageContentDatabaseMigration, }; + it("requires approval for outside calls while preserving ordinary in-app tools", async () => { + const call = await actionCallIntent(shareLocalFileDocument, { + id: "local-file-test", + }); + if (!call.valid) throw call.error; + for (const [ctx, expected] of [ + [{ caller: "frontend" }, false], + [{ caller: "tool" }, false], + [{ caller: "http" }, true], + [{ caller: "cli" }, true], + [{ caller: "mcp-widget" }, true], + [{ caller: "mcp-widget-write" }, true], + [{ caller: "automation" }, true], + [{ caller: "mcp" }, true], + [{ caller: "webmcp" }, true], + [{ caller: "a2a" }, true], + [{ caller: "tool", networkProtocol: "mcp" }, true], + [{ caller: "tool", networkProtocol: "a2a" }, true], + [{ caller: "tool", networkProtocol: "provider-api" }, true], + [ + { + caller: "tool", + automation: { + triggerId: "trigger-test", + triggerName: "Test automation", + policyId: "policy-test", + }, + }, + true, + ], + ] as const) { + expect( + await preparedCallNeedsApproval(shareLocalFileDocument, call, ctx), + JSON.stringify(ctx), + ).toBe(expected); + } + }); + it("owns compact MCP membership beside each directly callable action", () => { for (const action of Object.values(directMcpActions)) { expect(action.mcpTool).toBe(true); @@ -107,6 +151,14 @@ describe("Content action-owned agent catalogs", () => { } }); + it("classifies shareable local-file copies as disclosure-capable writes", () => { + expect(shareLocalFileDocument.mcpAnnotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + }); + it("returns migration receipts as structured MCP content", () => { expect(migrateContentDatabaseRows.mcpApp?.structuredContent).toBe(true); }); diff --git a/templates/content/actions/share-local-file-document.ts b/templates/content/actions/share-local-file-document.ts index e88db32aba2..7b79263f567 100644 --- a/templates/content/actions/share-local-file-document.ts +++ b/templates/content/actions/share-local-file-document.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { parseIconValue, serializeIconValue } from "@agent-native/core/icons"; import { buildDeepLink } from "@agent-native/core/server"; @@ -75,6 +75,14 @@ function serializeDocument(row: typeof schema.documents.$inferSelect) { } export default defineAction({ + mcpTool: true, + needsApproval: (_args, ctx) => isOutsideAgentCall(ctx), + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, description: "Create or refresh a collection-backed shareable copy of a local-file document. Use this before sharing a local file with other users.", schema: z.object({ diff --git a/templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md b/templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md new file mode 100644 index 00000000000..e069456ce85 --- /dev/null +++ b/templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can open shareable local-file copies in Content, with approval before creating or refreshing the copy. diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 36e7cf08ed0..df5b0a26b3b 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -1,7 +1,6 @@ # Design — Agent Guide -Design agents build prototypes, systems, variants, and handoffs through -actions against shared SQL state. +Build prototypes, systems, variants, and handoffs with actions and shared SQL state. ## Skills @@ -17,18 +16,17 @@ Read `.agents/skills//SKILL.md` before deeper work: - `native-effects` — WebGPU fills/effects, source edits, and PNG. - `journey-storyboards` — onboarding-journey storyboards. -Also read `.agents/skills//SKILL.md` for shared guides: `actions`, +Also read `.agents/skills//SKILL.md`: `actions`, `adding-a-feature`, `storing-data`, `security`, `secrets`, `sharing`, `frontend-design`, `shadcn-ui`, `real-time-sync`, `context-awareness`, `delegate-to-agent`, `agent-native-docs`, `agent-native-toolkit`, `customizing-agent-native`, `client-side-routing`, `reliable-mutations`, `performance`, `external-agents`, `portability`, `self-modifying-code`, -`turn-into-skill`, and `workspace-conventions`. Each guide is at -`.agents/skills//SKILL.md`. +`turn-into-skill`, and `workspace-conventions`. ## Framework Docs -Search local docs with `pnpm action docs-search --query ""`; read by slug with `pnpm action docs-search --slug ""`. +Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs-search --slug ""`. ## Actions @@ -46,7 +44,7 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `add-session-replay-screenshots-to-board` | Add private Analytics replay screenshots to a Design board | | `stage-journey-canvas-frames` | Stage native PNGs in Design-owned private blob storage in batches | | `discard-journey-canvas-frame-import` | Discard staged import; queue its private blobs for cleanup | -| `create-journey-canvas` | Create provenance-backed storyboard with stubs, same-recording links, and explicit recording-gap references; no cohort metrics | +| `create-journey-canvas` | Create provenance-backed storyboards with stubs, same-recording links, and recording-gap references; no cohort metrics | | `add-breakpoint` / `remove-breakpoint` | Manage responsive frames | | `edit-design` | Adapt a design/screen | | `apply-visual-edit` | Apply deterministic layer edits | @@ -54,13 +52,16 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `create-design` / `generate-design` | Start empty design / generate a fresh screen | | `present-design-variants` | Generate 2–5 variants | | `view-screen` / `navigate` | Read current screen / move UI | -| `get-view-settings` / `update-view-settings` | Read/update view toggles (grid, snap, rulers, cursors, hidden comments) | +| `get-view-settings` / `update-view-settings` | Read/update view toggles | | `get-shader` / `edit-native-shader` | Inspect curated native effects; CAS-edit one inline scene | | `get-native-shader-approvals` | Read viewer-scoped exact effect approvals for accessible Design previews | | `prepare-native-scene-export` | Pin scene for PNG | -| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG and inspect attachment | -| `export-png` | Export PNG | -| `export-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export finished work | +| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG; inspect attachment | +| `generate-screens` | Plan screens | +| `export-png` / `export-coding-handoff` | PNG/code; outside-agent handoff calls need approval (7-day bearer URLs) | +| `export-html` | Export standalone HTML | +| `export-zip` | Export ZIP | +| `export-design-as-figma-svg` | Export Figma SVG | ## Core Rules diff --git a/templates/design/actions/export-coding-handoff.spec.ts b/templates/design/actions/export-coding-handoff.spec.ts index 78d84a3c6ce..941ad7da424 100644 --- a/templates/design/actions/export-coding-handoff.spec.ts +++ b/templates/design/actions/export-coding-handoff.spec.ts @@ -1,3 +1,7 @@ +import { + actionCallIntent, + preparedCallNeedsApproval, +} from "@agent-native/core/action"; import { describe, expect, it, vi } from "vitest"; vi.mock("@agent-native/core/server", () => ({ @@ -37,6 +41,42 @@ vi.mock("../server/lib/design-snapshot.js", () => ({ import action from "./export-coding-handoff.js"; describe("export-coding-handoff", () => { + it("requires approval for outside calls while preserving ordinary in-app tools", async () => { + const call = await actionCallIntent(action, { id: "design-test" }); + if (!call.valid) throw call.error; + for (const [ctx, expected] of [ + [{ caller: "frontend" }, false], + [{ caller: "tool" }, false], + [{ caller: "http" }, true], + [{ caller: "cli" }, true], + [{ caller: "mcp-widget" }, true], + [{ caller: "mcp-widget-write" }, true], + [{ caller: "automation" }, true], + [{ caller: "mcp" }, true], + [{ caller: "webmcp" }, true], + [{ caller: "a2a" }, true], + [{ caller: "tool", networkProtocol: "mcp" }, true], + [{ caller: "tool", networkProtocol: "a2a" }, true], + [{ caller: "tool", networkProtocol: "provider-api" }, true], + [ + { + caller: "tool", + automation: { + triggerId: "trigger-test", + triggerName: "Test automation", + policyId: "policy-test", + }, + }, + true, + ], + ] as const) { + expect( + await preparedCallNeedsApproval(action, call, ctx), + JSON.stringify(ctx), + ).toBe(expected); + } + }); + it("is available in the compact MCP Apps catalog", () => { expect(action.mcpApp?.compactCatalog).toBe(true); expect(action.mcpApp?.resource.title).toBe("Design agent handoff"); diff --git a/templates/design/actions/export-coding-handoff.ts b/templates/design/actions/export-coding-handoff.ts index 867c3c1f1b2..8fb7fb670b4 100644 --- a/templates/design/actions/export-coding-handoff.ts +++ b/templates/design/actions/export-coding-handoff.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { signShortLivedToken, buildDeepLink, @@ -33,6 +33,13 @@ function designDeepLink(designId: string): string { } export default defineAction({ + needsApproval: (_args, ctx) => isOutsideAgentCall(ctx), + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, description: "Turn a design into code: create an agent handoff for a design project. " + "Returns tokenized raw and ZIP URLs external agents can fetch, plus a " + diff --git a/templates/design/actions/generate-screens.ts b/templates/design/actions/generate-screens.ts index ceb2d991669..1d68d7c3abf 100644 --- a/templates/design/actions/generate-screens.ts +++ b/templates/design/actions/generate-screens.ts @@ -158,6 +158,12 @@ const requestedScreenSchema = z }); export default defineAction({ + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, description: "Start a multi-screen generation session on the Design canvas for " + "genuinely DISTINCT screens — different pages or states such as Home, " + diff --git a/templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md b/templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md new file mode 100644 index 00000000000..da8cac8b1ae --- /dev/null +++ b/templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can generate screens and open coding handoffs in Design, with approval before creating shareable code links. diff --git a/templates/design/server/plugins/agent-chat.ts b/templates/design/server/plugins/agent-chat.ts index 2aba7e73f88..1a6ad2e4bc4 100644 --- a/templates/design/server/plugins/agent-chat.ts +++ b/templates/design/server/plugins/agent-chat.ts @@ -15,6 +15,8 @@ const DESIGN_BACKGROUND_RUN_SOFT_TIMEOUT_MS = 13 * 60_000; const DESIGN_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS = 12 * 60_000; const EXTERNAL_CONNECTOR_TOOL_NAMES = [ + "generate-screens", + "export-coding-handoff", "open-visual-edit", "get-visual-edit-pending", "acknowledge-visual-edit-pending", @@ -302,7 +304,7 @@ export default createAgentChatPlugin({ connectorCatalog: EXTERNAL_CONNECTOR_TOOL_NAMES, keyToolNames: ["get-visual-edit-pending"], instructions: - "Resolve a named template or prior design first with list-design-templates / list-designs; copy with create-design-from-template, then adapt with edit-design — never regenerate a copied screen with generate-design. For new-design exploration use create-design then present-design-variants (2-5 variants) and surface the returned open link; do not navigate. Hand-off goes through export-png for one screen, or export-html / export-zip / export-coding-handoff / export-design-as-figma-svg for other formats. Persist early: create or update the design and its files as soon as a coherent candidate exists. " + + "Resolve a named template or prior design first with list-design-templates / list-designs; copy with create-design-from-template, then adapt with edit-design — never regenerate a copied screen with generate-design. For new-design exploration use create-design then present-design-variants (2-5 variants) and surface the returned open link; do not navigate. Hand-off goes through export-png for a screen image or export-coding-handoff for code. The coding handoff requires approval because its raw and ZIP URLs grant bearer access for seven days. Persist early: create or update the design and its files as soon as a coherent candidate exists. " + 'Design system: get-design, get-design-snapshot, and view-screen return `designSystem` (a bounded summary with scope "summary" and a `next` line); call get-design-system { id } once before the first screen you author for the full context (create-design returns it in full), then reuse it. Apply designSystem.agentContext, plus index-design-tokens for an existing design, before authoring or restyling; never invent a generic palette. For a new design, pass the exact title as `designSystem` or a designSystemId; omit both to link the caller\'s default. Preserve existing screen composition as well as linked system tokens, fonts, assets, and custom instructions. Read back the saved file after every visual mutation. For a running localhost app, use open-visual-edit and keep each route/state/viewport as its own URL-backed screen. FULL APP DESIGNS: when the open design has a fusionApp (its screens are a live Builder branch container), the app code lives in that branch. Make every app code change (styles, components, copy in source, data, routes) by calling send-builder-message with the designId and a precise prompt; it delegates to Builder on that branch, streams its work into this chat, and returns when the change is done and the screens reload. Use apply-builder-edits only for edits already queued from the canvas (queue-builder-edit); one delegation per request, then report what changed. Never answer an app code change on a Builder app design by asking the user to connect Builder or to copy a prompt. On a Builder app design, pending live visual edits (text, styles, hidden or layer state) are app code changes too: call get-visual-edit-pending, then pass its prompt to send-builder-message with visualEditRevision set to that revision; send-builder-message clears the handoff when Builder finishes. Never route them through the localhost source tools or write consent, which only exist for connected localhost apps. Canvas annotations (strokes and notes from Annotate) on a Builder app design are the user describing changes to the app: work out what each mark points at and asks for, and delegate that change with send-builder-message. The annotation strokes are not part of the app or the design, so never try to edit or delete them. Update a selected screen with update-screen-source, and use add-localhost-screens or add-breakpoint for additional canvas frames. KEY VISUAL HANDOFF: after the user edits a live screen, call get-visual-edit-pending with the visual-edit designId before asking for copy/paste. It returns the latest source prompt and revision even when the Design tab is closed; apply that prompt to the connected app, then call acknowledge-visual-edit-pending with the same designId and revision only after the source change is verified, and call get-visual-edit-pending again to confirm it cleared. Never acknowledge a handoff you did not apply. The page-local get-visual-edit-prompt tool is an equivalent fallback only for browser-capable hosts.', directoryProfile: { ...CHATGPT_DIRECTORY_PROFILE, diff --git a/templates/slides-2/AGENTS.md b/templates/slides-2/AGENTS.md index db525b86b39..f85b9c91754 100644 --- a/templates/slides-2/AGENTS.md +++ b/templates/slides-2/AGENTS.md @@ -54,6 +54,12 @@ Create and edit slide decks through Agent-Native chat and the visual editor, bac | `search-svgl-logos`, `import-svgl-logo` | Find a logo and import it as a slide asset. | | `export-pptx` | Export a deck as PowerPoint. | +## External inline actions + +| Action | Purpose | +| --- | --- | +| `clone-context-slide` | Copy a pinned native library slide into an editable deck, preserving provenance | + ## Application State - `navigation` identifies the current view, deck, and slide. Home also records `homeTab` (`recent` or `templates`), `deckSearch`, and `deckFilter` (`all`, `created-by-me`, or `shared`); the templates route uses `search`; the themes route can include `themeId`. diff --git a/templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md b/templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md new file mode 100644 index 00000000000..60e2559f4d7 --- /dev/null +++ b/templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can clone a slide from existing context and open the copy in the Slides editor. diff --git a/templates/slides-2/server/plugins/agent-chat.ts b/templates/slides-2/server/plugins/agent-chat.ts index 70c4a9e1f82..72018730f93 100644 --- a/templates/slides-2/server/plugins/agent-chat.ts +++ b/templates/slides-2/server/plugins/agent-chat.ts @@ -52,6 +52,7 @@ const INITIAL_TOOL_NAMES = [ ]; const EXTERNAL_CONNECTOR_TOOL_NAMES = [ + "clone-context-slide", "view-screen", "control-presenter", "navigate", diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index 149736b8d8d..53a337192a0 100644 --- a/templates/slides/AGENTS.md +++ b/templates/slides/AGENTS.md @@ -70,3 +70,9 @@ Google Slides export produces a PPTX for the user to import; a native Slides fil Before building common workspace or agent UI, read `agent-native-toolkit`; read `customizing-agent-native` before adapting shared UI. Search with `rg --hidden --follow`; read the exact linked guide before deeper work. + +## External inline actions + +| Action | Purpose | +| --- | --- | +| `clone-context-slide` | Copy a pinned native library slide into an editable deck, preserving provenance | diff --git a/templates/slides/actions/clone-context-slide.ts b/templates/slides/actions/clone-context-slide.ts index 57356a0a418..5d5780439ab 100644 --- a/templates/slides/actions/clone-context-slide.ts +++ b/templates/slides/actions/clone-context-slide.ts @@ -81,6 +81,12 @@ export function cloneableNativeSlide(context: ContextDetail): { } export default defineAction({ + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, description: "Clone one pinned Google Slides-native Creative Context item into an existing deck without regenerating or rewriting its editable HTML/CSS. Returns a link that opens the cloned slide in Slides.", schema: z.object({ diff --git a/templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md b/templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md new file mode 100644 index 00000000000..60e2559f4d7 --- /dev/null +++ b/templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can clone a slide from existing context and open the copy in the Slides editor. diff --git a/templates/slides/server/plugins/agent-chat.ts b/templates/slides/server/plugins/agent-chat.ts index bf09159162a..30fe0566a85 100644 --- a/templates/slides/server/plugins/agent-chat.ts +++ b/templates/slides/server/plugins/agent-chat.ts @@ -55,6 +55,7 @@ const INITIAL_TOOL_NAMES = [ ]; const EXTERNAL_CONNECTOR_TOOL_NAMES = [ + "clone-context-slide", "view-screen", "navigate", "list-decks",