@agent-native/core 0.70.0 → 0.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +15 -15
  2. package/corpus/README.md +2 -2
  3. package/corpus/core/CHANGELOG.md +67 -0
  4. package/corpus/core/docs/design/durable-agent-runs.md +217 -0
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/action.ts +89 -0
  7. package/corpus/core/src/agent/model-config.ts +0 -2
  8. package/corpus/core/src/agent/run-loop-with-resume.ts +48 -0
  9. package/corpus/core/src/cli/skills.ts +2 -2
  10. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +275 -0
  11. package/corpus/core/src/client/AgentPanel.tsx +5 -3
  12. package/corpus/core/src/client/AssistantChat.tsx +8 -0
  13. package/corpus/core/src/client/client-surface.ts +41 -0
  14. package/corpus/core/src/client/composer/TiptapComposer.tsx +123 -12
  15. package/corpus/core/src/client/feedback-context.ts +4 -0
  16. package/corpus/core/src/client/index.ts +1 -0
  17. package/corpus/core/src/client/sse-event-processor.ts +7 -1
  18. package/corpus/core/src/mcp/build-server.ts +57 -1
  19. package/corpus/core/src/mcp/embed-app.ts +86 -2
  20. package/corpus/core/src/mcp/oauth-route.ts +109 -13
  21. package/corpus/core/src/server/core-routes-plugin.ts +78 -0
  22. package/corpus/core/src/server/onboarding-html.ts +35 -0
  23. package/corpus/core/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  24. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +22 -0
  25. package/corpus/templates/analytics/AGENTS.md +8 -1
  26. package/corpus/templates/analytics/actions/install-dashboard-template.ts +97 -2
  27. package/corpus/templates/analytics/actions/update-dashboard.ts +36 -7
  28. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +30 -3
  29. package/corpus/templates/analytics/app/components/layout/Header.tsx +0 -1
  30. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +2 -4
  31. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +0 -3
  32. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -2
  33. package/corpus/templates/analytics/changelog/2026-06-23-long-dashboard-and-analysis-names-in-the-sidebar-now-truncat.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-06-23-open-any-dashboard-chart-full-screen-in-a-modal-from-the-pan.md +6 -0
  35. package/corpus/templates/assets/actions/generate-image.ts +24 -0
  36. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +2 -2
  37. package/corpus/templates/assets/app/global.css +17 -1
  38. package/corpus/templates/assets/app/routes/_index.tsx +94 -14
  39. package/corpus/templates/assets/changelog/2026-06-23-chat-dates-in-the-sidebar-no-longer-wrap-onto-two-lines.md +6 -0
  40. package/corpus/templates/assets/changelog/2026-06-23-clicking-image-video-or-refine-on-the-start-screen-now-drops.md +6 -0
  41. package/corpus/templates/assets/changelog/2026-06-23-pick-your-image-generation-model-right-from-the-chat-model-m.md +6 -0
  42. package/corpus/templates/clips/app/components/library/library-grid.tsx +1 -20
  43. package/corpus/templates/clips/app/components/recorder/countdown-overlay.tsx +72 -48
  44. package/corpus/templates/clips/app/lib/countdown-audio-cue.ts +35 -17
  45. package/corpus/templates/clips/changelog/2026-06-23-removed-select-button-from-library-hover-a-clip-to-select-it.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-06-23-shared-video-links-now-play-for-anyone-without-signing-in.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-06-23-skip-or-cancel-the-recording-countdown-with-buttons-beside-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-camera-bubble-now-looks-sharp-the-instant-it-ope.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-06-23-the-desktop-recording-widget-now-grows-downward-only-when-yo.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-06-23-the-recording-start-sound-is-now-a-softer-more-modern-chime.md +6 -0
  51. package/corpus/templates/clips/chrome-extension/public/manifest.json +17 -8
  52. package/corpus/templates/clips/chrome-extension/src/background.ts +769 -180
  53. package/corpus/templates/clips/chrome-extension/src/content-script.ts +177 -0
  54. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +466 -329
  55. package/corpus/templates/clips/chrome-extension/src/overlay.css +377 -0
  56. package/corpus/templates/clips/chrome-extension/src/overlay.html +12 -0
  57. package/corpus/templates/clips/chrome-extension/src/overlay.ts +330 -0
  58. package/corpus/templates/clips/chrome-extension/src/permission.html +227 -0
  59. package/corpus/templates/clips/chrome-extension/src/permission.ts +105 -0
  60. package/corpus/templates/clips/chrome-extension/src/popup.html +3 -0
  61. package/corpus/templates/clips/chrome-extension/src/popup.ts +50 -2
  62. package/corpus/templates/clips/chrome-extension/src/styles.css +50 -0
  63. package/corpus/templates/clips/chrome-extension/vite.config.ts +3 -0
  64. package/corpus/templates/clips/desktop/src/lib/audio-cue.ts +41 -24
  65. package/corpus/templates/clips/desktop/src/lib/bubble-webrtc.ts +119 -2
  66. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +21 -2
  67. package/corpus/templates/clips/desktop/src/styles.css +70 -2
  68. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +73 -0
  69. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +34 -5
  70. package/corpus/templates/content/AGENTS.md +20 -3
  71. package/corpus/templates/content/actions/_database-utils.ts +15 -0
  72. package/corpus/templates/content/actions/_property-utils.ts +371 -4
  73. package/corpus/templates/content/actions/configure-document-property.ts +35 -1
  74. package/corpus/templates/content/actions/create-content-database.ts +5 -1
  75. package/corpus/templates/content/actions/delete-document-property.ts +52 -2
  76. package/corpus/templates/content/actions/duplicate-document-property.ts +34 -17
  77. package/corpus/templates/content/actions/reorder-document-property.ts +79 -0
  78. package/corpus/templates/content/actions/set-document-property.ts +39 -0
  79. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +806 -0
  80. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +226 -68
  81. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +59 -30
  82. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +25 -1
  83. package/corpus/templates/content/app/components/editor/blockFieldSaveController.ts +180 -0
  84. package/corpus/templates/content/app/components/editor/blockFieldSaveRegistry.ts +179 -0
  85. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +244 -0
  86. package/corpus/templates/content/app/components/editor/previewDocumentSaveRegistry.ts +132 -0
  87. package/corpus/templates/content/app/global.css +9 -0
  88. package/corpus/templates/content/app/hooks/use-document-properties.ts +21 -0
  89. package/corpus/templates/content/server/db/schema.ts +30 -0
  90. package/corpus/templates/content/server/plugins/db.ts +87 -0
  91. package/corpus/templates/content/shared/api.ts +7 -0
  92. package/corpus/templates/content/shared/properties.ts +109 -0
  93. package/corpus/templates/forms/.agents/skills/form-responses/SKILL.md +10 -0
  94. package/corpus/templates/forms/actions/export-responses.ts +6 -0
  95. package/corpus/templates/forms/actions/list-responses.ts +2 -0
  96. package/corpus/templates/forms/actions/response-insights.ts +2 -0
  97. package/corpus/templates/forms/app/pages/ResponsesPage.tsx +138 -1
  98. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-the-page-each-submission-came-from-.md +6 -0
  99. package/corpus/templates/forms/changelog/2026-06-23-response-tables-now-show-whether-feedback-came-from-the-web-.md +6 -0
  100. package/corpus/templates/forms/server/db/schema.ts +7 -0
  101. package/corpus/templates/forms/server/handlers/submissions.ts +19 -2
  102. package/corpus/templates/forms/server/lib/integrations.ts +76 -2
  103. package/corpus/templates/forms/server/plugins/db.ts +20 -0
  104. package/corpus/templates/forms/shared/types.ts +12 -0
  105. package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +3 -1
  106. package/corpus/templates/plan/changelog/2026-06-23-large-diagrams-in-a-plan-now-scroll-within-their-block-inste.md +6 -0
  107. package/dist/action.js +87 -0
  108. package/dist/action.js.map +1 -1
  109. package/dist/agent/engine/builder-engine.d.ts +1 -1
  110. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  111. package/dist/agent/model-config.d.ts +2 -2
  112. package/dist/agent/model-config.d.ts.map +1 -1
  113. package/dist/agent/model-config.js +0 -2
  114. package/dist/agent/model-config.js.map +1 -1
  115. package/dist/agent/run-loop-with-resume.d.ts +13 -0
  116. package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
  117. package/dist/agent/run-loop-with-resume.js +44 -0
  118. package/dist/agent/run-loop-with-resume.js.map +1 -1
  119. package/dist/cli/skills.js +2 -2
  120. package/dist/cli/skills.js.map +1 -1
  121. package/dist/cli/sync-builder-starter-manifest.d.ts +32 -0
  122. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -0
  123. package/dist/cli/sync-builder-starter-manifest.js +179 -0
  124. package/dist/cli/sync-builder-starter-manifest.js.map +1 -0
  125. package/dist/client/AgentPanel.d.ts.map +1 -1
  126. package/dist/client/AgentPanel.js +5 -3
  127. package/dist/client/AgentPanel.js.map +1 -1
  128. package/dist/client/AssistantChat.d.ts +6 -1
  129. package/dist/client/AssistantChat.d.ts.map +1 -1
  130. package/dist/client/AssistantChat.js +2 -2
  131. package/dist/client/AssistantChat.js.map +1 -1
  132. package/dist/client/client-surface.d.ts +17 -0
  133. package/dist/client/client-surface.d.ts.map +1 -0
  134. package/dist/client/client-surface.js +24 -0
  135. package/dist/client/client-surface.js.map +1 -0
  136. package/dist/client/composer/TiptapComposer.d.ts +26 -1
  137. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  138. package/dist/client/composer/TiptapComposer.js +27 -5
  139. package/dist/client/composer/TiptapComposer.js.map +1 -1
  140. package/dist/client/feedback-context.d.ts +3 -0
  141. package/dist/client/feedback-context.d.ts.map +1 -1
  142. package/dist/client/feedback-context.js +2 -0
  143. package/dist/client/feedback-context.js.map +1 -1
  144. package/dist/client/index.d.ts +1 -0
  145. package/dist/client/index.d.ts.map +1 -1
  146. package/dist/client/index.js +1 -0
  147. package/dist/client/index.js.map +1 -1
  148. package/dist/client/sse-event-processor.d.ts.map +1 -1
  149. package/dist/client/sse-event-processor.js +7 -1
  150. package/dist/client/sse-event-processor.js.map +1 -1
  151. package/dist/mcp/build-server.d.ts +21 -0
  152. package/dist/mcp/build-server.d.ts.map +1 -1
  153. package/dist/mcp/build-server.js +43 -1
  154. package/dist/mcp/build-server.js.map +1 -1
  155. package/dist/mcp/embed-app.d.ts.map +1 -1
  156. package/dist/mcp/embed-app.js +86 -2
  157. package/dist/mcp/embed-app.js.map +1 -1
  158. package/dist/mcp/oauth-route.d.ts.map +1 -1
  159. package/dist/mcp/oauth-route.js +95 -12
  160. package/dist/mcp/oauth-route.js.map +1 -1
  161. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  162. package/dist/server/core-routes-plugin.js +63 -0
  163. package/dist/server/core-routes-plugin.js.map +1 -1
  164. package/dist/server/onboarding-html.d.ts.map +1 -1
  165. package/dist/server/onboarding-html.js +35 -0
  166. package/dist/server/onboarding-html.js.map +1 -1
  167. package/dist/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  168. package/docs/design/durable-agent-runs.md +217 -0
  169. package/package.json +1 -1
  170. package/src/templates/workspace-core/.agents/skills/reliable-mutations/SKILL.md +72 -0
  171. package/corpus/templates/analytics/app/pages/About.tsx +0 -108
  172. package/corpus/templates/analytics/app/routes/about.tsx +0 -9
@@ -184,6 +184,46 @@ export interface MCPRequestMeta {
184
184
  * `config.actions`. Set by `mountMCP` from `verifyAuth`.
185
185
  */
186
186
  fullSurface?: boolean;
187
+ /**
188
+ * Whether this request may receive inline MCP App embeds (the `ui://`
189
+ * resource reference hosts render in an iframe). Resolved once per request by
190
+ * `createMCPServerForRequest` from `isMcpAppsInlineEnabled(identity)` — the
191
+ * deploy-toggleable kill switch. When `false`, no MCP App resource is
192
+ * advertised or referenced and tool results fall back to their deep-link
193
+ * text. Defaults to disabled when unset.
194
+ */
195
+ inlineMcpApps?: boolean;
196
+ }
197
+
198
+ /**
199
+ * Deploy-toggleable kill switch for inline MCP App embeds — the `ui://`
200
+ * resource reference hosts like Codex / Cursor / ChatGPT render in a sandboxed
201
+ * iframe. **Off by default**, so a not-yet-verified inline embed never reaches
202
+ * normal users; flip it on per environment with `AGENT_NATIVE_MCP_APPS_INLINE=1`
203
+ * and a redeploy. While the global switch is off, accounts listed in
204
+ * `AGENT_NATIVE_MCP_APPS_INLINE_ALLOW_EMAILS` (comma/space separated) still get
205
+ * inline embeds, so you can keep verifying a fix in production before enabling
206
+ * it for everyone. Requires no skills/instructions change — when disabled, tool
207
+ * results simply fall back to their deep-link text.
208
+ */
209
+ export function isMcpAppsInlineEnabled(
210
+ identity: MCPCallerIdentity | undefined,
211
+ ): boolean {
212
+ const flag = process.env.AGENT_NATIVE_MCP_APPS_INLINE?.trim().toLowerCase();
213
+ if (flag === "1" || flag === "true" || flag === "yes" || flag === "on") {
214
+ return true;
215
+ }
216
+ const email = identity?.userEmail?.trim().toLowerCase();
217
+ if (email) {
218
+ const allowed = (
219
+ process.env.AGENT_NATIVE_MCP_APPS_INLINE_ALLOW_EMAILS ?? ""
220
+ )
221
+ .split(/[\s,]+/)
222
+ .map((value) => value.trim().toLowerCase())
223
+ .filter(Boolean);
224
+ if (allowed.includes(email)) return true;
225
+ }
226
+ return false;
187
227
  }
188
228
 
189
229
  type McpOAuthScope = (typeof MCP_OAUTH_SCOPES)[number];
@@ -708,7 +748,7 @@ function safeUiSegment(value: string | undefined, fallback: string): string {
708
748
 
709
749
  // ChatGPT and Claude cache MCP App resource HTML by `ui://` URI. Bump this
710
750
  // when the shared shell changes in a way that must invalidate host caches.
711
- const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v45";
751
+ const MCP_APP_RESOURCE_SHELL_VERSION = "shell-v46";
712
752
 
713
753
  function legacyDefaultMcpAppUri(config: MCPConfig, actionName: string): string {
714
754
  const app = safeUiSegment(config.appId ?? config.name, "agent-native");
@@ -912,6 +952,12 @@ async function resolveMcpAppResource(
912
952
  ): Promise<ResolvedMcpAppResource | null> {
913
953
  const resource = entry.mcpApp?.resource;
914
954
  if (!resource) return null;
955
+ // Inline MCP App embeds are gated behind the deploy-toggleable kill switch
956
+ // (default off). When disabled, advertise no resource and attach no embed
957
+ // reference so hosts fall back to the tool's deep-link text instead of an
958
+ // iframe. This is the single chokepoint for every embed surface: tools/list
959
+ // descriptor meta, tools/call result meta, resources/list, resources/read.
960
+ if (!requestMeta?.inlineMcpApps) return null;
915
961
  const resolvedUri = getMcpAppResourceUri(config, actionName, entry);
916
962
  if (!resolvedUri) return null;
917
963
  const description = resource.description ?? entry.tool.description;
@@ -1192,6 +1238,16 @@ export async function createMCPServerForRequest(
1192
1238
  ? { userEmail: ownerFromEnv, orgDomain: undefined }
1193
1239
  : undefined);
1194
1240
 
1241
+ // Resolve the inline-MCP-App kill switch once per request from the effective
1242
+ // identity + environment, then thread it through `requestMeta` so every
1243
+ // resource/tool handler below honors the same decision. An explicit value on
1244
+ // the incoming meta (tests / embedded callers) wins.
1245
+ requestMeta = {
1246
+ ...(requestMeta ?? {}),
1247
+ inlineMcpApps:
1248
+ requestMeta?.inlineMcpApps ?? isMcpAppsInlineEnabled(effectiveIdentity),
1249
+ };
1250
+
1195
1251
  // The action set the request handlers operate on = base actions + generic
1196
1252
  // cross-app builtins (template wins on name collision). An authenticated
1197
1253
  // real caller (connect-minted token / `mcp install` owner / production —
@@ -67,6 +67,8 @@ export function embedApp(
67
67
  .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 700; color: color-mix(in srgb, CanvasText 72%, Canvas); }
68
68
  .actions { display: flex; align-items: center; gap: 6px; }
69
69
  button { min-height: 28px; border: 1px solid color-mix(in srgb, CanvasText 14%, Canvas); border-radius: 7px; background: Canvas; color: CanvasText; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; padding: 0 9px; }
70
+ button.primary { min-height: 32px; padding: 0 14px; background: CanvasText; color: Canvas; border-color: CanvasText; }
71
+ button.primary:hover:not(:disabled) { background: color-mix(in srgb, CanvasText 86%, Canvas); }
70
72
  button:disabled { opacity: .55; cursor: default; }
71
73
  .stage { position: relative; min-height: var(--agent-native-viewport-height); }
72
74
  iframe { display: block; width: 100%; height: var(--agent-native-viewport-height); border: 0; background: Canvas; }
@@ -895,9 +897,21 @@ export function embedApp(
895
897
  });
896
898
  if (!response.ok) {
897
899
  if (response.status === 401 && isEmbedStartUrl(src)) {
900
+ reportEmbedError(
901
+ "transplant-auth",
902
+ "Embedded app session expired (HTTP 401).",
903
+ "Opaque-origin sandbox could not authenticate the embed.",
904
+ 401
905
+ );
898
906
  refreshExpiredEmbedSession();
899
907
  return;
900
908
  }
909
+ reportEmbedError(
910
+ "transplant-http",
911
+ "Embedded app returned HTTP " + response.status + ".",
912
+ undefined,
913
+ response.status
914
+ );
901
915
  throw new Error("Embedded app returned HTTP " + response.status + ".");
902
916
  }
903
917
  const html = await response.text();
@@ -998,6 +1012,52 @@ export function embedApp(
998
1012
  }, frameReadyTimeoutMs);
999
1013
  }
1000
1014
 
1015
+ // Best-effort telemetry sink so inline-embed failures are visible in
1016
+ // Sentry (handshake timeout, transplant fetch status/CORS, auth, CSP).
1017
+ // The shell is a sandboxed opaque-origin iframe, so it POSTs to the app
1018
+ // origin's CORS-open /_agent-native/mcp/embed-error route, derived from the
1019
+ // known app URLs. Deduped per stage+message so a retry loop can't spam.
1020
+ const reportedEmbedErrorKeys = new Set();
1021
+ let embedErrorReportCount = 0;
1022
+ function embedReportOrigin() {
1023
+ for (const value of [openStartUrl, openUrl, lastFrameSrc]) {
1024
+ if (typeof value === "string" && value) {
1025
+ try { return new URL(value, window.location.href).origin; } catch {}
1026
+ }
1027
+ }
1028
+ return "";
1029
+ }
1030
+ function reportEmbedError(stage, message, detail, status) {
1031
+ try {
1032
+ const key = String(stage) + "|" + String(message || "");
1033
+ if (reportedEmbedErrorKeys.has(key) || embedErrorReportCount >= 8) return;
1034
+ reportedEmbedErrorKeys.add(key);
1035
+ embedErrorReportCount += 1;
1036
+ const origin = embedReportOrigin();
1037
+ if (!origin) return;
1038
+ let renderMode = "";
1039
+ try { renderMode = renderModeSource().mode || ""; } catch {}
1040
+ const body = JSON.stringify({
1041
+ stage: String(stage || ""),
1042
+ message: String(message || "").slice(0, 500),
1043
+ detail: detail ? String(detail).slice(0, 1200) : undefined,
1044
+ url: openStartUrl || openUrl || lastFrameSrc || "",
1045
+ status: typeof status === "number" ? status : undefined,
1046
+ host: window.location.hostname || "",
1047
+ renderMode: renderMode,
1048
+ bridge: openAiBridge ? "openai" : app ? "native" : "none",
1049
+ userAgent: navigator.userAgent || ""
1050
+ });
1051
+ fetch(origin + "/_agent-native/mcp/embed-error", {
1052
+ method: "POST",
1053
+ credentials: "omit",
1054
+ keepalive: true,
1055
+ headers: { "Content-Type": "application/json" },
1056
+ body: body
1057
+ }).catch(() => {});
1058
+ } catch {}
1059
+ }
1060
+
1001
1061
  function renderFrameFallback() {
1002
1062
  clearFrameReadyTimer();
1003
1063
  clearFrameLoadTimer();
@@ -1005,12 +1065,13 @@ export function embedApp(
1005
1065
  const fallbackCopy = openUrl
1006
1066
  ? "This chat host did not allow the embedded app frame to load inline. You can still open the same app route through the host or use the URL below."
1007
1067
  : "This chat host did not allow the embedded app frame to load inline.";
1068
+ reportEmbedError("frame-fallback", fallbackCopy);
1008
1069
  stage.innerHTML =
1009
1070
  '<div class="fallback">' +
1010
1071
  '<div class="fallback-title">Open this app in its own tab</div>' +
1011
1072
  '<div class="fallback-copy">' + esc(fallbackCopy) + '</div>' +
1012
1073
  '<div class="fallback-actions">' +
1013
- '<button type="button" data-fallback-open>Open app</button>' +
1074
+ '<button type="button" class="primary" data-fallback-open>Open in new tab</button>' +
1014
1075
  '<button type="button" data-fallback-retry>Try inline again</button>' +
1015
1076
  '</div>' +
1016
1077
  (openUrl ? '<a class="fallback-url" href="' + esc(openUrl) + '" target="_blank" rel="noreferrer">' + esc(openUrl) + '</a>' : '') +
@@ -1038,6 +1099,7 @@ export function embedApp(
1038
1099
  const fallbackCopy = openUrl
1039
1100
  ? "The inline MCP app could not load in this chat host. You can open the same app route in a new tab or retry the inline load."
1040
1101
  : "The inline MCP app could not load in this chat host.";
1102
+ reportEmbedError("app-launch-error", message || fallbackCopy, message);
1041
1103
  const copyHtml = message
1042
1104
  ? '<div>' + esc(message) + '</div><div>' + esc(fallbackCopy) + '</div>'
1043
1105
  : esc(fallbackCopy);
@@ -1046,7 +1108,7 @@ export function embedApp(
1046
1108
  '<div class="fallback-title">App did not load</div>' +
1047
1109
  '<div class="fallback-copy">' + copyHtml + '</div>' +
1048
1110
  '<div class="fallback-actions">' +
1049
- '<button type="button" data-fallback-open>Open in new tab</button>' +
1111
+ '<button type="button" class="primary" data-fallback-open>Open in new tab</button>' +
1050
1112
  '<button type="button" data-fallback-retry>Retry</button>' +
1051
1113
  '</div>' +
1052
1114
  (openUrl ? '<a class="fallback-url" href="' + esc(openUrl) + '" target="_blank" rel="noreferrer">' + esc(openUrl) + '</a>' : '') +
@@ -1801,8 +1863,30 @@ export function embedApp(
1801
1863
  }
1802
1864
  } catch (err) {
1803
1865
  console.error("[agent-native] MCP app shell failed", err);
1866
+ reportEmbedError(
1867
+ "bridge-init",
1868
+ err && err.message ? err.message : "Could not initialize app.",
1869
+ err && err.stack ? String(err.stack) : undefined
1870
+ );
1804
1871
  setMessage(err && err.message ? err.message : "Could not initialize app.");
1805
1872
  }
1873
+
1874
+ window.addEventListener("error", (event) => {
1875
+ const err = event && event.error;
1876
+ reportEmbedError(
1877
+ "window-error",
1878
+ (err && err.message) || (event && event.message) || "Uncaught error in MCP app embed.",
1879
+ err && err.stack ? String(err.stack) : undefined
1880
+ );
1881
+ });
1882
+ window.addEventListener("unhandledrejection", (event) => {
1883
+ const reason = event && event.reason;
1884
+ reportEmbedError(
1885
+ "unhandled-rejection",
1886
+ (reason && reason.message) || String(reason || "Unhandled promise rejection in MCP app embed."),
1887
+ reason && reason.stack ? String(reason.stack) : undefined
1888
+ );
1889
+ });
1806
1890
  })();
1807
1891
  </script>
1808
1892
  </body>
@@ -423,15 +423,23 @@ function redirectWithOAuthError(params: {
423
423
  return redirect(url.toString());
424
424
  }
425
425
 
426
- function redirectWithCode(params: {
426
+ function buildCodeRedirectUrl(params: {
427
427
  redirectUri: string;
428
428
  code: string;
429
429
  state?: string;
430
- }): Response {
430
+ }): string {
431
431
  const url = new URL(params.redirectUri);
432
432
  url.searchParams.set("code", params.code);
433
433
  if (params.state) url.searchParams.set("state", params.state);
434
- return redirect(url.toString());
434
+ return url.toString();
435
+ }
436
+
437
+ function redirectWithCode(params: {
438
+ redirectUri: string;
439
+ code: string;
440
+ state?: string;
441
+ }): Response {
442
+ return redirect(buildCodeRedirectUrl(params));
435
443
  }
436
444
 
437
445
  function codeChallengeForVerifier(verifier: string): string {
@@ -530,6 +538,19 @@ function isValidCodeVerifier(value: unknown): value is string {
530
538
  );
531
539
  }
532
540
 
541
+ // Shared styling for the browser-facing OAuth pages (consent + post-authorize
542
+ // confirmation) so they read as one coherent dark surface.
543
+ const OAUTH_PAGE_BASE_STYLE = `
544
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #09090b; color: #f4f4f5; }
545
+ body { min-height: 100vh; display: grid; place-items: center; margin: 0; padding: 24px; }
546
+ main { width: min(520px, 100%); border: 1px solid #27272a; border-radius: 8px; background: #111113; padding: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.35); }
547
+ h1 { font-size: 22px; line-height: 1.2; margin: 0 0 10px; }
548
+ p { color: #a1a1aa; line-height: 1.5; margin: 0 0 18px; }
549
+ .actions { display: flex; gap: 10px; justify-content: flex-end; }
550
+ button, .btn { border: 0; border-radius: 6px; padding: 10px 14px; font: inherit; font-weight: 650; cursor: pointer; text-decoration: none; display: inline-block; }
551
+ .primary { background: #f4f4f5; color: #09090b; }
552
+ .secondary { background: #27272a; color: #f4f4f5; }`;
553
+
533
554
  function renderConsentPage(params: {
534
555
  appName: string;
535
556
  email: string;
@@ -552,18 +573,9 @@ function renderConsentPage(params: {
552
573
  <meta charset="utf-8">
553
574
  <meta name="viewport" content="width=device-width, initial-scale=1">
554
575
  <title>Authorize ${escapeHtml(params.appName)}</title>
555
- <style>
556
- :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #09090b; color: #f4f4f5; }
557
- body { min-height: 100vh; display: grid; place-items: center; margin: 0; padding: 24px; }
558
- main { width: min(520px, 100%); border: 1px solid #27272a; border-radius: 8px; background: #111113; padding: 24px; box-shadow: 0 24px 80px rgba(0,0,0,.35); }
559
- h1 { font-size: 22px; line-height: 1.2; margin: 0 0 10px; }
560
- p { color: #a1a1aa; line-height: 1.5; margin: 0 0 18px; }
576
+ <style>${OAUTH_PAGE_BASE_STYLE}
561
577
  ul { margin: 0 0 22px; padding-left: 22px; color: #d4d4d8; }
562
578
  code { color: #67e8f9; }
563
- .actions { display: flex; gap: 10px; justify-content: flex-end; }
564
- button { border: 0; border-radius: 6px; padding: 10px 14px; font-weight: 650; cursor: pointer; }
565
- .primary { background: #f4f4f5; color: #09090b; }
566
- .secondary { background: #27272a; color: #f4f4f5; }
567
579
  </style>
568
580
  </head>
569
581
  <body>
@@ -583,6 +595,61 @@ function renderConsentPage(params: {
583
595
  </html>`;
584
596
  }
585
597
 
598
+ // Shown after the user approves a native/desktop client (cursor://, vscode://, …)
599
+ // whose redirect is a private-use scheme. A bare 302 to a custom scheme hands the
600
+ // code to the OS app but leaves the browser tab dangling on a blank/error page, so
601
+ // we render a friendly confirmation that also re-fires the deep link (so the client
602
+ // still receives the code) and tells the user they can return to their agent.
603
+ function renderAuthorizedPage(params: {
604
+ appName: string;
605
+ clientName: string | null;
606
+ redirectUrl: string;
607
+ }): string {
608
+ const friendlyClient = params.clientName?.trim() || null;
609
+ const returnTarget = friendlyClient ?? "your agent";
610
+ const openLabel = friendlyClient
611
+ ? `Open ${escapeHtml(friendlyClient)}`
612
+ : "Return to your agent";
613
+ return `<!doctype html>
614
+ <html lang="en">
615
+ <head>
616
+ <meta charset="utf-8">
617
+ <meta name="viewport" content="width=device-width, initial-scale=1">
618
+ <title>Authorized</title>
619
+ <style>${OAUTH_PAGE_BASE_STYLE}
620
+ main.success { text-align: center; }
621
+ .check { width: 56px; height: 56px; margin: 4px auto 16px; display: grid; place-items: center; border-radius: 999px; background: rgba(34,197,94,.12); }
622
+ .check svg { width: 30px; height: 30px; }
623
+ .success .actions { justify-content: center; margin-top: 4px; }
624
+ .hint { color: #71717a; font-size: 13px; margin: 18px 0 0; }
625
+ </style>
626
+ </head>
627
+ <body>
628
+ <main class="success">
629
+ <div class="check">
630
+ <svg viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
631
+ <path d="M20 6 9 17l-5-5"></path>
632
+ </svg>
633
+ </div>
634
+ <h1>You're all set</h1>
635
+ <p>${escapeHtml(params.appName)} has authorized ${escapeHtml(returnTarget)}. You can return to ${escapeHtml(returnTarget)} to continue.</p>
636
+ <div class="actions">
637
+ <a class="btn primary" id="return-link" href="${escapeHtml(params.redirectUrl)}">${openLabel}</a>
638
+ </div>
639
+ <p class="hint">You can close this tab.</p>
640
+ </main>
641
+ <script>
642
+ (function () {
643
+ var link = document.getElementById("return-link");
644
+ if (link && link.href) {
645
+ try { window.location.href = link.href; } catch (e) {}
646
+ }
647
+ })();
648
+ </script>
649
+ </body>
650
+ </html>`;
651
+ }
652
+
586
653
  async function resolveOrgDomain(
587
654
  orgId: string | undefined,
588
655
  ): Promise<string | undefined> {
@@ -744,6 +811,35 @@ async function handleAuthorize(
744
811
  scope,
745
812
  resource,
746
813
  });
814
+
815
+ // Native/desktop clients register a private-use scheme (cursor://, vscode://, …).
816
+ // A 302 to that scheme opens the app but leaves the browser tab dangling, so we
817
+ // render a friendly confirmation page that re-fires the deep link instead. For
818
+ // https/loopback callbacks the client (or its local server) renders its own page,
819
+ // so keep the standard redirect there.
820
+ let isDeepLinkRedirect = false;
821
+ try {
822
+ const protocol = new URL(redirectUri).protocol;
823
+ isDeepLinkRedirect =
824
+ protocol !== "http:" &&
825
+ protocol !== "https:" &&
826
+ isPrivateUseRedirectScheme(protocol);
827
+ } catch {
828
+ isDeepLinkRedirect = false;
829
+ }
830
+ if (isDeepLinkRedirect) {
831
+ return html(
832
+ renderAuthorizedPage({
833
+ appName: options.appName || options.appId || "Agent Native",
834
+ clientName: client.clientName ?? null,
835
+ redirectUrl: buildCodeRedirectUrl({
836
+ redirectUri,
837
+ state,
838
+ code: code.code,
839
+ }),
840
+ }),
841
+ );
842
+ }
747
843
  return redirectWithCode({ redirectUri, state, code: code.code });
748
844
  }
749
845
 
@@ -32,6 +32,7 @@ import {
32
32
  MCP_EMBED_CORS_ALLOW_HEADERS,
33
33
  shouldAllowMcpEmbedCredentials,
34
34
  } from "../shared/mcp-embed-headers.js";
35
+ import { captureError } from "./capture-error.js";
35
36
  import { handleMcpConnect } from "../mcp/connect-route.js";
36
37
  import {
37
38
  handleMcpOAuth,
@@ -3161,6 +3162,83 @@ export function createCoreRoutesPlugin(
3161
3162
  `${P}/embed/start`,
3162
3163
  createEmbedStartRouteHandler({ getExistingSession: getSession }),
3163
3164
  );
3165
+
3166
+ // POST /_agent-native/mcp/embed-error — telemetry sink for MCP App
3167
+ // embed shells. The shell runs in a sandboxed, opaque-origin iframe
3168
+ // (Codex, Cursor, ChatGPT, Claude) with no session cookie or CSRF
3169
+ // token, so this endpoint is intentionally unauthenticated and
3170
+ // CORS-open to the SAME sandbox origins as /embed/start. It forwards a
3171
+ // small, bounded diagnostic payload to Sentry via captureError so we
3172
+ // can see *why* an inline embed failed (handshake timeout, transplant
3173
+ // fetch status/CORS, auth, CSP) per host. Best-effort: always 204,
3174
+ // never throws, body capped, no client-trusted identity.
3175
+ getH3App(nitroApp).use(
3176
+ `${P}/mcp/embed-error`,
3177
+ defineEventHandler(async (event: H3Event) => {
3178
+ const origin = getHeader(event, "origin");
3179
+ if (origin && isMcpEmbedCorsOrigin(origin)) {
3180
+ setResponseHeader(event, "Access-Control-Allow-Origin", origin);
3181
+ setResponseHeader(event, "Vary", "Origin");
3182
+ setResponseHeader(
3183
+ event,
3184
+ "Access-Control-Allow-Methods",
3185
+ "POST,OPTIONS",
3186
+ );
3187
+ setResponseHeader(
3188
+ event,
3189
+ "Access-Control-Allow-Headers",
3190
+ MCP_EMBED_CORS_ALLOW_HEADERS,
3191
+ );
3192
+ }
3193
+ const method = getMethod(event);
3194
+ if (method === "OPTIONS") {
3195
+ setResponseStatus(event, 204);
3196
+ return "";
3197
+ }
3198
+ if (method !== "POST") {
3199
+ setResponseStatus(event, 405);
3200
+ return { error: "Method not allowed" };
3201
+ }
3202
+ const body = await readBody(event).catch(() => undefined);
3203
+ const rec =
3204
+ body && typeof body === "object" && !Array.isArray(body)
3205
+ ? (body as Record<string, unknown>)
3206
+ : {};
3207
+ const str = (value: unknown, max: number): string | undefined =>
3208
+ typeof value === "string" && value
3209
+ ? value.slice(0, max)
3210
+ : undefined;
3211
+ const message = str(rec.message, 500) ?? "MCP embed failed";
3212
+ try {
3213
+ captureError(new Error(message), {
3214
+ route: `${P}/mcp/embed-error`,
3215
+ method: "POST",
3216
+ userAgent:
3217
+ str(rec.userAgent, 300) ?? getHeader(event, "user-agent"),
3218
+ tags: {
3219
+ source: "mcp-embed-shell",
3220
+ embed_stage: str(rec.stage, 60),
3221
+ embed_render_mode: str(rec.renderMode, 40),
3222
+ embed_host: str(rec.host, 160),
3223
+ embed_bridge: str(rec.bridge, 40),
3224
+ },
3225
+ extra: {
3226
+ embedUrl: str(rec.url, 600),
3227
+ httpStatus:
3228
+ typeof rec.status === "number"
3229
+ ? rec.status
3230
+ : str(rec.status, 40),
3231
+ detail: str(rec.detail, 1200),
3232
+ origin,
3233
+ },
3234
+ });
3235
+ } catch {
3236
+ // Observability must never throw back into the request path.
3237
+ }
3238
+ setResponseStatus(event, 204);
3239
+ return "";
3240
+ }),
3241
+ );
3164
3242
  }
3165
3243
 
3166
3244
  if (!options.disableAppState) {
@@ -1191,6 +1191,7 @@ ${
1191
1191
  }
1192
1192
  }
1193
1193
  function __anFinishOAuthExchange(ret, flowId, sessionToken) {
1194
+ __anGoogleSignInInFlight = false;
1194
1195
  if (__anIsBuilderPreview()) {
1195
1196
  if (sessionToken) {
1196
1197
  __anSetOAuthDebug('OAuth exchange redeemed; applying session bridge to embedded app', flowId);
@@ -1361,6 +1362,8 @@ ${identitySsoScript}
1361
1362
  }
1362
1363
  var __anOAuthPollTimer = null;
1363
1364
  var __anOAuthPollCount = 0;
1365
+ var __anGoogleSignInInFlight = false;
1366
+ var __anGoogleRecoverBound = false;
1364
1367
  function __anNewOAuthFlowId() {
1365
1368
  try {
1366
1369
  if (window.crypto && typeof window.crypto.randomUUID === 'function') {
@@ -1401,6 +1404,34 @@ ${identitySsoScript}
1401
1404
  err.textContent = message;
1402
1405
  err.classList.add('show');
1403
1406
  btn.disabled = false;
1407
+ __anGoogleSignInInFlight = false;
1408
+ }
1409
+ function __anRecoverGoogleSignInAfterReturn() {
1410
+ // The user left for the Google sign-in window and came back. If the flow
1411
+ // never completed (e.g. they closed the window to switch profiles), the
1412
+ // button is stuck disabled with no error path firing for up to 5 minutes.
1413
+ // Re-enable it so they can retry. Wait briefly first so a genuinely
1414
+ // in-flight exchange can still finish and navigate without a flicker.
1415
+ if (!__anGoogleSignInInFlight) return;
1416
+ setTimeout(function() {
1417
+ if (!__anGoogleSignInInFlight) return;
1418
+ var btn = document.getElementById('google-btn');
1419
+ if (!btn || !btn.disabled) return;
1420
+ if (__anOAuthPollTimer) {
1421
+ clearInterval(__anOAuthPollTimer);
1422
+ __anOAuthPollTimer = null;
1423
+ }
1424
+ btn.disabled = false;
1425
+ __anGoogleSignInInFlight = false;
1426
+ }, 1200);
1427
+ }
1428
+ function __anBindGoogleRecover() {
1429
+ if (__anGoogleRecoverBound) return;
1430
+ __anGoogleRecoverBound = true;
1431
+ window.addEventListener('focus', __anRecoverGoogleSignInAfterReturn);
1432
+ document.addEventListener('visibilitychange', function() {
1433
+ if (document.visibilityState === 'visible') __anRecoverGoogleSignInAfterReturn();
1434
+ });
1404
1435
  }
1405
1436
  function __anHandlePopupOAuthFailure(ret, btn, err, flowId, redirectReason, builderFrameMessage) {
1406
1437
  if (__anIsBuilderPreview() && __anIsInFrame()) {
@@ -2013,6 +2044,8 @@ ${
2013
2044
  var err = document.getElementById('google-err');
2014
2045
  var ret = __anGetReturnPath();
2015
2046
  btn.disabled = true;
2047
+ __anGoogleSignInInFlight = true;
2048
+ __anBindGoogleRecover();
2016
2049
  err.classList.remove('show');
2017
2050
  if (__anResolveAuthFlow() === 'popup') {
2018
2051
  __anStartPopupOAuth(ret, btn, err);
@@ -2037,11 +2070,13 @@ ${
2037
2070
  err.textContent = data.message || 'Google OAuth is not configured.';
2038
2071
  err.classList.add('show');
2039
2072
  btn.disabled = false;
2073
+ __anGoogleSignInInFlight = false;
2040
2074
  }
2041
2075
  } catch (e) {
2042
2076
  err.textContent = 'Failed to connect. Please try again.';
2043
2077
  err.classList.add('show');
2044
2078
  btn.disabled = false;
2079
+ __anGoogleSignInInFlight = false;
2045
2080
  }
2046
2081
  }`
2047
2082
  : ""
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: reliable-mutations
3
+ description: >-
4
+ How the agent must perform writes so they actually persist under the hosted
5
+ ~40s run budget. Use whenever you create, update, delete, or batch-write app
6
+ data — especially "do this for many items" loops, or any task where the user
7
+ expects N things to end up saved.
8
+ ---
9
+
10
+ # Reliable Mutations
11
+
12
+ ## Rule
13
+
14
+ Make a change in **one atomic call** when the action supports it, then **verify
15
+ the persisted end state and report concrete proof** (counts/ids). Never drive a
16
+ multi-step change by looping many small writes, and never report success from a
17
+ tool ✓ alone.
18
+
19
+ ## Why
20
+
21
+ Hosted agent runs have a ~40s soft budget (it exists to hand off cleanly under
22
+ the upstream gateway/function walls — it is correct and is not raisable; see the
23
+ durable-agent-runs design doc). When you loop many sequential writes inside one
24
+ turn, the budget can cut you off mid-loop. The run resumes in a new chunk, but
25
+ the resume often re-does earlier steps instead of advancing, so the loop churns
26
+ and the *saved* result ends up partial — or empty — even though each individual
27
+ tool call appeared to succeed. The user gets told "done" while the data says
28
+ otherwise. One atomic call commits or fails as a unit; verification turns a
29
+ hopeful ✓ into a fact.
30
+
31
+ ## How
32
+
33
+ 1. **Prefer a single atomic call.** If an action accepts the whole set (add
34
+ many, set all, bulk update), pass the full batch in one call so it commits
35
+ atomically. Check the action surface for a batch/plural form before reaching
36
+ for a loop.
37
+ 2. **Do not loop many small writes under the run budget.** A sequence of N
38
+ per-item writes in one turn will race the ~40s cutoff and can leave partial
39
+ or no state. If no batch action exists, that is a gap in the action layer —
40
+ add or extend an action that accepts the batch (see the `actions` skill)
41
+ rather than papering over it with a loop.
42
+ 3. **Verify the end state after writing.** Re-read the data (a list/read action,
43
+ a count query) and confirm the result matches intent — the right number of
44
+ rows, the expected ids/fields. Do this before you tell the user it worked.
45
+ 4. **Report proof-of-done, not vibes.** State concrete evidence: "saved 12 of 12
46
+ panels (ids …)" or "updated 5 rows". Do not infer success from the presence
47
+ of a tool ✓ on an individual call.
48
+ 5. **On a time-budget cutoff, fail loud.** If the turn is cut before the change
49
+ is fully committed and verified, say so explicitly and report what *did*
50
+ persist (M of N) and what remains. Never round a partial or unverified write
51
+ up to "done".
52
+
53
+ ## Don't
54
+
55
+ - Don't loop `for each item: write(item)` for a large set in a single hosted
56
+ turn.
57
+ - Don't claim completion because every tool call returned ✓ — a ✓ on an aborted
58
+ chunk does not mean the row was committed.
59
+ - Don't silently shrink the scope ("I added a few of them") and present it as the
60
+ finished task.
61
+ - Don't try to "fix" this by asking for a longer run timeout — the budget is
62
+ correct; restructure the write instead.
63
+
64
+ ## Related
65
+
66
+ - `actions` — define or extend a batch/atomic action when only per-item writes
67
+ exist.
68
+ - `storing-data` — where app data lives and how reads/writes are scoped.
69
+ - `performance` — avoid query waterfalls when verifying end state.
70
+ - Design doc: `packages/core/docs/design/durable-agent-runs.md` — the real
71
+ ceiling fix (checkpointed and durable runs); this skill is the agent-facing
72
+ mitigation that reduces how often the ceiling is hit.
@@ -174,6 +174,28 @@ pnpm action update-dashboard --dashboardId weekly-metrics --config '<full json>'
174
174
 
175
175
  After a mutation, navigate to the dashboard if the user is elsewhere. The app syncs through the framework's polling/query invalidation path.
176
176
 
177
+ ## Reliable Bulk Edits
178
+
179
+ This is the dashboard-specific application of the framework-wide `reliable-mutations` skill — read that for the general rule (one atomic write, verify end state, report proof-of-done).
180
+
181
+ Hosted agent runs have a **~40s budget**. Many sequential `update-dashboard` calls (one per panel, plus schema-discovery calls) will blow that budget and leave the dashboard in a partial state — earlier inserts looked like they succeeded (✓), but nothing actually persisted. Avoid this:
182
+
183
+ - **Batch ALL changes into ONE `update-dashboard` call.** A single `update-dashboard` is atomic: it applies every op to an in-memory config, validates all panel SQL, then upserts once. Never loop the action.
184
+ - To add N panels, pass N ops in one call: `ops: [{op:"insert", path:"/panels/-", value:<panel>}, … ]` (`/panels/-` appends to the end).
185
+ - The `ops` format needs no discovery: each op is `{ op, path, from?, value? }`, `op ∈ set | replace | remove | insert | move | move-before`, and `path` is a JSON Pointer (e.g. `/panels/3`, `/panels/3/title`, `/name`).
186
+ - **To add a shipped template's panels, prefer `install-dashboard-template` with `mergePanels: true`** and the existing `dashboardId`. It appends only the template panels whose id is not already present (preserving existing panels and order) in one atomic save — you don't author each panel yourself.
187
+ - **Always verify the returned proof-of-done and report it.** `update-dashboard` returns `panelCount`, `appliedOps`, and a `summary` string; `install-dashboard-template --mergePanels` returns `addedPanelIds`, `skippedExistingIds`, and `panelCount`. Tell the user the resulting panel count instead of assuming success.
188
+
189
+ ```bash
190
+ # Add several panels in ONE atomic call (never one call per panel)
191
+ pnpm action update-dashboard --dashboardId weekly-metrics \
192
+ --ops '[{"op":"insert","path":"/panels/-","value":{"id":"p1","title":"A","source":"first-party","chartType":"metric","width":1,"sql":"SELECT COUNT(*) AS value FROM analytics_events"}},
193
+ {"op":"insert","path":"/panels/-","value":{"id":"p2","title":"B","source":"first-party","chartType":"metric","width":1,"sql":"SELECT COUNT(DISTINCT user_id) AS value FROM analytics_events"}}]'
194
+
195
+ # Append a template's panels to an existing dashboard in one call
196
+ pnpm action install-dashboard-template --templateId skills-cli-funnel --dashboardId weekly-metrics --mergePanels true
197
+ ```
198
+
177
199
  ## Archiving vs deleting
178
200
 
179
201
  Dashboards have a soft-delete state. The default user-facing destructive action is **Archive** (recoverable). Hard delete still exists, but lives behind a "Delete permanently" confirm in both the page header and the sidebar dropdown — and in the agent surface, behind the older `delete-dashboard` action. Archived rows stay in the `dashboards` table with `archived_at` set, are hidden from the default sidebar list, and remain accessible by id (so deep links in chat history keep working) until explicitly purged.