@agent-native/core 0.101.10 → 0.101.12

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 (91) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/client.ts +23 -1
  5. package/corpus/core/src/a2a/handlers.ts +65 -1
  6. package/corpus/core/src/a2a/server.ts +124 -0
  7. package/corpus/core/src/a2a/task-store.ts +253 -0
  8. package/corpus/core/src/a2a/types.ts +26 -0
  9. package/corpus/core/src/agent/production-agent.ts +5 -2
  10. package/corpus/core/src/coding-tools/run-code.ts +65 -12
  11. package/corpus/core/src/extensions/actions.ts +141 -7
  12. package/corpus/core/src/mcp/builtin-tools.ts +25 -1
  13. package/corpus/core/src/scripts/call-agent.ts +22 -3
  14. package/corpus/core/src/server/agent-chat-plugin.ts +86 -1
  15. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  16. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +59 -0
  17. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +5 -2
  18. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +13 -4
  19. package/corpus/templates/analytics/server/lib/real-data-actions.ts +63 -1
  20. package/corpus/templates/analytics/server/plugins/agent-chat.ts +30 -0
  21. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +23 -0
  22. package/corpus/templates/content/app/components/editor/MathRenderer.tsx +43 -0
  23. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +260 -4
  24. package/corpus/templates/content/app/components/editor/extensions/NotionExtensions.tsx +78 -0
  25. package/corpus/templates/content/app/global.css +53 -0
  26. package/corpus/templates/content/app/i18n/zh-TW.ts +13 -0
  27. package/corpus/templates/content/app/i18n-data.ts +163 -0
  28. package/corpus/templates/content/app/root.tsx +2 -0
  29. package/corpus/templates/content/changelog/2026-07-14-latex-equations-render-in-documents-public-pages-and-exports.md +6 -0
  30. package/corpus/templates/content/package.json +1 -0
  31. package/corpus/templates/content/shared/document-export.ts +296 -25
  32. package/corpus/templates/content/shared/inline-math.ts +128 -0
  33. package/corpus/templates/content/shared/math-rendering.ts +50 -0
  34. package/corpus/templates/content/shared/nfm.ts +17 -14
  35. package/corpus/templates/content/vite.config.ts +0 -1
  36. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +17 -0
  37. package/corpus/templates/mail/AGENTS.md +21 -19
  38. package/corpus/templates/mail/actions/create-attachment-upload.ts +49 -0
  39. package/corpus/templates/mail/changelog/2026-07-14-connected-agents-can-now-securely-upload-local-files-for-mai.md +6 -0
  40. package/corpus/templates/mail/server/handlers/media.ts +84 -61
  41. package/corpus/templates/mail/server/lib/attachment-upload-ticket.ts +234 -0
  42. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +8 -4
  43. package/corpus/templates/mail/server/lib/media-upload.ts +114 -0
  44. package/corpus/templates/mail/server/plugins/auth.ts +9 -1
  45. package/corpus/templates/mail/server/routes/api/media/attachment-upload/[uploadId].put.ts +1 -0
  46. package/dist/a2a/client.d.ts +5 -1
  47. package/dist/a2a/client.d.ts.map +1 -1
  48. package/dist/a2a/client.js +18 -1
  49. package/dist/a2a/client.js.map +1 -1
  50. package/dist/a2a/handlers.d.ts.map +1 -1
  51. package/dist/a2a/handlers.js +57 -8
  52. package/dist/a2a/handlers.js.map +1 -1
  53. package/dist/a2a/server.d.ts.map +1 -1
  54. package/dist/a2a/server.js +87 -1
  55. package/dist/a2a/server.js.map +1 -1
  56. package/dist/a2a/task-store.d.ts +27 -0
  57. package/dist/a2a/task-store.d.ts.map +1 -1
  58. package/dist/a2a/task-store.js +216 -0
  59. package/dist/a2a/task-store.js.map +1 -1
  60. package/dist/a2a/types.d.ts +24 -0
  61. package/dist/a2a/types.d.ts.map +1 -1
  62. package/dist/a2a/types.js.map +1 -1
  63. package/dist/agent/production-agent.d.ts +1 -0
  64. package/dist/agent/production-agent.d.ts.map +1 -1
  65. package/dist/agent/production-agent.js +5 -2
  66. package/dist/agent/production-agent.js.map +1 -1
  67. package/dist/coding-tools/run-code.js +64 -11
  68. package/dist/coding-tools/run-code.js.map +1 -1
  69. package/dist/collab/struct-routes.d.ts +1 -1
  70. package/dist/extensions/actions.d.ts.map +1 -1
  71. package/dist/extensions/actions.js +129 -7
  72. package/dist/extensions/actions.js.map +1 -1
  73. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  74. package/dist/mcp/builtin-tools.js +22 -3
  75. package/dist/mcp/builtin-tools.js.map +1 -1
  76. package/dist/notifications/routes.d.ts +2 -2
  77. package/dist/observability/routes.d.ts +5 -5
  78. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  79. package/dist/resources/handlers.d.ts +3 -3
  80. package/dist/scripts/call-agent.d.ts +1 -1
  81. package/dist/scripts/call-agent.d.ts.map +1 -1
  82. package/dist/scripts/call-agent.js +19 -1
  83. package/dist/scripts/call-agent.js.map +1 -1
  84. package/dist/secrets/routes.d.ts +9 -9
  85. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  86. package/dist/server/agent-chat-plugin.js +71 -2
  87. package/dist/server/agent-chat-plugin.js.map +1 -1
  88. package/dist/server/transcribe-voice.d.ts +1 -1
  89. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
  90. package/package.json +1 -1
  91. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +25 -0
@@ -3,8 +3,17 @@ import type { ActionRunContext } from "../action.js";
3
3
  import type { ActionEntry } from "../agent/production-agent.js";
4
4
  import type { AgentChatAttachment } from "../agent/types.js";
5
5
  import { writeAppState } from "../application-state/script-helpers.js";
6
- import { getRequestRunContext } from "../server/request-context.js";
6
+ import { readResource } from "../resources/script-helpers.js";
7
+ import {
8
+ getRequestOrgId,
9
+ getRequestRunContext,
10
+ getRequestUserEmail,
11
+ } from "../server/request-context.js";
7
12
  import { resolveAccess } from "../sharing/access.js";
13
+ import {
14
+ readWorkspaceFile,
15
+ type WorkspaceFilesScope,
16
+ } from "../workspace-files/store.js";
8
17
  import type {
9
18
  ExtensionContentEdit,
10
19
  ExtensionLegacyPatch,
@@ -483,7 +492,7 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
483
492
  "create-extension": {
484
493
  tool: {
485
494
  description:
486
- 'Create a persisted sandboxed Alpine.js mini-app extension and render it inline in the chat. Use this when the user wants generated UI that should be saved, reusable, or visible in the Extensions view: extensions, widgets, dashboards, calculators, mini-apps, and reusable interactive utilities. For one-time chat-only UI, use render-inline-extension instead. The content must be a self-contained Alpine.js HTML body snippet that can use appAction(), appFetch(), dbQuery(), extensionFetch(), extensionData, agentNative.ui.output(value, opts?), and agentNative.chat.send()/sendToAgentChat(). Use appAction() for app data writes and extensionData for extension-owned persisted UI state; dbQuery() is for read-only inspection of known app SQL tables. Use agentNative.ui.output for passive current values from knobs, sliders, and selections; it writes application state at inline-ui:<extension id>:output, which the agent can read later with readAppState when the user says to use that value. Use agentNative.chat.send for visible submit/apply actions. Persist reusable user-edited state with extensionData: if the extension has checkboxes, todos, notes, filters, preferences, or any control whose value should survive reload/reopen, load that state on init and save changes with extensionData, usually at user scope, instead of keeping it only in Alpine state. IMPORTANT — hosting a pasted file: if the user pasted a large HTML/Alpine file (it appears in your context as an <attachment name="pasted-text-…"> block) and asked you to host it as-is, do NOT copy that file into `content`. Instead leave `content` empty and pass `contentFromAttachment` set to that attachment\'s name (or the literal "latest" for the most recent pasted block) — the server reads the file verbatim. Re-emitting a large pasted file as `content` regularly gets cut off mid-stream and stalls the turn. Prefer appAction(name, params) for app data and actions, including read actions mounted as GET; do not call template /api/* routes from appFetch because the extension bridge only allows framework /_agent-native/* paths. Parse JSON string action results before aggregating; use dbQuery() only for known existing SQL tables and never for writes. Keep the initial create-extension payload compact and working; for complex extensions, create a useful v1 first, then use focused update-extension edits for refinements rather than assembling one enormous initial tool input. For any non-trivial component (more than a couple of state fields, any methods, any string formatting, any branching) put the component in a <script> block via Alpine.data(\'name\', () => ({...})) and reference it with x-data="name" — do NOT cram methods, template literals, or branching logic into an inline x-data="{...}" attribute (HTML parser pitfalls cause ReferenceError failures). Define every variable referenced from x-text/x-show/x-if/x-for on the data object\'s initial state. If the extension\'s value depends on an LLM call, require a real key via \\${keys.OPENAI_API_KEY}/\\${keys.ANTHROPIC_API_KEY} (and tell the user to add it in the Dispatch Vault, or in app Settings → API Keys & Connections for standalone apps, if missing) or route the AI work to the agent chat — never ship a stubbed analysis step that renders a placeholder/boolean as the result.',
495
+ 'Create a persisted sandboxed Alpine.js mini-app extension and render it inline in the chat. Use this when the user wants generated UI that should be saved, reusable, or visible in the Extensions view: extensions, widgets, dashboards, calculators, mini-apps, and reusable interactive utilities. For one-time chat-only UI, use render-inline-extension instead. The content must be a self-contained Alpine.js HTML body snippet that can use appAction(), appFetch(), dbQuery(), extensionFetch(), extensionData, agentNative.ui.output(value, opts?), and agentNative.chat.send()/sendToAgentChat(). Use appAction() for app data writes and extensionData for extension-owned persisted UI state; dbQuery() is for read-only inspection of known app SQL tables. Use agentNative.ui.output for passive current values from knobs, sliders, and selections; it writes application state at inline-ui:<extension id>:output, which the agent can read later with readAppState when the user says to use that value. Use agentNative.chat.send for visible submit/apply actions. Persist reusable user-edited state with extensionData: if the extension has checkboxes, todos, notes, filters, preferences, or any control whose value should survive reload/reopen, load that state on init and save changes with extensionData, usually at user scope, instead of keeping it only in Alpine state. IMPORTANT — hosting a pasted file: if the user pasted a large HTML/Alpine file (it appears in your context as an <attachment name="pasted-text-…"> block) and asked you to host it as-is, do NOT copy that file into `content`. Instead leave `content` empty and pass `contentFromAttachment` set to that attachment\'s name (or the literal "latest" for the most recent pasted block) — the server reads the file verbatim. Re-emitting a large pasted file as `content` regularly gets cut off mid-stream and stalls the turn. IMPORTANT — cloning a large extension that lives as a workspace resource (not a chat attachment): leave `content` empty and pass `contentFromWorkspaceFile` set to the resource path (e.g. "intuit-analytics-extension.html"); the server reads the full file. Do NOT try to reconstruct the body with run-code or route create-extension through run-code (mutating actions are not callable there). Prefer appAction(name, params) for app data and actions, including read actions mounted as GET; do not call template /api/* routes from appFetch because the extension bridge only allows framework /_agent-native/* paths. Parse JSON string action results before aggregating; use dbQuery() only for known existing SQL tables and never for writes. Keep the initial create-extension payload compact and working; for complex extensions, create a useful v1 first, then use focused update-extension edits for refinements rather than assembling one enormous initial tool input. For any non-trivial component (more than a couple of state fields, any methods, any string formatting, any branching) put the component in a <script> block via Alpine.data(\'name\', () => ({...})) and reference it with x-data="name" — do NOT cram methods, template literals, or branching logic into an inline x-data="{...}" attribute (HTML parser pitfalls cause ReferenceError failures). Define every variable referenced from x-text/x-show/x-if/x-for on the data object\'s initial state. If the extension\'s value depends on an LLM call, require a real key via \\${keys.OPENAI_API_KEY}/\\${keys.ANTHROPIC_API_KEY} (and tell the user to add it in the Dispatch Vault, or in app Settings → API Keys & Connections for standalone apps, if missing) or route the AI work to the agent chat — never ship a stubbed analysis step that renders a placeholder/boolean as the result.',
487
496
  parameters: {
488
497
  type: "object",
489
498
  properties: {
@@ -506,6 +515,11 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
506
515
  description:
507
516
  'Host a pasted/attached file verbatim WITHOUT re-typing it. Set this to the name of an attachment on the current turn (e.g. "pasted-text-1718000000000-ab12cd.txt") or the literal "latest" for the most recent pasted block; the server resolves it into the extension content. Use this instead of `content` whenever the user pasted a large file to host — it avoids re-emitting thousands of tokens. When set, leave `content` empty.',
508
517
  },
518
+ contentFromWorkspaceFile: {
519
+ type: "string",
520
+ description:
521
+ 'Host a workspace/shared resource file verbatim WITHOUT re-typing it. Set this to the resource path (e.g. "intuit-analytics-extension.html"); the server reads the full file and uses it as the extension content. Use this — NOT run-code or contentFromAttachment — when cloning a large extension body that already exists as a workspace resource. When set, leave `content` empty.',
522
+ },
509
523
  icon: {
510
524
  type: "string",
511
525
  description: "Optional icon name or short label.",
@@ -521,7 +535,7 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
521
535
  run: async (args, ctx) => {
522
536
  const name = String(args?.name ?? "").trim();
523
537
  if (!name) return "Error: name is required.";
524
- const resolved = resolveExtensionContent(args, ctx);
538
+ const resolved = await resolveExtensionContentAsync(args, ctx);
525
539
  if ("error" in resolved) return resolved.error;
526
540
  const content = resolved.content.trim();
527
541
  if (!content) return "Error: content is required.";
@@ -553,9 +567,12 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
553
567
  } catch {
554
568
  // Non-fatal — agent can still mention the path in its reply.
555
569
  }
570
+ const hiddenIds = await getHiddenExtensionIdsForCurrentUser();
556
571
  return {
557
572
  ok: true,
558
- extension: { ...existing, path: existingPath },
573
+ // Compact summary (contentLength + contentHash, no full body). Echoing
574
+ // the whole HTML back is pure token waste — the agent just supplied it.
575
+ extension: await summarizeExtension(existing, hiddenIds, false),
559
576
  path: existingPath,
560
577
  next: `Extension was already created in this session (recovered from a connection retry). The user is being navigated to it — no further navigation tool calls needed.`,
561
578
  };
@@ -585,9 +602,12 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
585
602
  // Non-fatal — agent can still mention the path in its reply.
586
603
  }
587
604
 
605
+ const hiddenIds = await getHiddenExtensionIdsForCurrentUser();
588
606
  return {
589
607
  ok: true,
590
- extension: { ...extension, path },
608
+ // Compact summary (contentLength + contentHash, no full body). Echoing
609
+ // the whole HTML back is pure token waste — the agent just supplied it.
610
+ extension: await summarizeExtension(extension, hiddenIds, false),
591
611
  path,
592
612
  next: `Created. The user is being navigated to the new extension automatically — no further navigation tool calls needed.`,
593
613
  };
@@ -624,6 +644,11 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
624
644
  description:
625
645
  'Optional full replacement sourced from a pasted/attached file on the current turn, by attachment name (or the literal "latest" for the most recent pasted block). Use instead of `content` when replacing the whole body with a large pasted file so you do not have to re-type it. Ignored when `content` is provided.',
626
646
  },
647
+ contentFromWorkspaceFile: {
648
+ type: "string",
649
+ description:
650
+ 'Optional full replacement sourced from a workspace/shared resource file, by resource path (e.g. "intuit-analytics-extension.html"). The server reads the full file and uses it as the replacement body. Use instead of `content` when replacing the whole body with a large file that exists as a workspace resource. Ignored when `content` is provided.',
651
+ },
627
652
  patches: {
628
653
  type: "string",
629
654
  description:
@@ -670,9 +695,10 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
670
695
  : undefined;
671
696
  if (
672
697
  replacementContent === undefined &&
673
- args?.contentFromAttachment !== undefined
698
+ (args?.contentFromAttachment !== undefined ||
699
+ args?.contentFromWorkspaceFile !== undefined)
674
700
  ) {
675
- const resolved = resolveExtensionContent(args, ctx);
701
+ const resolved = await resolveExtensionContentAsync(args, ctx);
676
702
  if ("error" in resolved) return resolved.error;
677
703
  replacementContent = resolved.content;
678
704
  }
@@ -1320,6 +1346,114 @@ function resolveExtensionContent(
1320
1346
  return { content: resolved };
1321
1347
  }
1322
1348
 
1349
+ /**
1350
+ * Resolve the workspace-files bridge scope exactly the way run-code's
1351
+ * workspaceRead/workspaceWrite do: org-preferred (org → shared owner) with the
1352
+ * requesting user's email as the solo fallback. Kept in lockstep with
1353
+ * `resolveScope` in `workspace-files/tool.ts`.
1354
+ */
1355
+ function workspaceFilesBridgeScope(): WorkspaceFilesScope | null {
1356
+ const orgId = getRequestOrgId();
1357
+ if (orgId) return { scope: "org", scopeId: orgId };
1358
+ const email = getRequestUserEmail();
1359
+ if (email) return { scope: "user", scopeId: email };
1360
+ return null;
1361
+ }
1362
+
1363
+ /**
1364
+ * Read a workspace/shared/personal resource file's FULL content by path.
1365
+ *
1366
+ * Precedence (single, documented rule so this never silently resolves a
1367
+ * different file than the agent inspected):
1368
+ * 1. The run-code `workspace-files` bridge scope (org → shared owner, else the
1369
+ * user's email). This is the SAME owner/scope `workspaceRead` /
1370
+ * `workspaceWrite` use, so a body the agent staged via `workspaceWrite` is
1371
+ * resolved here verbatim — the two paths cannot diverge.
1372
+ * 2. User-managed Resources (personal override → org/shared → workspace
1373
+ * default) as a fallback, for pre-built resources that were created in the
1374
+ * Resources panel rather than staged through the bridge.
1375
+ *
1376
+ * Unlike attachments, resource content is not capped/truncated on the way in, so
1377
+ * this is the correct path for cloning a large extension body that already
1378
+ * exists as a workspace resource (e.g. a per-customer dashboard).
1379
+ */
1380
+ async function readWorkspaceFileContent(path: string): Promise<string | null> {
1381
+ const trimmed = path.trim();
1382
+ if (!trimmed) return null;
1383
+ // 1) Bridge parity — resolve exactly the file workspaceRead/workspaceWrite see.
1384
+ const bridgeScope = workspaceFilesBridgeScope();
1385
+ if (bridgeScope) {
1386
+ let bridgeFile: Awaited<ReturnType<typeof readWorkspaceFile>>;
1387
+ try {
1388
+ bridgeFile = await readWorkspaceFile(bridgeScope, trimmed);
1389
+ } catch {
1390
+ // A THROW here is a transient store error or invalid path — NOT a
1391
+ // definitive "not found". Fail closed rather than silently hosting a
1392
+ // possibly-different same-path Resources body than workspaceRead
1393
+ // inspected. A retry re-runs this read cleanly.
1394
+ return null;
1395
+ }
1396
+ // A null result means the file genuinely does not exist in the bridge scope;
1397
+ // fall through to user-managed Resources for pre-built resource-panel files.
1398
+ if (bridgeFile && typeof bridgeFile.content === "string") {
1399
+ return bridgeFile.content;
1400
+ }
1401
+ }
1402
+ // 2) Fallback — user-managed Resources by scope precedence.
1403
+ for (const scope of ["personal", "shared", "workspace"] as const) {
1404
+ try {
1405
+ const content = await readResource(trimmed, { scope });
1406
+ if (typeof content === "string") return content;
1407
+ } catch {
1408
+ // A given scope can throw (e.g. `personal` when no user identity is
1409
+ // resolvable in this context). Don't let one scope abort the lookup —
1410
+ // fall through and try the next one.
1411
+ }
1412
+ }
1413
+ return null;
1414
+ }
1415
+
1416
+ /**
1417
+ * Resolve the extension HTML body from (in priority order) inline `content`, a
1418
+ * `contentFromWorkspaceFile` resource path, or a `contentFromAttachment` handle.
1419
+ *
1420
+ * The workspace-file path exists because a large extension body frequently lives
1421
+ * as a workspace resource (not a chat attachment). Without it the model has no
1422
+ * viable route — inline is too large to shuttle reliably, contentFromAttachment
1423
+ * only sees chat attachments, and mutating actions cannot run from run-code — so
1424
+ * it loops and the run aborts with no_progress.
1425
+ */
1426
+ async function resolveExtensionContentAsync(
1427
+ args: Record<string, string> | undefined,
1428
+ ctx: ActionRunContext | undefined,
1429
+ ): Promise<{ content: string } | { error: string }> {
1430
+ const inline = args?.content !== undefined ? String(args.content) : undefined;
1431
+ if (inline !== undefined && inline.trim().length > 0) {
1432
+ return { content: inline };
1433
+ }
1434
+
1435
+ const wsRef =
1436
+ args?.contentFromWorkspaceFile !== undefined
1437
+ ? String(args.contentFromWorkspaceFile).trim()
1438
+ : "";
1439
+ if (wsRef) {
1440
+ const content = await readWorkspaceFileContent(wsRef);
1441
+ if (content === null) {
1442
+ return {
1443
+ error: `Error: contentFromWorkspaceFile="${wsRef}" did not match any readable workspace/shared/personal resource file. Check the exact path (e.g. "intuit-analytics-extension.html"), or pass the HTML inline via content.`,
1444
+ };
1445
+ }
1446
+ if (content.trim().length === 0) {
1447
+ return {
1448
+ error: `Error: workspace file "${wsRef}" is empty. Pass non-empty HTML inline via content, or point contentFromWorkspaceFile at a file with content.`,
1449
+ };
1450
+ }
1451
+ return { content };
1452
+ }
1453
+
1454
+ return resolveExtensionContent(args, ctx);
1455
+ }
1456
+
1323
1457
  function coerceBoolean(value: unknown): boolean {
1324
1458
  return value === true || value === "true";
1325
1459
  }
@@ -31,7 +31,7 @@
31
31
  * `mountMCP` — the Node bits are dynamically imported inside `run()`.
32
32
  */
33
33
 
34
- import type { Task } from "../a2a/types.js";
34
+ import type { A2AApprovedAction, Task } from "../a2a/types.js";
35
35
  import type { ActionEntry } from "../agent/production-agent.js";
36
36
  import type { ActionTool } from "../agent/types.js";
37
37
  import { getConfiguredAppBasePath } from "../server/app-base-path.js";
@@ -404,6 +404,7 @@ async function submitAskAppA2ATask(
404
404
  route: AskAppRoute,
405
405
  message: string,
406
406
  maxWaitMs: number,
407
+ approvedActions?: A2AApprovedAction[],
407
408
  ): Promise<AskAppTaskResult> {
408
409
  const deadline = maxWaitMs > 0 ? Date.now() + maxWaitMs : undefined;
409
410
  const { client, metadata } = await createA2AClientForAskApp(
@@ -419,6 +420,7 @@ async function submitAskAppA2ATask(
419
420
  {
420
421
  async: true,
421
422
  metadata,
423
+ ...(approvedActions?.length ? { approvedActions } : {}),
422
424
  },
423
425
  );
424
426
  const finalOrRunning = await waitForA2ATask(client, task, deadline);
@@ -890,6 +892,7 @@ async function routeAskOverA2A(
890
892
  durable?: boolean;
891
893
  maxWaitMs?: number;
892
894
  requestOrigin?: string;
895
+ approvedActions?: A2AApprovedAction[];
893
896
  },
894
897
  ): Promise<
895
898
  { app: string; routedVia: "a2a"; response: string } | AskAppTaskResult
@@ -904,6 +907,7 @@ async function routeAskOverA2A(
904
907
  },
905
908
  message,
906
909
  options.maxWaitMs ?? ASK_APP_DEFAULT_INLINE_WAIT_MS,
910
+ options.approvedActions,
907
911
  );
908
912
  }
909
913
  const { callAgent } = await import("../a2a/client.js");
@@ -919,6 +923,7 @@ async function routeAskOverA2A(
919
923
  orgDomain: auth.orgDomain,
920
924
  orgSecret: auth.orgSecret,
921
925
  requestOrigin: options?.requestOrigin,
926
+ approvedActions: options?.approvedActions,
922
927
  // Bound the wait — cross-app A2A polls async by default.
923
928
  timeoutMs: 5 * 60_000,
924
929
  });
@@ -1012,6 +1017,19 @@ function askAppTool(
1012
1017
  description:
1013
1018
  "Maximum time to wait inline before returning a taskId. Hosted MCP clamps this to 25000ms.",
1014
1019
  },
1020
+ approvedActions: {
1021
+ type: "array",
1022
+ description:
1023
+ "Exact downstream tool calls the user explicitly authorized in this chat. Never infer authorization or include a different action.",
1024
+ items: {
1025
+ type: "object",
1026
+ properties: {
1027
+ tool: { type: "string" },
1028
+ input: { type: "object", additionalProperties: true },
1029
+ },
1030
+ required: ["tool", "input"],
1031
+ },
1032
+ } as any,
1015
1033
  },
1016
1034
  ["message"],
1017
1035
  ),
@@ -1024,6 +1042,9 @@ function askAppTool(
1024
1042
  const maxWaitMs = isExplicitAsyncAsk(args.async)
1025
1043
  ? 0
1026
1044
  : boundedAskAppWaitMs(args.maxWaitMs);
1045
+ const approvedActions = Array.isArray(args.approvedActions)
1046
+ ? (args.approvedActions as A2AApprovedAction[])
1047
+ : undefined;
1027
1048
 
1028
1049
  // Cross-app: the caller named a *different* workspace app. Route the
1029
1050
  // message to THAT app's agent over A2A (its `/_agent-native/a2a`
@@ -1040,6 +1061,7 @@ function askAppTool(
1040
1061
  durable: useDurableA2A,
1041
1062
  maxWaitMs,
1042
1063
  requestOrigin: targetApp.origin,
1064
+ approvedActions,
1043
1065
  },
1044
1066
  );
1045
1067
  } catch (err: any) {
@@ -1074,6 +1096,7 @@ function askAppTool(
1074
1096
  durable: useDurableA2A,
1075
1097
  maxWaitMs,
1076
1098
  requestOrigin: dirMatch.url,
1099
+ approvedActions,
1077
1100
  },
1078
1101
  );
1079
1102
  } catch (err: any) {
@@ -1120,6 +1143,7 @@ function askAppTool(
1120
1143
  },
1121
1144
  message,
1122
1145
  maxWaitMs,
1146
+ approvedActions,
1123
1147
  );
1124
1148
  }
1125
1149
 
@@ -6,7 +6,7 @@ import {
6
6
  shouldPreferGlobalA2ASecret,
7
7
  signA2AToken,
8
8
  } from "../a2a/client.js";
9
- import type { Task } from "../a2a/types.js";
9
+ import type { A2AApprovedAction, Task } from "../a2a/types.js";
10
10
  import {
11
11
  formatLlmCredentialErrorMessage,
12
12
  isLlmCredentialError,
@@ -112,17 +112,34 @@ export const tool: ActionTool = {
112
112
  type: "string",
113
113
  description: "The message/question to send to the other agent",
114
114
  },
115
+ approvedActions: {
116
+ type: "array",
117
+ description:
118
+ "Exact downstream tool calls the current user explicitly authorized in this chat. Never infer authorization or include a broader/different action.",
119
+ items: {
120
+ type: "object",
121
+ properties: {
122
+ tool: { type: "string" },
123
+ input: { type: "object", additionalProperties: true },
124
+ },
125
+ required: ["tool", "input"],
126
+ },
127
+ },
115
128
  },
116
129
  required: ["agent", "message"],
117
130
  },
118
131
  };
119
132
 
120
133
  export async function run(
121
- args: Record<string, string>,
134
+ args: Record<string, unknown>,
122
135
  context?: ActionRunContext,
123
136
  selfAppId?: string,
124
137
  ): Promise<string> {
125
- const { agent: agentIdOrName, message } = args;
138
+ const agentIdOrName = String(args.agent ?? "");
139
+ const message = String(args.message ?? "");
140
+ const approvedActions = Array.isArray(args.approvedActions)
141
+ ? (args.approvedActions as A2AApprovedAction[])
142
+ : undefined;
126
143
 
127
144
  if (!agentIdOrName) return "Error: --agent is required";
128
145
  if (!message) return "Error: --message is required";
@@ -320,6 +337,7 @@ export async function run(
320
337
  userEmail: callerEmail,
321
338
  orgDomain: callerOrgDomain,
322
339
  orgSecret: callerOrgSecret,
340
+ approvedActions,
323
341
  onUpdate: onRemotePollUpdate,
324
342
  ...(callTimeoutMs
325
343
  ? {
@@ -408,6 +426,7 @@ export async function run(
408
426
  userEmail: email,
409
427
  orgDomain: domain,
410
428
  orgSecret,
429
+ approvedActions,
411
430
  });
412
431
  const sanitized =
413
432
  formatDownstreamLlmCredentialFailure(agent.name, response) ?? response;
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import nodePath from "node:path";
2
3
 
3
4
  import {
@@ -19,7 +20,10 @@ import {
19
20
  isTrustedLocalRuntime,
20
21
  } from "../a2a/auth-policy.js";
21
22
  import { applyAgentTextEventToBuffer } from "../a2a/response-text.js";
22
- import { updateTaskStatusMessage } from "../a2a/task-store.js";
23
+ import {
24
+ createA2AApproval,
25
+ updateTaskStatusMessage,
26
+ } from "../a2a/task-store.js";
23
27
  import type { ActionHttpConfig } from "../action.js";
24
28
  import {
25
29
  canUpdateAgentAppModelDefaultSettings,
@@ -52,6 +56,7 @@ import {
52
56
  createProductionAgentHandler,
53
57
  actionsToEngineTools,
54
58
  executeAgentToolCall,
59
+ toolCallCacheKey,
55
60
  getActiveRunForThreadAsync,
56
61
  abortRunDurably,
57
62
  subscribeToRun,
@@ -1171,6 +1176,25 @@ export function createAgentChatPlugin(
1171
1176
  publicSkillsOnly: true,
1172
1177
  streaming: true,
1173
1178
  durableBackgroundRuns: options?.durableBackgroundRuns,
1179
+ executeApproval: async (approval) => {
1180
+ const result = await executeAgentToolCall({
1181
+ actions: mcpFullActions ?? allScripts,
1182
+ name: approval.tool,
1183
+ input: approval.input,
1184
+ callId: approval.callId,
1185
+ ownerEmail: approval.ownerEmail,
1186
+ orgId: approval.orgId ?? null,
1187
+ approvedToolCalls: [approval.approvalKey],
1188
+ });
1189
+ if (result.status === "approval_required") {
1190
+ return {
1191
+ status: "failed" as const,
1192
+ output:
1193
+ "The approved action was unexpectedly gated again and did not run.",
1194
+ };
1195
+ }
1196
+ return { status: result.status, output: result.output };
1197
+ },
1174
1198
  handler: async function* (message, context) {
1175
1199
  // Resolve the caller's identity for user-scoped data access.
1176
1200
  // Priority: A2A-JWT verified email (set by the A2A handler in
@@ -1495,6 +1519,9 @@ export function createAgentChatPlugin(
1495
1519
  // scope when a processor hop or alternate runner is involved.
1496
1520
  ownerEmail: userEmail,
1497
1521
  orgId: getRequestOrgId() ?? null,
1522
+ approvedToolCalls: context.approvedActions?.map((approved) =>
1523
+ toolCallCacheKey(approved.tool, approved.input),
1524
+ ),
1498
1525
  executionMode: "act",
1499
1526
  send: (event) => {
1500
1527
  a2aEvents.push(event);
@@ -1549,6 +1576,62 @@ export function createAgentChatPlugin(
1549
1576
  },
1550
1577
  );
1551
1578
 
1579
+ const approval = [...a2aEvents]
1580
+ .reverse()
1581
+ .find(
1582
+ (
1583
+ event,
1584
+ ): event is Extract<
1585
+ AgentChatEvent,
1586
+ { type: "approval_required" }
1587
+ > => event.type === "approval_required",
1588
+ );
1589
+ if (approval) {
1590
+ const pending = await createA2AApproval({
1591
+ taskId: context.taskId,
1592
+ ownerEmail: userEmail,
1593
+ orgId: getRequestOrgId() ?? null,
1594
+ tool: approval.tool,
1595
+ toolInput: approval.input,
1596
+ approvalKey: approval.approvalKey,
1597
+ callId: approval.toolCallId ?? crypto.randomUUID(),
1598
+ });
1599
+ const baseUrl = resolveArtifactBaseUrl(context.event);
1600
+ const approvalPath = `/_agent-native/a2a/approvals/${encodeURIComponent(pending.id)}`;
1601
+ const approvalUrl = baseUrl
1602
+ ? `${baseUrl}${approvalPath}`
1603
+ : approvalPath;
1604
+ yield {
1605
+ role: "agent" as const,
1606
+ metadata: {
1607
+ agentNativeTaskState: "input-required",
1608
+ agentNativeApproval: {
1609
+ id: pending.id,
1610
+ tool: approval.tool,
1611
+ url: approvalUrl,
1612
+ },
1613
+ },
1614
+ parts: [
1615
+ {
1616
+ type: "text" as const,
1617
+ text:
1618
+ `Human approval is required to run ${approval.tool}. ` +
1619
+ `Open ${approvalUrl} to review and approve this one-time action.`,
1620
+ },
1621
+ {
1622
+ type: "data" as const,
1623
+ data: {
1624
+ kind: "agent-native/approval-required",
1625
+ approvalId: pending.id,
1626
+ tool: approval.tool,
1627
+ approvalUrl,
1628
+ },
1629
+ },
1630
+ ],
1631
+ };
1632
+ return;
1633
+ }
1634
+
1552
1635
  const { responseText, finalText } = assembleA2AFinalResponse(
1553
1636
  a2aEvents,
1554
1637
  a2aToolResults,
@@ -5621,6 +5704,8 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
5621
5704
  "/_agent-native/agent-model-defaults",
5622
5705
  "/_agent-native/mcp",
5623
5706
  "/mcp",
5707
+ "/.well-known/agent-card.json",
5708
+ "/_agent-native/a2a",
5624
5709
  ],
5625
5710
  });
5626
5711
  };
@@ -147,6 +147,31 @@ for await (const update of client.stream({
147
147
  }
148
148
  ```
149
149
 
150
+ ### Carrying explicit chat authorization
151
+
152
+ When the authenticated caller has an exact consequential action that the user
153
+ explicitly authorized in the originating chat, pass the tool name and complete
154
+ input as `approvedActions`. The receiver accepts these grants only from a
155
+ JWT-verified user identity, converts each one to the same content-addressed key
156
+ as its local approval gate, and consumes it once:
157
+
158
+ ```ts
159
+ await client.send(message, {
160
+ async: true,
161
+ approvedActions: [
162
+ {
163
+ tool: "send-email",
164
+ input: { to, subject, body, attachments },
165
+ },
166
+ ],
167
+ });
168
+ ```
169
+
170
+ Never infer authorization from request prose or broaden the input. A changed
171
+ recipient, body, attachment, or tool produces a different key and follows the
172
+ receiver's normal approval-required path. Static API keys and unsigned callers
173
+ cannot carry these grants.
174
+
150
175
  ## JSON-RPC Methods
151
176
 
152
177
  | Method | Purpose | Auth required |
@@ -143,6 +143,65 @@ Notes:
143
143
  "extension unavailable" message instead of the content. Share the extension to
144
144
  the same audience as the dashboard so all viewers can see it.
145
145
 
146
+ ## Cloning An Extension-Backed Dashboard (e.g. per-customer copies)
147
+
148
+ When the user asks for a copy of an existing extension-backed dashboard for a
149
+ different customer/org (for example "make an Intuit version of the Roku usage
150
+ dashboard"), follow this playbook. Extension bodies are frequently tens of
151
+ thousands of characters. The reliable path is to read+transform+write the body
152
+ INSIDE `run-code` (where `workspaceRead` returns the full file) and then create
153
+ from that written file — never by pulling the body into chat context first or
154
+ re-typing it as a `content` argument.
155
+
156
+ 1. `get-sql-dashboard` with `includeConfig: true` on the source dashboard and
157
+ confirm the target panel is `chartType: "extension"`; grab its
158
+ `config.extensionId`.
159
+ 2. `get-extension` for that id with `forceContent: true` **exactly once**. Reuse
160
+ that body for the rest of the turn — a second same-run read intentionally
161
+ omits `content` and returns `contentOmitted` instead. That is not the content
162
+ disappearing; use the copy you already have. Do NOT try to re-fetch the body
163
+ with `run-code` (`appAction('get-extension')`) to page past a display
164
+ truncation — the same-run omit makes it return empty `content`, wasting turns.
165
+ If you need the full body again, read the workspace resource file (step 5) or
166
+ set `forceContent: true` on a single native `get-extension`.
167
+ 3. Change ONLY the small customer-specific static config (e.g. the
168
+ `ACCOUNT_USAGE_STATIC` block: company name, title, org-discovery filters,
169
+ messaging). Prefer a focused `update-extension` edit/patch over regenerating
170
+ the entire HTML.
171
+ 4. **Call `create-extension` / `update-extension` as native tools.** They are
172
+ mutating actions and are NOT callable from `run-code` / `appAction` (the
173
+ sandbox bridge only exposes read-only actions). Do not try to create or update
174
+ an extension from inside `run-code`.
175
+ 5. **If the source body already exists as a workspace/shared resource file**
176
+ (e.g. a pre-built `intuit-analytics-extension.html`), do the read AND the
177
+ customer swap in ONE `run-code` call, then create from the written file:
178
+ - Inside `run-code`: `const src = await workspaceRead('<source>.html')`
179
+ returns the WHOLE file (it auto-pages; there is no 50k cap here), do the
180
+ small string-replace on the static config block, then
181
+ `await workspaceWrite('<target>.html', modified)`.
182
+ - Then call `create-extension` (native) with
183
+ `contentFromWorkspaceFile: '<target>.html'` and leave `content` empty — the
184
+ server reads the full file verbatim.
185
+ Do NOT read the source body with the `resources` read tool (or `get-extension`)
186
+ first just to transform it: that display is capped and wastes a turn. And do
187
+ NOT re-emit an 80k+ char body as the `content` argument — it gets cut off
188
+ mid-stream. `contentFromAttachment` only sees files the user pasted into chat,
189
+ not workspace resources. `create-extension`/`update-extension` are mutating and
190
+ cannot run from `run-code`, so only the read+write+transform happens there.
191
+ 6. Finally `update-dashboard` to save a new dashboard embedding the new
192
+ extension panel (`chartType: "extension"`, `config.extensionId`), then
193
+ `navigate` to it.
194
+
195
+ ### Display truncation is cosmetic — do not chase the "missing" tail
196
+
197
+ A tool result ending in `...[truncated — full result was N chars; only first
198
+ 50,000 shown]` (from the `resources` read tool or `get-extension`) means only the
199
+ DISPLAYED text was capped. The file is intact. `run-code`'s `workspaceRead`
200
+ returns the full N chars, and `contentFromWorkspaceFile` hosts the full file.
201
+ Never read the same file twice or try to "page the rest" to recover the tail —
202
+ that is the single biggest source of wasted turns on clone requests. Decide to
203
+ clone, then go straight to the `run-code` read+transform+write path in step 5.
204
+
146
205
  ## Config Shape
147
206
 
148
207
  ```jsonc
@@ -14,10 +14,13 @@ import {
14
14
 
15
15
  const DASHBOARD_CONTEXT =
16
16
  "The user wants to create a new analytics dashboard. " +
17
- "REAL_DATA_REQUIRED: before saving or answering, run at least one real data-source query action; `data-source-status`, `list-data-dictionary`, `update-dashboard`, and dry-run validation do not count as data queries. " +
17
+ "TEMPLATE FIRST If the user names an existing dashboard as a template to clone/base this on, resolve its id first (use `list-sql-dashboards` if you only have a title), then call `get-sql-dashboard` with `includeConfig: true` immediately and inspect `panels[].chartType`. " +
18
+ 'If any panel is `chartType: "extension"`, this is an extension-backed dashboard: call `get-extension` for that panel\'s `config.extensionId`, clone/adapt it with `create-extension` (apply the requested customer/org filters), then save a new dashboard via `update-dashboard` that embeds the new extension panel (`chartType: "extension"`, `config.extensionId`). Do not rebuild an extension template as guessed SQL/BigQuery panels. ' +
19
+ "LARGE EXTENSION CLONE — Extension bodies can be very large (tens of thousands of characters). Call `get-extension` with `forceContent: true` exactly ONCE and reuse that body; a second same-run read intentionally omits `content` (you'll see `contentOmitted`), so don't treat that as the content being gone. Call `create-extension` / `update-extension` as NATIVE tools — they are mutating actions and cannot be invoked from `run-code`/`appAction`. For customer-specific clones, change only the small static config block (e.g. `ACCOUNT_USAGE_STATIC`) and prefer a focused `update-extension` edit over regenerating the whole HTML. Never shovel the full body through `run-code` or chat; if you stage it in a workspace scratch file, read it back with `workspaceRead` (which returns the whole file). " +
20
+ "REAL_DATA_REQUIRED: before presenting numbers or authoring new SQL that invents tables/columns/filters, run at least one real data-source query action; `data-source-status`, `list-data-dictionary`, `get-sql-dashboard`, `get-extension`, `update-dashboard`, `mutate-dashboard`, and dry-run validation do not count as data queries. It is OK to inspect a template, clone an extension shell, ask one clarifying question (org id / account filter), or report an exact unavailable/error result without running a data query, as long as you do not invent metrics. " +
18
21
  "The `demo` source is reserved for the built-in Node Exporter demo and does not satisfy REAL_DATA_REQUIRED unless the user explicitly asks to work on that demo dashboard. " +
19
22
  "If no source can answer, report the exact unavailable/error result instead of saving a dashboard with guessed schema or metrics. " +
20
- "Create a SQL-driven dashboard by calling the `update-dashboard` action with `dashboardId` and `config`. " +
23
+ "SQL PANELS — Only for native SQL dashboards (not template clones of an extension-backed dashboard): create a SQL-driven dashboard by calling the `update-dashboard` action with `dashboardId` and `config`. " +
21
24
  "The config shape is: { name: string, panels: [{ id, title, sql, source, chartType, width, tab?, config? }] }. " +
22
25
  "Each panel needs: id (unique string), title, sql (the query), source ('bigquery' | 'ga4' | 'amplitude' | 'first-party' | 'demo' | 'prometheus'), " +
23
26
  "chartType ('line' | 'area' | 'bar' | 'metric' | 'table' | 'pie'), width (1 or 2). " +