@agent-native/core 0.98.6 → 0.98.8

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 (88) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/external-agents.mdx +22 -12
  4. package/corpus/core/docs/content/locales/ar-SA/external-agents.mdx +8 -2
  5. package/corpus/core/docs/content/locales/de-DE/external-agents.mdx +8 -2
  6. package/corpus/core/docs/content/locales/es-ES/external-agents.mdx +8 -2
  7. package/corpus/core/docs/content/locales/fr-FR/external-agents.mdx +8 -2
  8. package/corpus/core/docs/content/locales/hi-IN/external-agents.mdx +8 -2
  9. package/corpus/core/docs/content/locales/ja-JP/external-agents.mdx +8 -2
  10. package/corpus/core/docs/content/locales/ko-KR/external-agents.mdx +8 -2
  11. package/corpus/core/docs/content/locales/pt-BR/external-agents.mdx +8 -2
  12. package/corpus/core/docs/content/locales/zh-CN/external-agents.mdx +8 -2
  13. package/corpus/core/docs/content/locales/zh-TW/external-agents.mdx +8 -2
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/a2a/handlers.ts +6 -5
  16. package/corpus/core/src/a2a/task-store.ts +63 -0
  17. package/corpus/core/src/client/session-replay.ts +90 -6
  18. package/corpus/core/src/integrations/adapters/slack.ts +110 -19
  19. package/corpus/core/src/integrations/identity.ts +103 -39
  20. package/corpus/core/src/integrations/pending-tasks-retry-job.ts +2 -1
  21. package/corpus/core/src/integrations/pending-tasks-store.ts +1 -1
  22. package/corpus/core/src/integrations/plugin.ts +215 -8
  23. package/corpus/core/src/integrations/types.ts +37 -0
  24. package/corpus/core/src/integrations/webhook-handler.ts +1 -0
  25. package/corpus/core/src/mcp/builtin-tools.ts +5 -2
  26. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +4 -1
  27. package/corpus/templates/analytics/AGENTS.md +9 -9
  28. package/corpus/templates/analytics/changelog/2026-07-12-analytics-uses-the-full-in-app-agent-for-multi-step-incident.md +6 -0
  29. package/corpus/templates/analytics/server/handlers/session-replay.ts +15 -9
  30. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +23 -5
  31. package/corpus/templates/analytics/server/lib/session-replay.ts +26 -5
  32. package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -1
  33. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +8 -5
  34. package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +8 -3
  35. package/corpus/templates/calendar/app/components/calendar/DeleteEventDialog.tsx +12 -2
  36. package/dist/a2a/handlers.d.ts.map +1 -1
  37. package/dist/a2a/handlers.js +6 -6
  38. package/dist/a2a/handlers.js.map +1 -1
  39. package/dist/a2a/task-store.d.ts +12 -0
  40. package/dist/a2a/task-store.d.ts.map +1 -1
  41. package/dist/a2a/task-store.js +54 -0
  42. package/dist/a2a/task-store.js.map +1 -1
  43. package/dist/client/session-replay.d.ts.map +1 -1
  44. package/dist/client/session-replay.js +71 -9
  45. package/dist/client/session-replay.js.map +1 -1
  46. package/dist/collab/routes.d.ts +1 -1
  47. package/dist/collab/struct-routes.d.ts +1 -1
  48. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  49. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  50. package/dist/integrations/adapters/slack.js +94 -11
  51. package/dist/integrations/adapters/slack.js.map +1 -1
  52. package/dist/integrations/identity.d.ts +19 -2
  53. package/dist/integrations/identity.d.ts.map +1 -1
  54. package/dist/integrations/identity.js +76 -34
  55. package/dist/integrations/identity.js.map +1 -1
  56. package/dist/integrations/pending-tasks-retry-job.d.ts.map +1 -1
  57. package/dist/integrations/pending-tasks-retry-job.js +2 -1
  58. package/dist/integrations/pending-tasks-retry-job.js.map +1 -1
  59. package/dist/integrations/pending-tasks-store.js +1 -1
  60. package/dist/integrations/pending-tasks-store.js.map +1 -1
  61. package/dist/integrations/plugin.d.ts.map +1 -1
  62. package/dist/integrations/plugin.js +165 -9
  63. package/dist/integrations/plugin.js.map +1 -1
  64. package/dist/integrations/types.d.ts +32 -0
  65. package/dist/integrations/types.d.ts.map +1 -1
  66. package/dist/integrations/types.js.map +1 -1
  67. package/dist/integrations/webhook-handler.js +1 -0
  68. package/dist/integrations/webhook-handler.js.map +1 -1
  69. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  70. package/dist/mcp/builtin-tools.js +5 -2
  71. package/dist/mcp/builtin-tools.js.map +1 -1
  72. package/dist/notifications/routes.d.ts +1 -1
  73. package/dist/progress/routes.d.ts +1 -1
  74. package/dist/resources/handlers.d.ts +3 -3
  75. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  76. package/dist/server/transcribe-voice.d.ts +1 -1
  77. package/docs/content/external-agents.mdx +22 -12
  78. package/docs/content/locales/ar-SA/external-agents.mdx +8 -2
  79. package/docs/content/locales/de-DE/external-agents.mdx +8 -2
  80. package/docs/content/locales/es-ES/external-agents.mdx +8 -2
  81. package/docs/content/locales/fr-FR/external-agents.mdx +8 -2
  82. package/docs/content/locales/hi-IN/external-agents.mdx +8 -2
  83. package/docs/content/locales/ja-JP/external-agents.mdx +8 -2
  84. package/docs/content/locales/ko-KR/external-agents.mdx +8 -2
  85. package/docs/content/locales/pt-BR/external-agents.mdx +8 -2
  86. package/docs/content/locales/zh-CN/external-agents.mdx +8 -2
  87. package/docs/content/locales/zh-TW/external-agents.mdx +8 -2
  88. package/package.json +1 -1
@@ -53,7 +53,10 @@ import {
53
53
  startGoogleDocsPoller,
54
54
  handlePushNotification,
55
55
  } from "./google-docs-poller.js";
56
- import { resolveDefaultIntegrationExecutionContext } from "./identity.js";
56
+ import {
57
+ IntegrationIdentityDeclinedError,
58
+ resolveDefaultIntegrationExecutionContext,
59
+ } from "./identity.js";
57
60
  import {
58
61
  disconnectIntegrationInstallation,
59
62
  listIntegrationInstallations,
@@ -77,6 +80,7 @@ import {
77
80
  claimPendingTask,
78
81
  getNextPendingTaskIdForThread,
79
82
  insertPendingTask,
83
+ isDuplicateEventError,
80
84
  MAX_PENDING_TASK_ATTEMPTS,
81
85
  markTaskCompleted,
82
86
  markTaskFailed,
@@ -137,6 +141,7 @@ import type {
137
141
  IntegrationsPluginOptions,
138
142
  IntegrationStatus,
139
143
  IntegrationExecutionContext,
144
+ IncomingMessage,
140
145
  } from "./types.js";
141
146
  import {
142
147
  listIntegrationUsageBudgets,
@@ -252,6 +257,28 @@ type IntegrationCredentialContext = {
252
257
 
253
258
  const REMOTE_DEVICE_ONLINE_MS = 90_000;
254
259
 
260
+ // One decline reply per sender + decline reason per window: during a Slack
261
+ // API outage every message would otherwise get another identical "try again"
262
+ // reply. Short enough that a persistent condition still reminds the sender.
263
+ const DECLINE_NOTICE_DEDUPE_TTL_MS = 5 * 60 * 1_000;
264
+ const SYSTEM_NOTICE_DEDUPE_TTL_MS = 24 * 60 * 60 * 1_000;
265
+
266
+ type IntegrationSystemNoticeTaskPayload = {
267
+ kind: "system-notice";
268
+ incoming: IncomingMessage;
269
+ text: string;
270
+ dedupeKey?: string;
271
+ dedupeTtlMs?: number;
272
+ };
273
+
274
+ function systemNoticeEventKey(
275
+ dedupeKey: string,
276
+ ttlMs: number,
277
+ now = Date.now(),
278
+ ): string {
279
+ return `system-notice:${dedupeKey}:${Math.floor(now / ttlMs)}`;
280
+ }
281
+
255
282
  export async function enqueueRemoteCommand(
256
283
  envelope: RemoteCodeCommandEnvelope,
257
284
  ): Promise<Record<string, unknown>> {
@@ -629,6 +656,66 @@ export function createIntegrationsPlugin(
629
656
  const h3 = getH3App(nitroApp);
630
657
  const P = `${FRAMEWORK_ROUTE_PREFIX}/integrations`;
631
658
 
659
+ async function enqueueSystemNotice(
660
+ event: any,
661
+ incoming: IncomingMessage,
662
+ text: string,
663
+ opts?: { dedupeKey?: string; dedupeTtlMs?: number },
664
+ ): Promise<void> {
665
+ if (!text.trim()) return;
666
+ const taskId = `notice-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
667
+ const dedupeTtlMs = Math.max(
668
+ 1,
669
+ opts?.dedupeTtlMs ?? SYSTEM_NOTICE_DEDUPE_TTL_MS,
670
+ );
671
+ const noticeThreadId = `system-notice:${taskId}`;
672
+ const payload: IntegrationSystemNoticeTaskPayload = {
673
+ kind: "system-notice",
674
+ incoming,
675
+ text,
676
+ ...(opts?.dedupeKey ? { dedupeKey: opts.dedupeKey } : {}),
677
+ ...(opts?.dedupeTtlMs ? { dedupeTtlMs: opts.dedupeTtlMs } : {}),
678
+ };
679
+ try {
680
+ await insertPendingTask({
681
+ id: taskId,
682
+ platform: incoming.platform,
683
+ // System notices are auxiliary delivery work, not the user's agent
684
+ // run. Give each notice its own queue lane so a retrying notice cannot
685
+ // block the real message task for this Slack/Telegram thread.
686
+ externalThreadId: noticeThreadId,
687
+ payload: JSON.stringify(payload),
688
+ ownerEmail: `integration@${incoming.platform}`,
689
+ externalEventKey: opts?.dedupeKey
690
+ ? systemNoticeEventKey(opts.dedupeKey, dedupeTtlMs)
691
+ : undefined,
692
+ });
693
+ } catch (err) {
694
+ if (isDuplicateEventError(err)) return;
695
+ throw err;
696
+ }
697
+
698
+ // The SQL row is the durable source of truth. This best-effort self-call
699
+ // only reduces latency; the recurring pending-task sweep retries a row
700
+ // if the serverless host freezes this webhook execution immediately.
701
+ let token: string | undefined;
702
+ try {
703
+ token = signInternalToken(taskId);
704
+ } catch (err) {
705
+ if (process.env.NODE_ENV === "production") throw err;
706
+ }
707
+ void fetch(`${getBaseUrl(event)}${P}/process-task`, {
708
+ method: "POST",
709
+ headers: {
710
+ "Content-Type": "application/json",
711
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
712
+ },
713
+ body: JSON.stringify({ taskId }),
714
+ }).catch((err) => {
715
+ console.warn("[integrations] System notice dispatch failed:", err);
716
+ });
717
+ }
718
+
632
719
  async function requireSession(event: any): Promise<boolean> {
633
720
  const session = await getSession(event).catch(() => null);
634
721
  if (session?.email) return true;
@@ -1542,6 +1629,34 @@ export function createIntegrationsPlugin(
1542
1629
  isIntegrationCaller: true,
1543
1630
  },
1544
1631
  async () => {
1632
+ const taskPayload = JSON.parse(task.payload) as
1633
+ | IntegrationSystemNoticeTaskPayload
1634
+ | { kind?: undefined };
1635
+ if (taskPayload.kind === "system-notice") {
1636
+ if (!adapter.sendSystemNotice) {
1637
+ throw new Error(
1638
+ `Platform ${task.platform} cannot deliver system notices`,
1639
+ );
1640
+ }
1641
+ const config = await getIntegrationConfig(task.platform);
1642
+ const credentialContext =
1643
+ await credentialContextForIntegrationConfig(config);
1644
+ await withCredentialContext(credentialContext, () =>
1645
+ adapter.sendSystemNotice!(
1646
+ taskPayload.incoming,
1647
+ taskPayload.text,
1648
+ {
1649
+ ...(taskPayload.dedupeKey
1650
+ ? { dedupeKey: taskPayload.dedupeKey }
1651
+ : {}),
1652
+ ...(taskPayload.dedupeTtlMs
1653
+ ? { dedupeTtlMs: taskPayload.dedupeTtlMs }
1654
+ : {}),
1655
+ },
1656
+ ),
1657
+ );
1658
+ return;
1659
+ }
1545
1660
  const resources = await loadResourcesForPrompt(
1546
1661
  task.ownerEmail,
1547
1662
  true,
@@ -2370,7 +2485,8 @@ export function createIntegrationsPlugin(
2370
2485
  null;
2371
2486
  if (
2372
2487
  incoming.platform === "slack" &&
2373
- incoming.conversationType === "dm"
2488
+ incoming.conversationType === "dm" &&
2489
+ !options?.resolveExecutionContext
2374
2490
  ) {
2375
2491
  try {
2376
2492
  defaultExecutionContext = await withCredentialContext(
@@ -2378,17 +2494,48 @@ export function createIntegrationsPlugin(
2378
2494
  () => resolveDefaultIntegrationExecutionContext(incoming!),
2379
2495
  );
2380
2496
  } catch (err) {
2381
- // An app-specific resolver may intentionally support a legacy
2382
- // Slack setup. When no override exists, the branch below fails
2383
- // closed instead of falling back to a service principal.
2384
- if (!options?.resolveExecutionContext && !options?.resolveOwner) {
2497
+ // The legacy owner-only resolver predates org-bound identities
2498
+ // and must not turn a rejected Slack DM into an authenticated
2499
+ // owner run. Custom resolveExecutionContext is checked above and
2500
+ // skips this default ladder entirely so apps can fully own auth
2501
+ // without framework membership checks or identity side effects.
2502
+ const declined =
2503
+ err instanceof IntegrationIdentityDeclinedError ? err : null;
2504
+ if (declined) {
2505
+ console.warn(
2506
+ `[integrations] default Slack DM identity declined message:`,
2507
+ declined.message,
2508
+ );
2509
+ if (adapter.sendSystemNotice) {
2510
+ try {
2511
+ await enqueueSystemNotice(
2512
+ event,
2513
+ incoming!,
2514
+ declined.userFacingMessage,
2515
+ {
2516
+ dedupeKey: `decline:${incoming!.tenantId ?? "unknown"}:${incoming!.senderId ?? "unknown"}:${declined.reason}`,
2517
+ dedupeTtlMs: DECLINE_NOTICE_DEDUPE_TTL_MS,
2518
+ },
2519
+ );
2520
+ } catch (noticeErr) {
2521
+ console.warn(
2522
+ `[integrations] could not persist decline notice:`,
2523
+ noticeErr instanceof Error
2524
+ ? noticeErr.message
2525
+ : noticeErr,
2526
+ );
2527
+ setResponseStatus(event, 500);
2528
+ return { error: "notice enqueue failed" };
2529
+ }
2530
+ }
2531
+ } else {
2385
2532
  console.error(
2386
2533
  `[integrations] default Slack DM identity denied message:`,
2387
2534
  err,
2388
2535
  );
2389
- setResponseStatus(event, 200);
2390
- return "ok";
2391
2536
  }
2537
+ setResponseStatus(event, 200);
2538
+ return "ok";
2392
2539
  }
2393
2540
  }
2394
2541
  let executionContext: IntegrationExecutionContext = {
@@ -2413,6 +2560,66 @@ export function createIntegrationsPlugin(
2413
2560
  }
2414
2561
  } else if (defaultExecutionContext) {
2415
2562
  executionContext = defaultExecutionContext;
2563
+ if (defaultExecutionContext.anonymousMember) {
2564
+ if (!options?.allowAnonymousOrgScopedSlackDm) {
2565
+ const senderEmail =
2566
+ typeof incoming.senderEmail === "string" &&
2567
+ incoming.senderEmail.trim()
2568
+ ? incoming.senderEmail.trim()
2569
+ : null;
2570
+ const noticeText = senderEmail
2571
+ ? `I couldn't match your Slack account to an organization member, so I can't run this request. Ask an organization admin to add ${senderEmail}, then try again.`
2572
+ : "I couldn't verify your Slack account email, so I can't run this request. Ask an organization admin to reconnect Slack with the users:read.email scope, then try again.";
2573
+ if (adapter.sendSystemNotice) {
2574
+ try {
2575
+ await enqueueSystemNotice(event, incoming, noticeText, {
2576
+ dedupeKey: `anonymous-tier-disabled:${incoming.tenantId ?? "unknown"}:${incoming.senderId ?? "unknown"}`,
2577
+ });
2578
+ } catch (noticeErr) {
2579
+ console.warn(
2580
+ `[integrations] could not persist unlinked-member notice:`,
2581
+ noticeErr instanceof Error
2582
+ ? noticeErr.message
2583
+ : noticeErr,
2584
+ );
2585
+ setResponseStatus(event, 500);
2586
+ return { error: "notice enqueue failed" };
2587
+ }
2588
+ }
2589
+ setResponseStatus(event, 200);
2590
+ return "ok";
2591
+ }
2592
+ // The anonymous tier must never be silent. (1) The agent run
2593
+ // can tell: the note rides the serialized `incoming` into the
2594
+ // queued task and surfaces via <integration-context>.
2595
+ incoming.identityNote =
2596
+ "Caller is an unlinked Slack workspace member running with organization-wide visibility only; personal or privately-shared data is not accessible. They can get personal access by having an admin add their Slack email to the organization (or by reconnecting Slack with the users:read.email scope).";
2597
+ // (2) The sender gets a one-time heads-up through the same
2598
+ // durable SQL queue as agent work. The self-dispatch is only a
2599
+ // latency optimization; the retry sweep guarantees delivery.
2600
+ if (adapter.sendSystemNotice) {
2601
+ const senderEmail =
2602
+ typeof incoming.senderEmail === "string" &&
2603
+ incoming.senderEmail.trim()
2604
+ ? incoming.senderEmail.trim()
2605
+ : null;
2606
+ const noticeText = senderEmail
2607
+ ? `Heads up: I couldn't match your Slack account to an organization member, so I can only use org-wide data. Ask an admin to add ${senderEmail} to the organization for personal access.`
2608
+ : "Heads up: I couldn't verify your Slack account's email, so I can only use org-wide data. Ask an admin to update the Slack connection with the users:read.email scope for personal access.";
2609
+ try {
2610
+ await enqueueSystemNotice(event, incoming, noticeText, {
2611
+ dedupeKey: `anonymous-tier:${incoming.tenantId ?? "unknown"}:${incoming.senderId ?? "unknown"}`,
2612
+ });
2613
+ } catch (noticeErr) {
2614
+ console.warn(
2615
+ `[integrations] could not persist anonymous-tier notice:`,
2616
+ noticeErr instanceof Error ? noticeErr.message : noticeErr,
2617
+ );
2618
+ setResponseStatus(event, 500);
2619
+ return { error: "notice enqueue failed" };
2620
+ }
2621
+ }
2622
+ }
2416
2623
  } else if (options?.resolveOwner) {
2417
2624
  try {
2418
2625
  executionContext.ownerEmail = await withCredentialContext(
@@ -104,6 +104,13 @@ export interface IncomingMessage {
104
104
  targetAgent?: string;
105
105
  instruction: string;
106
106
  };
107
+ /**
108
+ * Trusted app-side note about the caller's identity/visibility tier, set
109
+ * after execution-context resolution (e.g. an anonymous org-scoped Slack
110
+ * member). Surfaced to the agent as integration context. Adapters must not
111
+ * copy this from user-controlled webhook payload fields.
112
+ */
113
+ identityNote?: string;
107
114
  /** Provider-native message/activity reference for contextual replies. */
108
115
  replyRef?: string;
109
116
  /** Message timestamp (epoch ms) */
@@ -311,6 +318,23 @@ export interface PlatformAdapter {
311
318
  opts?: { placeholderRef?: string },
312
319
  ): Promise<void>;
313
320
 
321
+ /**
322
+ * Send a short best-effort system notice to the conversation the incoming
323
+ * message arrived on (polite identity declines, one-time access guidance).
324
+ * Bypasses agent formatting. When `dedupeKey` is provided, the adapter may
325
+ * drop the notice if the same key was sent recently. Callers must treat
326
+ * failures as non-fatal.
327
+ */
328
+ sendSystemNotice?(
329
+ incoming: IncomingMessage,
330
+ text: string,
331
+ opts?: {
332
+ dedupeKey?: string;
333
+ /** Dedupe window for `dedupeKey`. Adapters pick a default when omitted. */
334
+ dedupeTtlMs?: number;
335
+ },
336
+ ): Promise<void>;
337
+
314
338
  /**
315
339
  * Optionally post a "working on it…" placeholder message immediately when a
316
340
  * webhook arrives, before the agent loop runs. Adapters that support
@@ -414,6 +438,13 @@ export interface IntegrationsPluginOptions {
414
438
  resolveExecutionContext?: (
415
439
  incoming: IncomingMessage,
416
440
  ) => IntegrationExecutionContext | Promise<IntegrationExecutionContext>;
441
+ /**
442
+ * Explicitly allow an unlinked, verified Slack workspace member to run a DM
443
+ * with the installation organization's shared/service visibility. Disabled
444
+ * by default: DM identity resolution fails closed unless an app deliberately
445
+ * accepts this wider access tier.
446
+ */
447
+ allowAnonymousOrgScopedSlackDm?: boolean;
417
448
  /**
418
449
  * Optional preprocessor for inbound platform messages. Can intercept special
419
450
  * commands (such as `/link`) before the agent loop runs.
@@ -436,4 +467,10 @@ export interface IntegrationExecutionContext {
436
467
  principalType: "user" | "service";
437
468
  installationId?: string;
438
469
  scopeId?: string;
470
+ /**
471
+ * True when a hydrated full workspace member could not be matched to an
472
+ * organization member and runs with the anonymous org-scoped service
473
+ * principal (org-wide visibility only, nothing user-private).
474
+ */
475
+ anonymousMember?: boolean;
439
476
  }
@@ -745,6 +745,7 @@ async function processIncomingMessage(
745
745
  incoming.senderName ? `Sender name: ${incoming.senderName}` : null,
746
746
  incoming.senderEmail ? `Sender email: ${incoming.senderEmail}` : null,
747
747
  incoming.senderId ? `Sender ID: ${incoming.senderId}` : null,
748
+ incoming.identityNote ? `Caller identity: ${incoming.identityNote}` : null,
748
749
  incoming.sourceUrl ? `Source thread: ${incoming.sourceUrl}` : null,
749
750
  incoming.routingHint?.targetAgent
750
751
  ? `Required target agent: ${incoming.routingHint.targetAgent}`
@@ -858,8 +858,11 @@ function askAppTool(
858
858
  return {
859
859
  tool: tool(
860
860
  "Send a natural-language message to an app's AI agent and get its " +
861
- "response. Use for complex, multi-step tasks needing the agent's " +
862
- "reasoning and full app context. In a single-app project the 'app' " +
861
+ "response. Use this first for natural-language investigation, " +
862
+ "diagnosis, multi-step work, and changes; it runs with the app's " +
863
+ "full skills, instructions, tools, and context. Use direct action " +
864
+ "tools only for a known, bounded read or simple UI handoff. In a " +
865
+ "single-app project the 'app' " +
863
866
  "param is optional (defaults to this app). When 'app' names a " +
864
867
  "different workspace app it is routed there over A2A; the result's " +
865
868
  "'routedVia' field reports whether it ran cross-app or locally. " +
@@ -164,7 +164,10 @@ agent answers about browser recordings in the Analytics template.
164
164
  reintroducing a clamp) — as regression guards against reintroducing any
165
165
  viewport "recovery" or pointer-projection heuristic. Do not change their
166
166
  expectations merely to bless a new sanitizer or clamp; validate the affected
167
- replay in a browser first.
167
+ replay in a browser first. An interim clamp for the exact 3,189x885 pair was
168
+ also deleted once the view-time redaction root cause was proven; the earlier
169
+ 3,000-3,999px band was rejected because it also catches real 3440px-wide
170
+ displays. Neither the exact exception nor the band belongs in the player.
168
171
  - The event timeline soft-highlights the active marker, auto-scrolls it into
169
172
  view (pausing briefly after manual scroll), and supports search. It appears
170
173
  beside the player from ~880px content width upward.
@@ -124,15 +124,15 @@ details live in `.agents/skills/`.
124
124
  `userId`/email parameter (e.g. `userId: "user@example.com"`) or recording/
125
125
  issue id — never by matching emails read back from previously displayed
126
126
  output.
127
- - Analytics opts into the framework's authenticated-read connector policy, so
128
- every Analytics action explicitly marked GET + read-only +
129
- `publicAgent.requiresAuth` is directly callable by a signed-in external
130
- agent. This includes the incident reads above plus safe dashboard/analysis
131
- reads. Generic core `db-schema` / `db-query` remain in-app agent tools and
132
- are not automatically exposed because broad SQL/schema access is too
133
- powerful to infer from read-only metadata. Writes remain `ask_app`-only.
134
- Use the explicit catalog and `denyActions` policy for any unusually
135
- sensitive read instead of exposing an unannotated action.
127
+ - Analytics keeps its direct MCP surface explicitly curated, so external
128
+ agents should use `ask_app` for multi-step investigation and changes. The
129
+ six incident reads above are bounded, user/org-scoped fallback tools for
130
+ callers that already know which lookup they need. Generic core
131
+ `db-schema` / `db-query` remain in-app agent tools and are not exposed
132
+ directly because broad SQL/schema access is too powerful to infer from
133
+ read-only metadata. Writes remain `ask_app`-only. Use the explicit catalog
134
+ and `denyActions` policy for any unusually sensitive read instead of
135
+ exposing an unannotated action.
136
136
  - `/agents` is the Analytics home for admin surfaces. The default Monitoring
137
137
  view embeds the shared observability dashboard for traces, conversations,
138
138
  evals, experiments, and feedback. `/agents?view=dashboards` shows the
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-07-12
4
+ ---
5
+
6
+ Analytics uses the full in-app agent for multi-step incident investigations, with direct read tools available for focused lookups.
@@ -12,8 +12,8 @@ import {
12
12
 
13
13
  import { runApiHandlerWithContext } from "../lib/credentials";
14
14
  import {
15
+ resolveSessionReplayAgentAccess,
15
16
  SESSION_REPLAY_AGENT_ACCESS_PARAM,
16
- verifySessionReplayAgentAccess,
17
17
  } from "../lib/session-replay-agent-context.js";
18
18
  import {
19
19
  getSessionReplayManifest,
@@ -236,12 +236,13 @@ function replayChunkSeqsFromQuery(query: Record<string, unknown>): number[] {
236
236
  function verifyAgentReplayAccessToken(
237
237
  event: any,
238
238
  recordingId: string,
239
- ): string | null {
239
+ ): { token: string; viewerEmail: string } | null | false {
240
240
  const token = readAgentReplayAccessToken(event);
241
241
  if (!token) return null;
242
- if (verifySessionReplayAgentAccess(recordingId, token)) return token;
242
+ const access = resolveSessionReplayAgentAccess(recordingId, token);
243
+ if (access) return { token, viewerEmail: access.viewerEmail };
243
244
  setResponseStatus(event, 401);
244
- return "";
245
+ return false;
245
246
  }
246
247
 
247
248
  function listFiltersFromQuery(
@@ -371,13 +372,16 @@ export const handleSessionReplayManifest = defineEventHandler(async (event) => {
371
372
  }
372
373
 
373
374
  const agentAccessToken = verifyAgentReplayAccessToken(event, recordingId);
374
- if (agentAccessToken === "") {
375
+ if (agentAccessToken === false) {
375
376
  return { error: "Invalid or expired agent access" };
376
377
  }
377
378
  if (agentAccessToken) {
378
379
  try {
379
380
  applyAgentReplayReadHeaders(event);
380
- const manifest = await getSessionReplayTokenizedManifest(recordingId);
381
+ const manifest = await getSessionReplayTokenizedManifest(
382
+ recordingId,
383
+ agentAccessToken.viewerEmail,
384
+ );
381
385
  return {
382
386
  ...manifest,
383
387
  chunks: manifest.chunks.map((chunk) => ({
@@ -385,7 +389,7 @@ export const handleSessionReplayManifest = defineEventHandler(async (event) => {
385
389
  bytesPath: appendQueryParam(
386
390
  chunk.bytesPath,
387
391
  SESSION_REPLAY_AGENT_ACCESS_PARAM,
388
- agentAccessToken,
392
+ agentAccessToken.token,
389
393
  ),
390
394
  })),
391
395
  };
@@ -419,7 +423,7 @@ export const handleSessionReplayChunkBytes = defineEventHandler(
419
423
  }
420
424
 
421
425
  const agentAccessToken = verifyAgentReplayAccessToken(event, recordingId);
422
- if (agentAccessToken === "") {
426
+ if (agentAccessToken === false) {
423
427
  return { error: "Invalid or expired agent access" };
424
428
  }
425
429
  if (agentAccessToken) {
@@ -427,6 +431,7 @@ export const handleSessionReplayChunkBytes = defineEventHandler(
427
431
  const result = await readSessionReplayTokenizedChunkBytes(
428
432
  recordingId,
429
433
  seq,
434
+ agentAccessToken.viewerEmail,
430
435
  );
431
436
  applyAgentReplayReadHeaders(event);
432
437
  setResponseHeader(event, "Content-Type", "application/json");
@@ -472,7 +477,7 @@ export const handleSessionReplayChunkBatch = defineEventHandler(
472
477
  const seqs = replayChunkSeqsFromQuery(getQuery(event));
473
478
 
474
479
  const agentAccessToken = verifyAgentReplayAccessToken(event, recordingId);
475
- if (agentAccessToken === "") {
480
+ if (agentAccessToken === false) {
476
481
  return { error: "Invalid or expired agent access" };
477
482
  }
478
483
  if (agentAccessToken) {
@@ -480,6 +485,7 @@ export const handleSessionReplayChunkBatch = defineEventHandler(
480
485
  const result = await readSessionReplayTokenizedChunkBatch(
481
486
  recordingId,
482
487
  seqs,
488
+ agentAccessToken.viewerEmail,
483
489
  );
484
490
  applyAgentReplayReadHeaders(event);
485
491
  setResponseHeader(event, "Content-Type", "application/json");
@@ -651,10 +651,22 @@ export function verifySessionReplayAgentAccess(
651
651
  recordingId: string,
652
652
  token: string,
653
653
  ): boolean {
654
- return verifyScopedAgentAccessToken(token, {
654
+ return resolveSessionReplayAgentAccess(recordingId, token) !== null;
655
+ }
656
+
657
+ export function resolveSessionReplayAgentAccess(
658
+ recordingId: string,
659
+ token: string,
660
+ ): { viewerEmail: string } | null {
661
+ const result = verifyScopedAgentAccessToken(token, {
655
662
  resourceKind: SESSION_REPLAY_AGENT_ACCESS_TOKEN_PREFIX,
656
663
  resourceId: recordingId,
657
- }).ok;
664
+ });
665
+ // Replay grants are always minted by an authenticated viewer. Fail closed
666
+ // for grants without that signed identity: demo-mode policy is viewer-scoped,
667
+ // so guessing from the ambient request could expose identities to agents.
668
+ if (!result.ok || !result.viewerEmail) return null;
669
+ return { viewerEmail: result.viewerEmail };
658
670
  }
659
671
 
660
672
  export async function createSessionReplayAgentLink({
@@ -707,14 +719,18 @@ export async function buildSessionReplayAgentContext({
707
719
  origin?: string;
708
720
  includeTimeline?: boolean;
709
721
  }) {
710
- if (!verifySessionReplayAgentAccess(recordingId, token)) {
722
+ const access = resolveSessionReplayAgentAccess(recordingId, token);
723
+ if (!access) {
711
724
  const error = Object.assign(new Error("Invalid or expired agent access"), {
712
725
  statusCode: 401,
713
726
  });
714
727
  throw error;
715
728
  }
716
729
 
717
- const recording = await getSessionReplayTokenizedSummary(recordingId);
730
+ const recording = await getSessionReplayTokenizedSummary(
731
+ recordingId,
732
+ access.viewerEmail,
733
+ );
718
734
  const resolvedOrigin = appOrigin(origin);
719
735
  const basePath = appBasePath();
720
736
  const contextUrl = buildAgentAccessApiUrl({
@@ -747,7 +763,9 @@ export async function buildSessionReplayAgentContext({
747
763
  });
748
764
 
749
765
  const eventsResponse = includeTimeline
750
- ? await getSessionReplayTokenizedEvents(recording.id, { limit: 10000 })
766
+ ? await getSessionReplayTokenizedEvents(recording.id, access.viewerEmail, {
767
+ limit: 10000,
768
+ })
751
769
  : null;
752
770
  const events =
753
771
  eventsResponse?.chunks.flatMap((chunk) =>
@@ -1786,8 +1786,13 @@ export async function getSessionReplaySummary(
1786
1786
 
1787
1787
  export async function getSessionReplayTokenizedSummary(
1788
1788
  recordingId: string,
1789
+ viewerEmail: string,
1789
1790
  ): Promise<SessionRecordingSummary> {
1790
- const demoMode = await isSessionDemoModeEnabled(getRequestUserEmail());
1791
+ // Tokenized reads often run without an authenticated request session. Use
1792
+ // the viewer identity embedded in the signed, recording-scoped grant so a
1793
+ // link minted while demo mode is enabled cannot reveal real identities when
1794
+ // it is opened in a signed-out browser or by an external agent.
1795
+ const demoMode = await isSessionDemoModeEnabled(viewerEmail);
1791
1796
  const db = getDb() as any;
1792
1797
  // guard:allow-unscoped -- called only after verifySessionReplayAgentAccess(recordingId, token) verifies a signed, recording-scoped agent_access token.
1793
1798
  const [row] = await db
@@ -1847,6 +1852,7 @@ export async function getSessionReplayManifest(
1847
1852
 
1848
1853
  export async function getSessionReplayTokenizedManifest(
1849
1854
  recordingId: string,
1855
+ viewerEmail: string,
1850
1856
  ): Promise<{
1851
1857
  recording: AgentSessionRecordingSummary;
1852
1858
  chunks: Array<{
@@ -1859,7 +1865,10 @@ export async function getSessionReplayTokenizedManifest(
1859
1865
  bytesPath: string;
1860
1866
  }>;
1861
1867
  }> {
1862
- const recording = await getSessionReplayTokenizedSummary(recordingId);
1868
+ const recording = await getSessionReplayTokenizedSummary(
1869
+ recordingId,
1870
+ viewerEmail,
1871
+ );
1863
1872
  const manifest = await getSessionReplayManifestForRecording(recording);
1864
1873
  return {
1865
1874
  ...manifest,
@@ -1923,6 +1932,7 @@ export async function readSessionReplayChunkBytes(
1923
1932
  export async function readSessionReplayTokenizedChunkBytes(
1924
1933
  recordingId: string,
1925
1934
  seq: number,
1935
+ viewerEmail: string,
1926
1936
  ): Promise<{
1927
1937
  recording: AgentSessionRecordingSummary;
1928
1938
  seq: number;
@@ -1930,7 +1940,10 @@ export async function readSessionReplayTokenizedChunkBytes(
1930
1940
  /** Decompressed replay-chunk JSON text (a serialized rrweb events array). */
1931
1941
  json: string;
1932
1942
  }> {
1933
- const recording = await getSessionReplayTokenizedSummary(recordingId);
1943
+ const recording = await getSessionReplayTokenizedSummary(
1944
+ recordingId,
1945
+ viewerEmail,
1946
+ );
1934
1947
  const chunk = await readSessionReplayChunkBytesForRecording(recording, seq);
1935
1948
  return {
1936
1949
  ...chunk,
@@ -2175,8 +2188,12 @@ export async function readSessionReplayChunkBatch(
2175
2188
  export async function readSessionReplayTokenizedChunkBatch(
2176
2189
  recordingId: string,
2177
2190
  seqs: number[],
2191
+ viewerEmail: string,
2178
2192
  ): Promise<SessionReplayChunkBatchResult> {
2179
- const recording = await getSessionReplayTokenizedSummary(recordingId);
2193
+ const recording = await getSessionReplayTokenizedSummary(
2194
+ recordingId,
2195
+ viewerEmail,
2196
+ );
2180
2197
  return readSessionReplayChunkBatchForRecording(recording, seqs);
2181
2198
  }
2182
2199
 
@@ -2204,6 +2221,7 @@ export async function getSessionReplayEvents(
2204
2221
 
2205
2222
  export async function getSessionReplayTokenizedEvents(
2206
2223
  recordingId: string,
2224
+ viewerEmail: string,
2207
2225
  options: SessionReplayEventReadOptions = {},
2208
2226
  ): Promise<{
2209
2227
  recording: AgentSessionRecordingSummary;
@@ -2219,7 +2237,10 @@ export async function getSessionReplayTokenizedEvents(
2219
2237
  truncated: boolean;
2220
2238
  unavailableChunks: number;
2221
2239
  }> {
2222
- const recording = await getSessionReplayTokenizedSummary(recordingId);
2240
+ const recording = await getSessionReplayTokenizedSummary(
2241
+ recordingId,
2242
+ viewerEmail,
2243
+ );
2223
2244
  const result = await getSessionReplayEventsForRecording(recording, options);
2224
2245
  return {
2225
2246
  ...result,
@@ -259,7 +259,10 @@ export default createAgentChatPlugin({
259
259
  codeExecution: { production: "sandboxed" },
260
260
  connectorCatalog: [...ANALYTICS_CONNECTOR_CATALOG],
261
261
  externalAgents: {
262
- authenticatedReads: "auto",
262
+ // Keep the direct MCP surface deliberately curated. External agents
263
+ // should use ask_app for multi-step investigation; these six actions are
264
+ // bounded fallback reads for callers that already know what they need.
265
+ authenticatedReads: "off",
263
266
  writes: "ask_app_only",
264
267
  },
265
268
  resolveOrgId: async (event) => {