@agent-native/core 0.84.21 → 0.84.22

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 (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +27 -4
  5. package/corpus/core/src/client/guided-questions.tsx +11 -6
  6. package/corpus/core/src/client/tool-display.ts +8 -0
  7. package/corpus/core/src/file-upload/builder.ts +7 -2
  8. package/corpus/core/src/notifications/channels.ts +235 -38
  9. package/corpus/core/src/server/analytics.ts +32 -6
  10. package/corpus/core/src/server/ssr-handler.ts +31 -12
  11. package/corpus/templates/analytics/AGENTS.md +21 -0
  12. package/corpus/templates/analytics/README.md +35 -0
  13. package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
  14. package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
  15. package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
  16. package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
  17. package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
  18. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
  19. package/corpus/templates/analytics/netlify.toml +3 -0
  20. package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +109 -0
  21. package/corpus/templates/analytics/server/db/schema.ts +55 -0
  22. package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
  23. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +71 -0
  24. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +734 -0
  25. package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
  26. package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
  27. package/corpus/templates/analytics/server/plugins/db.ts +100 -0
  28. package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
  29. package/corpus/templates/assets/README.md +30 -8
  30. package/corpus/templates/brain/README.md +20 -552
  31. package/corpus/templates/calendar/README.md +22 -28
  32. package/corpus/templates/chat/README.md +32 -0
  33. package/corpus/templates/clips/README.md +37 -0
  34. package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
  35. package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
  36. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +41 -1
  37. package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
  38. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  39. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
  40. package/corpus/templates/content/AGENTS.md +7 -3
  41. package/corpus/templates/content/README.md +22 -34
  42. package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
  43. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
  44. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
  45. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
  46. package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
  47. package/corpus/templates/content/app/i18n-data.ts +278 -40
  48. package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
  49. package/corpus/templates/content/shared/api.ts +1 -0
  50. package/corpus/templates/design/AGENTS.md +7 -6
  51. package/corpus/templates/design/README.md +33 -0
  52. package/corpus/templates/design/actions/delete-file.ts +86 -10
  53. package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
  54. package/corpus/templates/design/actions/present-design-variants.ts +11 -13
  55. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  56. package/corpus/templates/design/app/pages/DesignEditor.tsx +2 -2
  57. package/corpus/templates/dispatch/README.md +34 -0
  58. package/corpus/templates/forms/README.md +34 -0
  59. package/corpus/templates/macros/README.md +28 -0
  60. package/corpus/templates/mail/README.md +35 -0
  61. package/corpus/templates/plan/README.md +30 -118
  62. package/corpus/templates/slides/README.md +34 -0
  63. package/corpus/templates/videos/README.md +22 -201
  64. package/dist/client/AssistantChat.d.ts.map +1 -1
  65. package/dist/client/AssistantChat.js +28 -4
  66. package/dist/client/AssistantChat.js.map +1 -1
  67. package/dist/client/guided-questions.d.ts.map +1 -1
  68. package/dist/client/guided-questions.js +10 -6
  69. package/dist/client/guided-questions.js.map +1 -1
  70. package/dist/client/tool-display.d.ts.map +1 -1
  71. package/dist/client/tool-display.js +8 -0
  72. package/dist/client/tool-display.js.map +1 -1
  73. package/dist/collab/awareness.d.ts +2 -2
  74. package/dist/collab/awareness.d.ts.map +1 -1
  75. package/dist/collab/routes.d.ts +1 -1
  76. package/dist/file-upload/builder.d.ts.map +1 -1
  77. package/dist/file-upload/builder.js +6 -2
  78. package/dist/file-upload/builder.js.map +1 -1
  79. package/dist/notifications/channels.d.ts +10 -0
  80. package/dist/notifications/channels.d.ts.map +1 -1
  81. package/dist/notifications/channels.js +172 -20
  82. package/dist/notifications/channels.js.map +1 -1
  83. package/dist/notifications/routes.d.ts +3 -3
  84. package/dist/observability/routes.d.ts +5 -5
  85. package/dist/progress/routes.d.ts +1 -1
  86. package/dist/resources/handlers.d.ts +1 -1
  87. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  88. package/dist/server/analytics.d.ts +16 -0
  89. package/dist/server/analytics.d.ts.map +1 -1
  90. package/dist/server/analytics.js +30 -7
  91. package/dist/server/analytics.js.map +1 -1
  92. package/dist/server/ssr-handler.d.ts.map +1 -1
  93. package/dist/server/ssr-handler.js +28 -12
  94. package/dist/server/ssr-handler.js.map +1 -1
  95. package/dist/server/transcribe-voice.d.ts +1 -1
  96. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4941
31
+ - template files: 4962
@@ -1,5 +1,13 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.84.22
4
+
5
+ ### Patch Changes
6
+
7
+ - 72d6a3e: Add built-in Slack webhook and email notification channels, keep chat status visible through auto-continuation handoffs, and make guided-question option values authoritative in follow-up context.
8
+ - 72d6a3e: Send both Builder upload compression-skip query params for compatibility with the production upload API rollout.
9
+ - 72d6a3e: Make the document Report-Only script-src CSP account for the framework-injected Google Analytics / Tag Manager loader and inline gtag config script, so GA no longer triggers a CSP violation on every page load (it was Report-Only, so GA was never actually blocked) and the policy stays safe to graduate to enforcement.
10
+
3
11
  ## 0.84.21
4
12
 
5
13
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.84.21",
3
+ "version": "0.84.22",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -233,6 +233,7 @@ const PENDING_SELECTION_KEY = "pending-selection-context";
233
233
  const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
234
234
  const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
235
235
  const ACTIVE_RUN_POLL_INTERVAL_MS = 150;
236
+ const AUTO_RESUME_STATUS_TIMEOUT_MS = 30_000;
236
237
  // How long a single activity (model call, tool prep, long tool) must stay
237
238
  // in-flight before its label is surfaced in the running indicator. Below this
238
239
  // the indicator stays a steady "Thinking" so normal fast turns don't flicker
@@ -1410,6 +1411,7 @@ const AssistantChatInner = forwardRef<
1410
1411
  // True during the 250ms continuation window and startup of the next chunk
1411
1412
  // (adapter's auto-continue delay before POSTing the next chunk).
1412
1413
  const [isAutoResuming, setIsAutoResuming] = useState(false);
1414
+ const autoResumeTimerRef = useRef<number | null>(null);
1413
1415
  const [optimisticRunning, setOptimisticRunning] = useState(false);
1414
1416
  const [runningActivityLabel, setRunningActivityLabel] = useState<
1415
1417
  string | null
@@ -2491,20 +2493,41 @@ const AssistantChatInner = forwardRef<
2491
2493
  const handler = (e: Event) => {
2492
2494
  const detail = (e as CustomEvent).detail as { tabId?: string };
2493
2495
  if (tabId && detail?.tabId && detail.tabId !== tabId) return;
2496
+ if (autoResumeTimerRef.current !== null) {
2497
+ window.clearTimeout(autoResumeTimerRef.current);
2498
+ }
2494
2499
  setIsAutoResuming(true);
2500
+ autoResumeTimerRef.current = window.setTimeout(() => {
2501
+ autoResumeTimerRef.current = null;
2502
+ setIsAutoResuming(false);
2503
+ }, AUTO_RESUME_STATUS_TIMEOUT_MS);
2495
2504
  };
2496
2505
  window.addEventListener("agent-chat:auto-continue", handler);
2497
- return () =>
2506
+ return () => {
2507
+ if (autoResumeTimerRef.current !== null) {
2508
+ window.clearTimeout(autoResumeTimerRef.current);
2509
+ autoResumeTimerRef.current = null;
2510
+ }
2498
2511
  window.removeEventListener("agent-chat:auto-continue", handler);
2512
+ };
2499
2513
  }, [tabId]);
2500
2514
 
2501
- // Clear auto-resume state when the run stops.
2515
+ // Clear auto-resume once the next chunk has visibly started or the user stops.
2516
+ // Do not clear solely because `isRunning` is false: auto-resume intentionally
2517
+ // covers that between-chunk gap so the chat never looks idle while work is
2518
+ // still scheduled.
2502
2519
  useEffect(() => {
2503
- if (!isRunning) {
2520
+ if (isRunning || forceStopped) {
2521
+ if (autoResumeTimerRef.current !== null) {
2522
+ window.clearTimeout(autoResumeTimerRef.current);
2523
+ autoResumeTimerRef.current = null;
2524
+ }
2504
2525
  setIsAutoResuming(false);
2526
+ }
2527
+ if (!isRunning && !isAutoResuming) {
2505
2528
  resetRunningActivity();
2506
2529
  }
2507
- }, [isRunning, resetRunningActivity]);
2530
+ }, [forceStopped, isAutoResuming, isRunning, resetRunningActivity]);
2508
2531
 
2509
2532
  // Auto-dequeue: when the agent is idle, send the next queued message. This
2510
2533
  // intentionally does not depend on observing the running -> idle transition:
@@ -150,6 +150,16 @@ export function formatGuidedAnswersForAgent(
150
150
  .join("\n");
151
151
  }
152
152
 
153
+ function defaultGuidedSubmitContext(formattedAnswers: string): string {
154
+ return [
155
+ "The user answered the guided questions.",
156
+ "Use the selected option values below as authoritative. If an answer includes exact ids, file names, or action instructions, follow those exact details instead of inferring them.",
157
+ "",
158
+ "Answers:",
159
+ formattedAnswers,
160
+ ].join("\n");
161
+ }
162
+
153
163
  /** A single option for {@link askUserQuestion}. Mirrors the agent `ask-question`
154
164
  * tool and Claude Code's AskUserQuestion option shape. */
155
165
  export interface AskUserQuestionOption {
@@ -1080,12 +1090,7 @@ export function useGuidedQuestionFlow({
1080
1090
  const resolvedSubmitMessage = payload?.submitMessage ?? submitMessage;
1081
1091
  const context =
1082
1092
  buildSubmitContext?.({ answers, formattedAnswers }) ??
1083
- [
1084
- "The user answered the pre-generation questions.",
1085
- "",
1086
- "Answers:",
1087
- formattedAnswers,
1088
- ].join("\n");
1093
+ defaultGuidedSubmitContext(formattedAnswers);
1089
1094
  sendToAgentChat({
1090
1095
  message: resolvedSubmitMessage,
1091
1096
  context,
@@ -1,6 +1,14 @@
1
+ const TOOL_DISPLAY_NAMES: Record<string, string> = {
2
+ "delete-file": "delete screen",
3
+ "get-design-snapshot": "get screen snapshot",
4
+ "edit-design": "edit screen",
5
+ };
6
+
1
7
  export function humanizeToolName(toolName: string | undefined): string {
2
8
  const raw = (toolName ?? "").trim();
3
9
  if (!raw) return "tool";
10
+ const displayName = TOOL_DISPLAY_NAMES[raw];
11
+ if (displayName) return displayName;
4
12
 
5
13
  let name = raw;
6
14
  if (name.startsWith("mcp__")) {
@@ -44,6 +44,11 @@ function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
44
44
  );
45
45
  }
46
46
 
47
+ function setSkipCompressionQueryParams(url: URL): void {
48
+ url.searchParams.set("skipCompressionWait", "true");
49
+ url.searchParams.set("skipCompression", "true");
50
+ }
51
+
47
52
  async function assertOk(res: Response, label: string): Promise<void> {
48
53
  if (!res.ok) {
49
54
  const body = await res.text().catch(() => "");
@@ -154,7 +159,7 @@ async function completeBuilderUpload(
154
159
  const host = builderUploadHost();
155
160
  const url = new URL("/api/v1/upload/complete", host);
156
161
  if (options?.skipCompressionWait) {
157
- url.searchParams.set("skipCompressionWait", "true");
162
+ setSkipCompressionQueryParams(url);
158
163
  }
159
164
  const res = await fetchWithTimeout(url.toString(), {
160
165
  method: "POST",
@@ -255,7 +260,7 @@ export const builderFileUploadProvider: FileUploadProvider = {
255
260
  const url = new URL("/api/v1/upload", builderUploadHost());
256
261
  if (filename) url.searchParams.set("name", filename);
257
262
  if (input.skipCompressionWait) {
258
- url.searchParams.set("skipCompressionWait", "true");
263
+ setSkipCompressionQueryParams(url);
259
264
  }
260
265
 
261
266
  const response = await uploadSmallFile(url, {
@@ -10,6 +10,16 @@
10
10
  * value never enters the agent context.
11
11
  * NOTIFICATIONS_WEBHOOK_AUTH → optional `Authorization` header value (also
12
12
  * supports `${keys.NAME}`).
13
+ * NOTIFICATIONS_SLACK_WEBHOOK_URL
14
+ * → POST notifications to a Slack incoming webhook.
15
+ * Supports `${keys.NAME}` substitution.
16
+ * NOTIFICATIONS_EMAIL_CHANNEL=1
17
+ * → enable the built-in email channel for
18
+ * per-notification recipients.
19
+ * NOTIFICATIONS_EMAIL_RECIPIENTS
20
+ * → comma-separated fallback recipients for email
21
+ * notifications that do not pass
22
+ * `metadata.emailRecipients`.
13
23
  */
14
24
 
15
25
  import { ssrfSafeFetch } from "../extensions/url-safety.js";
@@ -18,6 +28,7 @@ import {
18
28
  validateUrlAllowlist,
19
29
  getKeyAllowlist,
20
30
  } from "../secrets/substitution.js";
31
+ import { sendEmail } from "../server/email.js";
21
32
  import { registerNotificationChannel } from "./registry.js";
22
33
  import type { NotificationChannel } from "./types.js";
23
34
 
@@ -31,6 +42,16 @@ export function registerBuiltinNotificationChannels(): void {
31
42
  if (url) {
32
43
  registerNotificationChannel(createWebhookChannel(url));
33
44
  }
45
+
46
+ const slackUrl = process.env.NOTIFICATIONS_SLACK_WEBHOOK_URL;
47
+ if (slackUrl) {
48
+ registerNotificationChannel(createSlackWebhookChannel(slackUrl));
49
+ }
50
+
51
+ const emailRecipients = process.env.NOTIFICATIONS_EMAIL_RECIPIENTS?.trim();
52
+ if (process.env.NOTIFICATIONS_EMAIL_CHANNEL === "1" || emailRecipients) {
53
+ registerNotificationChannel(createEmailChannel());
54
+ }
34
55
  }
35
56
 
36
57
  function createWebhookChannel(urlTemplate: string): NotificationChannel {
@@ -38,47 +59,12 @@ function createWebhookChannel(urlTemplate: string): NotificationChannel {
38
59
  return {
39
60
  name: "webhook",
40
61
  async deliver(input, meta) {
41
- // Resolve `${keys.NAME}` references against the owner's user-scope
42
- // secrets. Missing keys throw — the error surfaces in logs and the
43
- // channel is marked un-delivered, but other channels still run.
44
- const { resolved: url } = await resolveKeyReferences(
62
+ const { url, headers } = await resolveWebhookRequest(
45
63
  urlTemplate,
46
- "user",
64
+ authTemplate,
47
65
  meta.owner,
66
+ "webhook",
48
67
  );
49
- const headers: Record<string, string> = {
50
- "Content-Type": "application/json",
51
- };
52
- if (authTemplate) {
53
- const { resolved: auth } = await resolveKeyReferences(
54
- authTemplate,
55
- "user",
56
- meta.owner,
57
- );
58
- headers.Authorization = auth;
59
- }
60
-
61
- // If the user set an allowlist on a referenced key, enforce it here —
62
- // origin-level check, same rule the automations fetch-tool applies.
63
- const keyNames = Array.from(
64
- new Set(
65
- Array.from(
66
- urlTemplate.matchAll(/\$\{keys\.([A-Za-z0-9_-]+)\}/g),
67
- (m) => m[1],
68
- ),
69
- ),
70
- );
71
- const allowlists = await Promise.all(
72
- keyNames.map((name) => getKeyAllowlist(name, "user", meta.owner)),
73
- );
74
- keyNames.forEach((name, i) => {
75
- if (!validateUrlAllowlist(url, allowlists[i])) {
76
- throw new Error(
77
- `[notifications] webhook URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
78
- );
79
- }
80
- });
81
-
82
68
  const res = await ssrfSafeFetch(
83
69
  url,
84
70
  {
@@ -106,6 +92,217 @@ function createWebhookChannel(urlTemplate: string): NotificationChannel {
106
92
  };
107
93
  }
108
94
 
95
+ function createSlackWebhookChannel(urlTemplate: string): NotificationChannel {
96
+ const authTemplate = process.env.NOTIFICATIONS_SLACK_WEBHOOK_AUTH;
97
+ return {
98
+ name: "slack",
99
+ async deliver(input, meta) {
100
+ const { url, headers } = await resolveWebhookRequest(
101
+ urlTemplate,
102
+ authTemplate,
103
+ meta.owner,
104
+ "Slack webhook",
105
+ );
106
+ const res = await ssrfSafeFetch(
107
+ url,
108
+ {
109
+ method: "POST",
110
+ headers,
111
+ body: JSON.stringify({
112
+ text: slackText(input.severity, input.title, input.body),
113
+ blocks: [
114
+ {
115
+ type: "section",
116
+ text: {
117
+ type: "mrkdwn",
118
+ text: `*${escapeSlack(input.title)}*`,
119
+ },
120
+ },
121
+ ...(input.body
122
+ ? [
123
+ {
124
+ type: "section",
125
+ text: {
126
+ type: "mrkdwn",
127
+ text: escapeSlack(input.body),
128
+ },
129
+ },
130
+ ]
131
+ : []),
132
+ {
133
+ type: "context",
134
+ elements: [
135
+ {
136
+ type: "mrkdwn",
137
+ text: `Severity: \`${input.severity}\` Owner: ${escapeSlack(meta.owner)}`,
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ }),
143
+ },
144
+ { maxRedirects: 3 },
145
+ );
146
+ if (!res.ok) {
147
+ throw new Error(
148
+ `[notifications] Slack webhook ${new URL(url).origin} returned ${res.status}${
149
+ (await readErrorSnippet(res)) || ""
150
+ }`,
151
+ );
152
+ }
153
+ },
154
+ };
155
+ }
156
+
157
+ function createEmailChannel(): NotificationChannel {
158
+ return {
159
+ name: "email",
160
+ async deliver(input) {
161
+ const recipients = notificationEmailRecipients(input.metadata);
162
+ if (recipients.length === 0) return;
163
+ const subject =
164
+ typeof input.metadata?.emailSubject === "string" &&
165
+ input.metadata.emailSubject.trim()
166
+ ? input.metadata.emailSubject.trim()
167
+ : `[${input.severity}] ${input.title}`;
168
+ const metadata = scrubEmailMetadata(input.metadata);
169
+ const metadataText = metadata
170
+ ? `\n\nMetadata:\n${JSON.stringify(metadata, null, 2)}`
171
+ : "";
172
+ const text = `${input.title}\n\n${input.body ?? ""}${metadataText}`;
173
+ const html = [
174
+ `<p><strong>${escapeHtml(input.title)}</strong></p>`,
175
+ input.body ? `<p>${escapeHtml(input.body)}</p>` : "",
176
+ metadata
177
+ ? `<pre>${escapeHtml(JSON.stringify(metadata, null, 2))}</pre>`
178
+ : "",
179
+ ].join("");
180
+
181
+ await Promise.all(
182
+ recipients.map((to) =>
183
+ sendEmail({
184
+ to,
185
+ subject,
186
+ text,
187
+ html,
188
+ }),
189
+ ),
190
+ );
191
+ },
192
+ };
193
+ }
194
+
195
+ async function resolveWebhookRequest(
196
+ urlTemplate: string,
197
+ authTemplate: string | undefined,
198
+ owner: string,
199
+ label: string,
200
+ ): Promise<{ url: string; headers: Record<string, string> }> {
201
+ // Resolve `${keys.NAME}` references against the owner's user-scope secrets.
202
+ // Missing keys throw — the error surfaces in logs and the channel is marked
203
+ // un-delivered, but other channels still run.
204
+ const { resolved: url } = await resolveKeyReferences(
205
+ urlTemplate,
206
+ "user",
207
+ owner,
208
+ );
209
+ const headers: Record<string, string> = {
210
+ "Content-Type": "application/json",
211
+ };
212
+ if (authTemplate) {
213
+ const { resolved: auth } = await resolveKeyReferences(
214
+ authTemplate,
215
+ "user",
216
+ owner,
217
+ );
218
+ headers.Authorization = auth;
219
+ }
220
+
221
+ // If the user set an allowlist on a referenced key, enforce it here —
222
+ // origin-level check, same rule the automations fetch-tool applies.
223
+ const keyNames = Array.from(
224
+ new Set(
225
+ Array.from(urlTemplate.matchAll(/\$\{keys\.([A-Za-z0-9_-]+)\}/g), (m) =>
226
+ String(m[1]),
227
+ ),
228
+ ),
229
+ );
230
+ const allowlists = await Promise.all(
231
+ keyNames.map((name) => getKeyAllowlist(name, "user", owner)),
232
+ );
233
+ keyNames.forEach((name, i) => {
234
+ if (!validateUrlAllowlist(url, allowlists[i])) {
235
+ throw new Error(
236
+ `[notifications] ${label} URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
237
+ );
238
+ }
239
+ });
240
+ return { url, headers };
241
+ }
242
+
243
+ function notificationEmailRecipients(
244
+ metadata: Record<string, unknown> | undefined,
245
+ ): string[] {
246
+ const fromMetadata = stringArray(metadata?.emailRecipients);
247
+ const fromEnv = commaList(process.env.NOTIFICATIONS_EMAIL_RECIPIENTS);
248
+ const seen = new Set<string>();
249
+ const recipients: string[] = [];
250
+ for (const raw of [...fromMetadata, ...fromEnv]) {
251
+ const email = raw.trim().toLowerCase();
252
+ if (!email || seen.has(email)) continue;
253
+ seen.add(email);
254
+ recipients.push(email);
255
+ }
256
+ return recipients;
257
+ }
258
+
259
+ function stringArray(value: unknown): string[] {
260
+ if (!Array.isArray(value)) return [];
261
+ return value.filter((item): item is string => typeof item === "string");
262
+ }
263
+
264
+ function commaList(value: string | undefined): string[] {
265
+ return value
266
+ ? value
267
+ .split(",")
268
+ .map((item) => item.trim())
269
+ .filter(Boolean)
270
+ : [];
271
+ }
272
+
273
+ function scrubEmailMetadata(
274
+ metadata: Record<string, unknown> | undefined,
275
+ ): Record<string, unknown> | null {
276
+ if (!metadata) return null;
277
+ const entries = Object.entries(metadata).filter(
278
+ ([key]) => key !== "emailRecipients" && key !== "emailSubject",
279
+ );
280
+ return entries.length ? Object.fromEntries(entries) : null;
281
+ }
282
+
283
+ function slackText(
284
+ severity: string,
285
+ title: string,
286
+ body: string | undefined,
287
+ ): string {
288
+ return `[${severity}] ${title}${body ? `\n${body}` : ""}`;
289
+ }
290
+
291
+ function escapeSlack(value: string): string {
292
+ return value
293
+ .replace(/&/g, "&amp;")
294
+ .replace(/</g, "&lt;")
295
+ .replace(/>/g, "&gt;");
296
+ }
297
+
298
+ function escapeHtml(value: string): string {
299
+ return value
300
+ .replace(/&/g, "&amp;")
301
+ .replace(/</g, "&lt;")
302
+ .replace(/>/g, "&gt;")
303
+ .replace(/"/g, "&quot;");
304
+ }
305
+
109
306
  /**
110
307
  * Read up to ~1 KB from the body for error context. Streams chunks so a
111
308
  * misbehaving endpoint returning a large error page doesn't pin that whole
@@ -39,14 +39,30 @@ function getGaMeasurementId(): string | null {
39
39
  );
40
40
  }
41
41
 
42
- function getGaScript(): string | null {
42
+ /**
43
+ * Script hosts the injected GA loader pulls executable code from. Google Tag
44
+ * Manager serves `gtag/js`, and GA4 can lazy-load additional collectors from
45
+ * `www.google-analytics.com`. These must be listed in the document `script-src`
46
+ * so the CSP reflects the code the framework itself injects (see
47
+ * `applyDocumentCsp` in `ssr-handler.ts`).
48
+ */
49
+ export const GA_CSP_SCRIPT_HOSTS = [
50
+ "https://www.googletagmanager.com",
51
+ "https://www.google-analytics.com",
52
+ ] as const;
53
+
54
+ /**
55
+ * The exact JS body (no surrounding `<script>` tags) of the inline gtag config
56
+ * block injected next to the gtag.js loader. Returned so the SSR handler can
57
+ * hash it for the `script-src` CSP directive — the hash must be computed from
58
+ * the identical string that `getGaScript()` embeds, so both call this helper.
59
+ * Returns `null` when GA is not configured.
60
+ */
61
+ export function getGaInlineConfigScriptBody(): string | null {
43
62
  const id = getGaMeasurementId();
44
63
  if (!id) return null;
45
- const srcId = encodeURIComponent(id);
46
64
  const jsId = JSON.stringify(id);
47
65
  return (
48
- `<script async src="https://www.googletagmanager.com/gtag/js?id=${srcId}"></script>` +
49
- `<script>` +
50
66
  `window.dataLayer=window.dataLayer||[];` +
51
67
  `function gtag(){dataLayer.push(arguments);}` +
52
68
  `gtag('js',new Date());` +
@@ -54,8 +70,18 @@ function getGaScript(): string | null {
54
70
  `if(typeof sessionStorage!=='undefined'&&sessionStorage.getItem('__an_signin')){` +
55
71
  `sessionStorage.removeItem('__an_signin');` +
56
72
  `gtag('event','sign_in');` +
57
- `}` +
58
- `</script>`
73
+ `}`
74
+ );
75
+ }
76
+
77
+ function getGaScript(): string | null {
78
+ const id = getGaMeasurementId();
79
+ if (!id) return null;
80
+ const srcId = encodeURIComponent(id);
81
+ const inlineBody = getGaInlineConfigScriptBody();
82
+ return (
83
+ `<script async src="https://www.googletagmanager.com/gtag/js?id=${srcId}"></script>` +
84
+ `<script>${inlineBody}</script>`
59
85
  );
60
86
  }
61
87
 
@@ -30,6 +30,10 @@ import {
30
30
  AGENT_NATIVE_SOCIAL_IMAGE_WIDTH,
31
31
  withAgentNativeSocialImageCacheBuster,
32
32
  } from "../shared/social-meta.js";
33
+ import {
34
+ GA_CSP_SCRIPT_HOSTS,
35
+ getGaInlineConfigScriptBody,
36
+ } from "./analytics.js";
33
37
  import {
34
38
  getAppBasePathFromViteEnv,
35
39
  stripAppBasePath as canonicalStripAppBasePath,
@@ -278,13 +282,18 @@ function extractScriptBody(scriptTag: string | null): string | null {
278
282
  * user-controlled content reaches the HTML).
279
283
  *
280
284
  * A third directive, `script-src`, is emitted via `Content-Security-Policy-
281
- * Report-Only` rather than enforced. The framework injects one deterministic
282
- * inline script per process (the Sentry config block its hash is computed
283
- * once at process startup from the resolved env vars). Templates additionally
284
- * render a theme-init inline script whose exact content varies by template
285
- * (default theme param, custom docs variant, etc.) and which is rendered by
286
- * React Router, not this handler, so its hash is not available here. Shipping
287
- * script-src as Report-Only surfaces violations without breaking template
285
+ * Report-Only` rather than enforced. The framework injects deterministic inline
286
+ * scripts (the Sentry config block, whose hash is computed once at process
287
+ * startup from the resolved env vars, and — when `GA_MEASUREMENT_ID` is set —
288
+ * the gtag config block, whose hash is derived from the same string
289
+ * `wrapWithAnalytics` embeds). It also loads Google Tag Manager / GA4 from
290
+ * `GA_CSP_SCRIPT_HOSTS`. All of those are listed here so the report-only policy
291
+ * reflects the code the framework itself injects instead of reporting a
292
+ * violation on every page load. Templates additionally render a theme-init
293
+ * inline script whose exact content varies by template (default theme param,
294
+ * custom docs variant, etc.) and which is rendered by React Router, not this
295
+ * handler, so its hash is not available here. Shipping script-src as
296
+ * Report-Only surfaces the remaining violations without breaking template
288
297
  * customisations; teams can graduate to enforcement once their hashes are
289
298
  * enumerated.
290
299
  *
@@ -306,13 +315,23 @@ function applyDocumentCsp(headers: Headers, sentryScript: string | null): void {
306
315
  );
307
316
  }
308
317
 
309
- // script-src as Report-Only: list 'self' plus the hash for the Sentry config
310
- // script the SSR handler injects into every HTML response (the hash is
311
- // computed once from the resolved env vars at process startup). Template
312
- // theme-init hashes are NOT included here see function comment above.
318
+ // script-src as Report-Only: list 'self', the framework-injected inline
319
+ // script hashes (Sentry config + gtag config), and the Google Analytics /
320
+ // Tag Manager loader hosts. These are exactly the scripts the framework
321
+ // itself injects, so listing them keeps the report-only policy from flagging
322
+ // GA on every page load (and keeps it safe to graduate to enforcement).
323
+ // Template theme-init hashes are NOT included here — see function comment.
313
324
  const sentryBody = extractScriptBody(sentryScript);
314
325
  const sentryHash = sentryBody ? computeInlineScriptHash(sentryBody) : null;
315
- const scriptSrcTokens = ["'self'", ...(sentryHash ? [sentryHash] : [])];
326
+ const gaInlineBody = getGaInlineConfigScriptBody();
327
+ const gaHash = gaInlineBody ? computeInlineScriptHash(gaInlineBody) : null;
328
+ const gaHosts = gaInlineBody ? [...GA_CSP_SCRIPT_HOSTS] : [];
329
+ const scriptSrcTokens = [
330
+ "'self'",
331
+ ...(sentryHash ? [sentryHash] : []),
332
+ ...(gaHash ? [gaHash] : []),
333
+ ...gaHosts,
334
+ ];
316
335
  const scriptSrc = `script-src ${scriptSrcTokens.join(" ")}`;
317
336
 
318
337
  const existingRo = headers.get("content-security-policy-report-only") ?? "";
@@ -107,6 +107,27 @@ details live in `.agents/skills/`.
107
107
  `@sparticuz/chromium-min` in serverless runtimes; set
108
108
  `DASHBOARD_REPORT_CHROMIUM_PACK_URL` only when overriding the default
109
109
  Chromium pack location.
110
+ - Analytics alert rules live in SQL via the `analytics-alert-rules` actions.
111
+ Use `save-analytics-alert-rule`, `list-analytics-alert-rules`,
112
+ `delete-analytics-alert-rule`, and `run-analytics-alerts`; do not hardcode
113
+ one-off alert checks in product code. Rules are generic over first-party
114
+ analytics events: set `eventName` for an exact event name, then filter on
115
+ columns like `event_name`, `app`, `template`, `user_key`, `session_id`,
116
+ `path`, or nested JSON fields like `properties.stage` and
117
+ `context.referrer`. `thresholdMode: "event_count"` counts matching events;
118
+ `thresholdMode: "distinct_count"` counts unique values from `distinctBy`.
119
+ Alert notifications use the shared notification channel registry, so
120
+ `channels` can include `inbox`, `email`, `slack`, `webhook`, or any custom
121
+ registered channel. Configure Slack with `NOTIFICATIONS_SLACK_WEBHOOK_URL`
122
+ and optional `NOTIFICATIONS_SLACK_WEBHOOK_AUTH`; configure email with
123
+ `NOTIFICATIONS_EMAIL_CHANNEL=1`, existing `RESEND_API_KEY` or
124
+ `SENDGRID_API_KEY` plus `EMAIL_FROM`, and pass per-rule `emailRecipients` or
125
+ the fallback `NOTIFICATIONS_EMAIL_RECIPIENTS`.
126
+ Netlify builds emit an alert cron trigger plus background worker from
127
+ `scripts/emit-netlify-dashboard-report-cron.ts` every five minutes; long-lived
128
+ runtimes use the in-process scheduler unless `ANALYTICS_ALERT_JOBS=0` is set.
129
+ External cron callers can POST `/api/analytics-alerts/run` with
130
+ `Authorization: Bearer $ANALYTICS_ALERTS_CRON_SECRET`.
110
131
 
111
132
  ## Application State
112
133
 
@@ -0,0 +1,35 @@
1
+ # Analytics
2
+
3
+ An open-source, agent-native alternative to Amplitude, Mixpanel, and Looker. Ask
4
+ analytics questions in plain English and get charts, dashboards, and re-runnable
5
+ investigations back — you own the code, the queries, and the data.
6
+
7
+ **Live app: [analytics.agent-native.com](https://analytics.agent-native.com)**
8
+
9
+ The agent connects to your data, writes and validates the SQL for you, and
10
+ renders the answer as a chart, table, metric, or saved dashboard panel. Anything
11
+ you can do in the UI, the agent can do through the same actions.
12
+
13
+ ## Features
14
+
15
+ - Ask data questions in plain English and get charts, tables, or metrics back.
16
+ - Connect BigQuery, GA4, Mixpanel, Amplitude, PostHog, HubSpot, Jira, and more,
17
+ plus a built-in first-party event collector.
18
+ - Build reusable SQL dashboards with filters, saved views, and parametric queries.
19
+ - Save ad-hoc analyses as re-runnable investigations with question, instructions,
20
+ and findings.
21
+ - Maintain a living data dictionary so the agent uses real warehouse column names.
22
+ - Share dashboards per-user or per-org with viewer / editor / admin roles.
23
+
24
+ ## Develop locally
25
+
26
+ Scaffold your own copy and run it:
27
+
28
+ ```bash
29
+ npx @agent-native/core@latest create my-analytics --standalone --template analytics
30
+ cd my-analytics
31
+ pnpm install
32
+ pnpm dev
33
+ ```
34
+
35
+ Full docs: [agent-native.com/docs/template-analytics](https://agent-native.com/docs/template-analytics).