@agent-native/core 0.227.0 → 0.228.0-nightly-20261111004142

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 (190) hide show
  1. package/dist/action.d.ts +6 -2
  2. package/dist/agent/engine/builder-engine.js +6 -1
  3. package/dist/agent/engine/tool-call-journal-seed.d.ts +2 -0
  4. package/dist/agent/engine/tool-call-journal-seed.js +14 -0
  5. package/dist/agent/harness/store.js +14 -0
  6. package/dist/agent/production-agent.js +257 -17
  7. package/dist/agent/reaped-run-thread.js +6 -0
  8. package/dist/agent/run-manager.js +6 -0
  9. package/dist/agent/tool-call-journal.d.ts +4 -0
  10. package/dist/agent/tool-call-journal.js +12 -0
  11. package/dist/agent/types.d.ts +7 -1
  12. package/dist/app-config/agent.d.ts +3 -0
  13. package/dist/app-config/agent.js +7 -0
  14. package/dist/app-config/schema.d.ts +3 -0
  15. package/dist/application-state/handlers.d.ts +3 -3
  16. package/dist/application-state/handlers.js +6 -1
  17. package/dist/application-state/index.d.ts +3 -2
  18. package/dist/application-state/index.js +3 -2
  19. package/dist/application-state/last-used-tab.d.ts +11 -0
  20. package/dist/application-state/last-used-tab.js +43 -0
  21. package/dist/application-state/script-helpers.d.ts +26 -0
  22. package/dist/application-state/script-helpers.js +83 -15
  23. package/dist/application-state/store.d.ts +8 -0
  24. package/dist/application-state/store.js +16 -0
  25. package/dist/cli/agent.js +7 -3
  26. package/dist/cli/code-agent-executor.d.ts +1 -0
  27. package/dist/cli/code-agent-executor.js +37 -2
  28. package/dist/cli/code.js +3 -1
  29. package/dist/client/agent-sidebar-state.d.ts +2 -0
  30. package/dist/client/browser-tab-use.d.ts +9 -0
  31. package/dist/client/browser-tab-use.js +112 -0
  32. package/dist/client/chat/runtime.js +3 -1
  33. package/dist/client/error-capture.js +33 -1
  34. package/dist/client/navigation/index.d.ts +1 -0
  35. package/dist/client/navigation/index.js +1 -0
  36. package/dist/client/resources/mcp-integration-logos.js +16 -0
  37. package/dist/client/review/use-review.d.ts +28 -0
  38. package/dist/client/review/use-review.js +41 -12
  39. package/dist/client/route-state.js +2 -0
  40. package/dist/client/sse-event-processor.d.ts +1 -0
  41. package/dist/client/sse-event-processor.js +7 -1
  42. package/dist/client/use-db-sync.d.ts +8 -1
  43. package/dist/client/use-db-sync.js +6 -5
  44. package/dist/coding-tools/run-code.js +12 -2
  45. package/dist/coding-tools/sandbox/executions-store.d.ts +1 -1
  46. package/dist/coding-tools/sandbox/executions-store.js +9 -3
  47. package/dist/collab/routes.d.ts +1 -1
  48. package/dist/data-programs/execute.js +1 -1
  49. package/dist/db/schema-snapshot.generated.js +17 -3
  50. package/dist/extensions/url-safety.d.ts +1 -3
  51. package/dist/extensions/url-safety.js +23 -23
  52. package/dist/file-upload/actions/upload-image.d.ts +5 -5
  53. package/dist/file-upload/index.d.ts +1 -1
  54. package/dist/file-upload/index.js +1 -1
  55. package/dist/file-upload/s3-client.d.ts +1 -0
  56. package/dist/file-upload/s3-client.js +70 -13
  57. package/dist/file-upload/s3.d.ts +9 -0
  58. package/dist/file-upload/s3.js +14 -1
  59. package/dist/framework-package-versions.json +8 -8
  60. package/dist/jobs/run-history.js +10 -3
  61. package/dist/jobs/scheduler.js +5 -36
  62. package/dist/localization/core-messages/supplemental/ar-SA.js +35 -0
  63. package/dist/localization/core-messages/supplemental/de-DE.js +35 -0
  64. package/dist/localization/core-messages/supplemental/en-US.js +35 -0
  65. package/dist/localization/core-messages/supplemental/es-ES.js +35 -0
  66. package/dist/localization/core-messages/supplemental/fr-FR.js +35 -0
  67. package/dist/localization/core-messages/supplemental/hi-IN.js +35 -0
  68. package/dist/localization/core-messages/supplemental/ja-JP.js +35 -0
  69. package/dist/localization/core-messages/supplemental/ko-KR.js +35 -0
  70. package/dist/localization/core-messages/supplemental/pt-BR.js +35 -0
  71. package/dist/localization/core-messages/supplemental/zh-CN.js +35 -0
  72. package/dist/localization/core-messages/supplemental/zh-TW.js +35 -0
  73. package/dist/localization/default-messages.d.ts +35 -0
  74. package/dist/localization/default-messages.js +35 -0
  75. package/dist/localization/mcp-settings-messages.d.ts +35 -0
  76. package/dist/mcp/actions/list-mcp-client-connections.d.ts +8 -0
  77. package/dist/mcp/actions/list-mcp-client-connections.js +39 -0
  78. package/dist/mcp/browser-tab-target.d.ts +16 -0
  79. package/dist/mcp/browser-tab-target.js +58 -0
  80. package/dist/mcp/build-server.d.ts +1 -0
  81. package/dist/mcp/build-server.js +38 -7
  82. package/dist/mcp/call-attribution.d.ts +4 -1
  83. package/dist/mcp/call-attribution.js +3 -1
  84. package/dist/mcp/client-connections.d.ts +6 -0
  85. package/dist/mcp/client-connections.js +91 -0
  86. package/dist/mcp/connect-route.js +12 -2
  87. package/dist/mcp/connect-store.d.ts +3 -0
  88. package/dist/mcp/connect-store.js +10 -1
  89. package/dist/mcp/direct-action-call.js +1 -0
  90. package/dist/mcp/oauth-store.d.ts +6 -0
  91. package/dist/mcp/oauth-store.js +23 -0
  92. package/dist/mcp/oauth-token.d.ts +1 -0
  93. package/dist/mcp/oauth-token.js +1 -0
  94. package/dist/mcp/server.js +9 -20
  95. package/dist/mcp-client/config.d.ts +2 -0
  96. package/dist/mcp-client/manager.js +23 -0
  97. package/dist/mcp-client/workspace-servers.js +42 -10
  98. package/dist/notifications/routes.d.ts +3 -3
  99. package/dist/org/handlers.d.ts +1 -1
  100. package/dist/org/handlers.js +21 -17
  101. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  102. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  103. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  104. package/dist/provider-api/index.js +17 -36
  105. package/dist/review/actions/create-review-comment.d.ts +7 -0
  106. package/dist/review/actions/create-review-comment.js +29 -3
  107. package/dist/review/actions/delete-review-comment.js +2 -2
  108. package/dist/review/actions/list-review-comments.d.ts +3 -0
  109. package/dist/review/actions/list-review-comments.js +41 -17
  110. package/dist/review/actions/react-to-review-comment.js +2 -2
  111. package/dist/review/actions/reply-review-comment.d.ts +4 -0
  112. package/dist/review/actions/reply-review-comment.js +15 -6
  113. package/dist/review/actions/update-review-comment-anchor.d.ts +6 -0
  114. package/dist/review/actions/update-review-comment-anchor.js +39 -10
  115. package/dist/review/actions/update-review-comment.d.ts +3 -0
  116. package/dist/review/actions/update-review-comment.js +28 -3
  117. package/dist/review/errors.d.ts +33 -0
  118. package/dist/review/errors.js +51 -0
  119. package/dist/review/index.d.ts +5 -4
  120. package/dist/review/index.js +3 -2
  121. package/dist/review/registry.d.ts +51 -1
  122. package/dist/review/registry.js +117 -0
  123. package/dist/review/store.d.ts +59 -14
  124. package/dist/review/store.js +622 -101
  125. package/dist/review/suggestions/actions.js +65 -10
  126. package/dist/review/suggestions/actor-kind.d.ts +5 -0
  127. package/dist/review/suggestions/actor-kind.js +20 -0
  128. package/dist/review/suggestions/store.js +16 -2
  129. package/dist/review/suggestions/types.d.ts +13 -0
  130. package/dist/review/types.d.ts +89 -0
  131. package/dist/scripts/db/dev-query-proxy.d.ts +2 -0
  132. package/dist/scripts/db/dev-query-proxy.js +4 -1
  133. package/dist/scripts/db/query.js +3 -0
  134. package/dist/scripts/dev/shell.js +4 -0
  135. package/dist/scripts/dev-session.d.ts +31 -0
  136. package/dist/scripts/dev-session.js +58 -0
  137. package/dist/scripts/resources/list.js +53 -4
  138. package/dist/scripts/runner.js +22 -4
  139. package/dist/search/query.d.ts +11 -5
  140. package/dist/search/query.js +52 -15
  141. package/dist/search/tokenize.d.ts +1 -0
  142. package/dist/search/tokenize.js +3 -0
  143. package/dist/secrets/api-keys.d.ts +4 -0
  144. package/dist/secrets/api-keys.js +5 -0
  145. package/dist/secrets/register.d.ts +2 -0
  146. package/dist/secrets/routes.d.ts +2 -2
  147. package/dist/secrets/routes.js +85 -6
  148. package/dist/secrets/storage.d.ts +2 -0
  149. package/dist/secrets/storage.js +34 -9
  150. package/dist/secrets/substitution.d.ts +3 -2
  151. package/dist/secrets/substitution.js +56 -7
  152. package/dist/server/action-discovery.js +5 -0
  153. package/dist/server/action-routes.js +8 -6
  154. package/dist/server/agent-chat/prompt-resources.js +117 -79
  155. package/dist/server/agent-chat/script-entries.js +15 -8
  156. package/dist/server/agent-chat-plugin.js +9 -4
  157. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  158. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  159. package/dist/server/agents-bundle.d.ts +3 -1
  160. package/dist/server/agents-bundle.js +4 -4
  161. package/dist/server/csrf.js +1 -0
  162. package/dist/server/dev-action-bridge.d.ts +2 -0
  163. package/dist/server/dev-action-bridge.js +34 -5
  164. package/dist/server/first-touch-route.d.ts +1 -1
  165. package/dist/server/http-response-telemetry.js +4 -0
  166. package/dist/server/loopback.js +3 -1
  167. package/dist/server/request-context.d.ts +26 -0
  168. package/dist/shared/app-state-routes.d.ts +7 -0
  169. package/dist/shared/app-state-routes.js +7 -0
  170. package/dist/shared/mcp-client-connections.d.ts +41 -0
  171. package/dist/shared/mcp-client-connections.js +18 -0
  172. package/dist/shared/mcp-connect-content.d.ts +18 -0
  173. package/dist/shared/mcp-connect-content.js +200 -172
  174. package/dist/shared/runtime-config.js +5 -1
  175. package/dist/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
  176. package/dist/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
  177. package/dist/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
  178. package/dist/tracking/providers.d.ts +7 -0
  179. package/dist/tracking/providers.js +39 -17
  180. package/dist/triggers/condition-evaluator.d.ts +2 -0
  181. package/dist/triggers/condition-evaluator.js +93 -3
  182. package/dist/triggers/dispatcher.js +24 -6
  183. package/docs/AGENTS.md +16 -16
  184. package/docs/content/create-with-an-agent.mdx +10 -4
  185. package/docs/content/durable-resume.mdx +11 -2
  186. package/docs/content/toolkit-comments-review.mdx +54 -1
  187. package/package.json +5 -4
  188. package/src/templates/chat/.agents/skills/context-awareness/SKILL.md +13 -0
  189. package/src/templates/default/.agents/skills/context-awareness/SKILL.md +13 -0
  190. package/src/templates/workspace-core/.agents/skills/context-awareness/SKILL.md +13 -0
@@ -127,6 +127,10 @@ function addIssue(issues, issue, environment) {
127
127
  });
128
128
  }
129
129
  export function buildRuntimeConfigPrompt(report, appName) {
130
+ // A healthy report has nothing to fix. A "fix this" prompt here would send
131
+ // an agent that reads the probe looking for problems that are not there.
132
+ if (report.issues.length === 0)
133
+ return "";
130
134
  const appLine = appName ? `\nApp: ${appName}` : "";
131
135
  const issueLines = report.issues
132
136
  .map((issue) => `- ${issue.severity.toUpperCase()}: ${issue.title}. ${issue.message}`)
@@ -137,7 +141,7 @@ export function buildRuntimeConfigPrompt(report, appName) {
137
141
  `Phase: ${report.phase}${appLine}`,
138
142
  "",
139
143
  "Detected issues:",
140
- issueLines || "- No issues were reported.",
144
+ issueLines,
141
145
  "",
142
146
  "Inspect the app's agent-native.json or agent-native.config.ts (including compatibility aliases), its deployment configuration, and the framework defaults. Make the smallest safe fix. Keep secrets out of source control. Do not print secret values in your response. Tell me exactly which deploy settings still need to be set, then verify the production build and runtime health check.",
143
147
  ].join("\n");
@@ -246,6 +246,19 @@ of the local route unless a matching `view` route or explicit `publicPath`
246
246
  identifies the mounted URL. Carry the mounted form separately in `publicPath`;
247
247
  never infer that `path` is mounted from its prefix alone.
248
248
 
249
+ **Calls from no tab.** An MCP tool call by a signed-in user carries no tab ID,
250
+ so the tab-scoped helpers (`readAppState("navigation")`,
251
+ `readAppStateForCurrentTab`, `writeAppStateForCurrentTab`) act on the tab of
252
+ this app the user used last within 12 hours. `useAgentRouteState` records a
253
+ tab's use when it gains focus or changes route; an app with its own navigation
254
+ sync calls `useBrowserTabUse(route)` from
255
+ `@agent-native/core/client/navigation`. The call doesn't become that tab:
256
+ `getCurrentRequestBrowserTabId()` stays null, and checks that must come from
257
+ the open editor keep requiring it. When no tab was used recently, `navigation`
258
+ reads return null rather than the account-wide row, writes keep going to the
259
+ account-wide row, and the MCP result says no tab was found. To pick a tab the
260
+ same way in your own code, use `resolveCurrentRequestStateTab()`.
261
+
249
262
  ## Jitter Prevention
250
263
 
251
264
  Agent-side application-state writes are tagged with
@@ -246,6 +246,19 @@ of the local route unless a matching `view` route or explicit `publicPath`
246
246
  identifies the mounted URL. Carry the mounted form separately in `publicPath`;
247
247
  never infer that `path` is mounted from its prefix alone.
248
248
 
249
+ **Calls from no tab.** An MCP tool call by a signed-in user carries no tab ID,
250
+ so the tab-scoped helpers (`readAppState("navigation")`,
251
+ `readAppStateForCurrentTab`, `writeAppStateForCurrentTab`) act on the tab of
252
+ this app the user used last within 12 hours. `useAgentRouteState` records a
253
+ tab's use when it gains focus or changes route; an app with its own navigation
254
+ sync calls `useBrowserTabUse(route)` from
255
+ `@agent-native/core/client/navigation`. The call doesn't become that tab:
256
+ `getCurrentRequestBrowserTabId()` stays null, and checks that must come from
257
+ the open editor keep requiring it. When no tab was used recently, `navigation`
258
+ reads return null rather than the account-wide row, writes keep going to the
259
+ account-wide row, and the MCP result says no tab was found. To pick a tab the
260
+ same way in your own code, use `resolveCurrentRequestStateTab()`.
261
+
249
262
  ## Jitter Prevention
250
263
 
251
264
  Agent-side application-state writes are tagged with
@@ -246,6 +246,19 @@ of the local route unless a matching `view` route or explicit `publicPath`
246
246
  identifies the mounted URL. Carry the mounted form separately in `publicPath`;
247
247
  never infer that `path` is mounted from its prefix alone.
248
248
 
249
+ **Calls from no tab.** An MCP tool call by a signed-in user carries no tab ID,
250
+ so the tab-scoped helpers (`readAppState("navigation")`,
251
+ `readAppStateForCurrentTab`, `writeAppStateForCurrentTab`) act on the tab of
252
+ this app the user used last within 12 hours. `useAgentRouteState` records a
253
+ tab's use when it gains focus or changes route; an app with its own navigation
254
+ sync calls `useBrowserTabUse(route)` from
255
+ `@agent-native/core/client/navigation`. The call doesn't become that tab:
256
+ `getCurrentRequestBrowserTabId()` stays null, and checks that must come from
257
+ the open editor keep requiring it. When no tab was used recently, `navigation`
258
+ reads return null rather than the account-wide row, writes keep going to the
259
+ account-wide row, and the MCP result says no tab was found. To pick a tab the
260
+ same way in your own code, use `resolveCurrentRequestStateTab()`.
261
+
249
262
  ## Jitter Prevention
250
263
 
251
264
  Agent-side application-state writes are tagged with
@@ -1,3 +1,10 @@
1
1
  export declare const AGENT_NATIVE_ANALYTICS_PROVIDER = "agent-native-analytics";
2
+ /**
3
+ * The end-of-request flush. On serverless, the caller hands this to the
4
+ * host's waitUntil, so sends still under way outlive the reply. Long-running
5
+ * servers don't freeze, and a flush per request would end their 10-second
6
+ * batching, so there it resolves at once.
7
+ */
8
+ export declare function flushTrackingSendsForResponse(): Promise<void>;
2
9
  export declare function sendPostHogEvent(name: string, properties: Record<string, unknown>, distinctId: string): boolean;
3
10
  export declare function registerBuiltinProviders(): void;
@@ -1,6 +1,7 @@
1
1
  import { withoutWriteObservation } from "../action-frame.js";
2
2
  import { getAppConfig } from "../app-config/index.js";
3
- import { getRequestContext } from "../server/request-context.js";
3
+ import { platformWaitUntil } from "../server/background-work.js";
4
+ import { getRequestContext, getRequestRunContext, } from "../server/request-context.js";
4
5
  import { isTestIdentity } from "../server/test-identity.js";
5
6
  import { resolveLaneEndpoint } from "../shared/environment-lanes.js";
6
7
  import { reshapeTrackedExceptionProperties } from "./posthog-exception.js";
@@ -32,12 +33,26 @@ function getTimer() {
32
33
  function setTimer(t) {
33
34
  globalThis[TIMER_KEY] = t;
34
35
  }
36
+ /**
37
+ * A serverless host can freeze the function once the reply is out, cutting
38
+ * off a send still under way. Inside a request, the host's waitUntil keeps the
39
+ * function up until the send ends. Actions reach it without the request event:
40
+ * Cloudflare's through the request's run context, Netlify's through its store.
41
+ */
42
+ function keepAlive(sending) {
43
+ try {
44
+ (getRequestRunContext()?.waitUntil ?? platformWaitUntil())?.(sending);
45
+ }
46
+ catch {
47
+ // coercion-ok: the send is already under way; a host refusing to wait for it only risks that event.
48
+ }
49
+ }
35
50
  function enqueue(url, body, headers, options) {
36
51
  const queue = getQueue();
37
52
  queue.push({ url, body, headers });
38
53
  const flushImmediately = options?.flushImmediately ?? isServerlessRuntime();
39
54
  if (flushImmediately || queue.length >= MAX_BATCH_SIZE) {
40
- void drainQueue();
55
+ keepAlive(drainQueue());
41
56
  }
42
57
  else if (!getTimer()) {
43
58
  const timer = setTimeout(() => {
@@ -72,6 +87,22 @@ function drainQueue() {
72
87
  // hold the request's background lifetime until those sends settle.
73
88
  return Promise.all([...inFlight]);
74
89
  }
90
+ /**
91
+ * Sends what is queued, then waits for every send still under way, including
92
+ * the ones a serverless runtime started the moment their event was tracked.
93
+ */
94
+ function flushSends() {
95
+ return drainQueue().then(() => undefined);
96
+ }
97
+ /**
98
+ * The end-of-request flush. On serverless, the caller hands this to the
99
+ * host's waitUntil, so sends still under way outlive the reply. Long-running
100
+ * servers don't freeze, and a flush per request would end their 10-second
101
+ * batching, so there it resolves at once.
102
+ */
103
+ export function flushTrackingSendsForResponse() {
104
+ return isServerlessRuntime() ? flushSends() : Promise.resolve();
105
+ }
75
106
  function isLocalhostUrl(value) {
76
107
  if (!value || !value.trim())
77
108
  return false;
@@ -122,6 +153,7 @@ function shouldSkipAgentNativeAnalyticsForLocalhost() {
122
153
  function isServerlessRuntime() {
123
154
  return Boolean(process.env.NETLIFY ||
124
155
  process.env.VERCEL ||
156
+ process.env.CF_PAGES ||
125
157
  process.env.AWS_LAMBDA_FUNCTION_NAME ||
126
158
  process.env.AWS_EXECUTION_ENV ||
127
159
  process.env.LAMBDA_TASK_ROOT ||
@@ -202,9 +234,7 @@ function createPostHogProvider(apiKey, host, errorTracking) {
202
234
  properties: { $set: traits },
203
235
  }));
204
236
  },
205
- flush: () => {
206
- return drainQueue().then(() => undefined);
207
- },
237
+ flush: flushSends,
208
238
  };
209
239
  }
210
240
  export function sendPostHogEvent(name, properties, distinctId) {
@@ -258,9 +288,7 @@ function createMixpanelProvider(token) {
258
288
  };
259
289
  enqueue("https://api.mixpanel.com/engage", JSON.stringify([data]));
260
290
  },
261
- flush: () => {
262
- return drainQueue().then(() => undefined);
263
- },
291
+ flush: flushSends,
264
292
  };
265
293
  }
266
294
  function stripExceptionContextForAmplitude(properties) {
@@ -307,9 +335,7 @@ function createAmplitudeProvider(apiKey) {
307
335
  };
308
336
  enqueue("https://api2.amplitude.com/2/httpapi", JSON.stringify(data));
309
337
  },
310
- flush: () => {
311
- return drainQueue().then(() => undefined);
312
- },
338
+ flush: flushSends,
313
339
  };
314
340
  }
315
341
  function createWebhookProvider(url, authHeader) {
@@ -334,9 +360,7 @@ function createWebhookProvider(url, authHeader) {
334
360
  timestamp: new Date().toISOString(),
335
361
  }), extra);
336
362
  },
337
- flush: () => {
338
- return drainQueue().then(() => undefined);
339
- },
363
+ flush: flushSends,
340
364
  };
341
365
  }
342
366
  function createAgentNativeAnalyticsProvider(publicKey, endpoint) {
@@ -364,9 +388,7 @@ function createAgentNativeAnalyticsProvider(publicKey, endpoint) {
364
388
  timestamp: new Date().toISOString(),
365
389
  }), undefined, { flushImmediately });
366
390
  },
367
- flush: () => {
368
- return drainQueue().then(() => undefined);
369
- },
391
+ flush: flushSends,
370
392
  };
371
393
  }
372
394
  let _registered = false;
@@ -32,5 +32,7 @@ export declare function evaluateCondition(condition: string | undefined, payload
32
32
  signal?: AbortSignal;
33
33
  engine?: AgentEngine;
34
34
  resolvedModel?: string;
35
+ /** Usage label for the check's model call, such as the automation's own. */
36
+ usageLabel?: string;
35
37
  }): Promise<boolean>;
36
38
  export declare function __clearConditionCache(): void;
@@ -27,6 +27,12 @@ import { getStoredModelForEngine, normalizeModelForEngine, resolveEngine, } from
27
27
  import { createTtlCache } from "../shared/ttl-cache.js";
28
28
  const CONDITION_EVAL_VERSION = "v4";
29
29
  const CONDITION_EVALUATION_TIMEOUT_MS = 15_000;
30
+ // How long a check cut off by its deadline may take to unwind its aborted
31
+ // stream, so the usage it reported is saved before the caller moves on.
32
+ const ABORTED_CHECK_UNWIND_MS = 1_000;
33
+ // How long the caller waits for the check's usage write. A write still
34
+ // running after this finishes on its own; it never rejects.
35
+ const USAGE_WRITE_WAIT_MS = 5_000;
30
36
  const CACHE_TTL_MS = 5 * 60 * 1000;
31
37
  const MAX_CACHE_SIZE = 500;
32
38
  const _cache = createTtlCache({
@@ -73,6 +79,9 @@ export async function evaluateCondition(condition, payload, identity, options =
73
79
  const controller = new AbortController();
74
80
  let timeoutId;
75
81
  let removeAbortListener;
82
+ // Saving what the check spent stays outside the deadline, so a slow write
83
+ // never turns an answer the model already gave into a timeout.
84
+ const usageWrites = [];
76
85
  const evaluate = async () => {
77
86
  let engine;
78
87
  let model;
@@ -115,7 +124,12 @@ export async function evaluateCondition(condition, payload, identity, options =
115
124
  return cached;
116
125
  }
117
126
  // A classification is an answer, not a write of the call that emitted the event.
118
- const result = await withoutWriteObservation(() => callClassifier(condition, payload, engine, model, controller.signal));
127
+ const result = await withoutWriteObservation(() => callClassifier(condition, payload, engine, model, controller.signal, {
128
+ ownerEmail: identity.userEmail,
129
+ ...(identity.orgId === undefined ? {} : { orgId: identity.orgId }),
130
+ ...(identity.appId === undefined ? {} : { app: identity.appId }),
131
+ label: options.usageLabel ?? "automation-condition",
132
+ }, usageWrites));
119
133
  if (controller.signal.aborted) {
120
134
  throw new Error(options.signal?.aborted
121
135
  ? "Condition evaluation aborted."
@@ -141,10 +155,11 @@ export async function evaluateCondition(condition, payload, identity, options =
141
155
  removeAbortListener = () => options.signal.removeEventListener("abort", onAbort);
142
156
  })
143
157
  : null;
158
+ const evaluation = evaluate();
144
159
  let result;
145
160
  try {
146
161
  result = await Promise.race([
147
- evaluate(),
162
+ evaluation,
148
163
  timeout,
149
164
  ...(aborted ? [aborted] : []),
150
165
  ]);
@@ -153,10 +168,54 @@ export async function evaluateCondition(condition, payload, identity, options =
153
168
  if (timeoutId)
154
169
  clearTimeout(timeoutId);
155
170
  removeAbortListener?.();
171
+ // A check cut off by the deadline queues its usage write only once its
172
+ // aborted stream unwinds, so give it a moment before collecting writes.
173
+ await settledWithin(evaluation, ABORTED_CHECK_UNWIND_MS);
174
+ await settledWithin(Promise.all(usageWrites), USAGE_WRITE_WAIT_MS);
156
175
  }
157
176
  return result;
158
177
  }
159
- async function callClassifier(condition, payload, engine, model, signal) {
178
+ /**
179
+ * Resolves when `promise` settles or `ms` passes, whichever is first. The
180
+ * timer is always cleared, so it needs no Node-only `unref` (Workers timers
181
+ * are plain numbers).
182
+ */
183
+ async function settledWithin(promise, ms) {
184
+ let timer;
185
+ try {
186
+ await Promise.race([
187
+ promise.then(() => undefined, () => undefined),
188
+ new Promise((resolve) => {
189
+ timer = setTimeout(resolve, ms);
190
+ }),
191
+ ]);
192
+ }
193
+ finally {
194
+ clearTimeout(timer);
195
+ }
196
+ }
197
+ /**
198
+ * Save what the check spent, as every other model call does, so usage
199
+ * dashboards and spend totals include it on whichever key paid for it.
200
+ * Never rejects, so callers can leave it running.
201
+ */
202
+ async function recordClassifierUsage(scope, usage) {
203
+ if (usage.inputTokens <= 0 &&
204
+ usage.outputTokens <= 0 &&
205
+ !usage.cacheReadTokens &&
206
+ !usage.cacheWriteTokens &&
207
+ usage.builderCreditsUsed == null) {
208
+ return;
209
+ }
210
+ try {
211
+ const { recordUsage } = await import("../usage/store.js");
212
+ await recordUsage({ ...scope, ...usage });
213
+ }
214
+ catch (error) {
215
+ console.error("[triggers] Failed to persist condition check usage:", error);
216
+ }
217
+ }
218
+ async function callClassifier(condition, payload, engine, model, signal, usageScope, usageWrites) {
160
219
  let payloadStr;
161
220
  try {
162
221
  payloadStr = JSON.stringify(payload, null, 2);
@@ -181,6 +240,13 @@ Condition: "${condition}"
181
240
  Does the event payload satisfy the condition above? Respond with ONLY "yes" or "no".`;
182
241
  let text = "";
183
242
  let streamErrorMessage;
243
+ const usage = {
244
+ inputTokens: 0,
245
+ outputTokens: 0,
246
+ cacheReadTokens: 0,
247
+ cacheWriteTokens: 0,
248
+ builderCreditsUsed: undefined,
249
+ };
184
250
  try {
185
251
  const stream = engine.stream({
186
252
  model,
@@ -196,6 +262,16 @@ Does the event payload satisfy the condition above? Respond with ONLY "yes" or "
196
262
  if (event.type === "text-delta") {
197
263
  text += event.text;
198
264
  }
265
+ else if (event.type === "usage") {
266
+ usage.inputTokens += event.inputTokens;
267
+ usage.outputTokens += event.outputTokens;
268
+ usage.cacheReadTokens += event.cacheReadTokens ?? 0;
269
+ usage.cacheWriteTokens += event.cacheWriteTokens ?? 0;
270
+ if (event.builderCreditsUsed !== undefined) {
271
+ usage.builderCreditsUsed =
272
+ (usage.builderCreditsUsed ?? 0) + event.builderCreditsUsed;
273
+ }
274
+ }
199
275
  else if (event.type === "stop" && event.reason === "error") {
200
276
  streamErrorMessage = event.error ?? "model error";
201
277
  }
@@ -209,6 +285,20 @@ Does the event payload satisfy the condition above? Respond with ONLY "yes" or "
209
285
  ? `Condition evaluation failed: ${err.message}`
210
286
  : "Condition evaluation failed: unknown error");
211
287
  }
288
+ finally {
289
+ // A check that failed or timed out after the model answered still spent.
290
+ usageWrites.push(recordClassifierUsage(usageScope, {
291
+ inputTokens: usage.inputTokens,
292
+ outputTokens: usage.outputTokens,
293
+ cacheReadTokens: usage.cacheReadTokens,
294
+ cacheWriteTokens: usage.cacheWriteTokens,
295
+ ...(usage.builderCreditsUsed === undefined
296
+ ? {}
297
+ : { builderCreditsUsed: usage.builderCreditsUsed }),
298
+ engineName: engine.name,
299
+ model,
300
+ }));
301
+ }
212
302
  if (streamErrorMessage) {
213
303
  throw new Error(`Condition evaluation failed: ${streamErrorMessage}`);
214
304
  }
@@ -2,7 +2,7 @@ import { automationMatchesEventOwner, resolveAutomationExecutionIdentity, } from
2
2
  import { isProductionServerlessFunctionRuntime } from "../db/client.js";
3
3
  import { subscribeAll, unsubscribe } from "../event-bus/index.js";
4
4
  import { countAutomationCredentialState, trackAutomationPaused, } from "../jobs/automation-events.js";
5
- import { applyAutomationFailure, classifyAutomationFailure, CLEAR_FAILURE_STATE, CONFIG_INVALID_ERROR_CODE, OWNER_MISSING_ERROR_CODE, pauseNow, } from "../jobs/automation-outcome.js";
5
+ import { applyAutomationFailure, classifyAutomationFailure, CLEAR_FAILURE_STATE, CONFIG_INVALID_ERROR_CODE, isTransientAutomationFailureCode, OWNER_MISSING_ERROR_CODE, pauseNow, } from "../jobs/automation-outcome.js";
6
6
  import { BackgroundAutomationRunError, checkBackgroundAutomationCredentials, isBackgroundAutomationRunActive, runBackgroundAutomation, validateAutomationRunIdentity, } from "../jobs/background-automation-runner.js";
7
7
  import { buildJobResourceContent, jobBelongsToApp, parseJobResource, patchJobFrontmatterFields, } from "../jobs/frontmatter.js";
8
8
  import { registerRecurringSweepHandler, } from "../jobs/sweep-hooks.js";
@@ -121,6 +121,13 @@ Execute the following automation instructions, and only these:
121
121
 
122
122
  ${input.body}`;
123
123
  }
124
+ function automationNameOf(resource) {
125
+ return resource.path.replace(/^jobs\//, "").replace(/\.md$/, "");
126
+ }
127
+ /** One label for a run and its condition check, so both count as its spend. */
128
+ function automationUsageLabel(resource) {
129
+ return `automation:${automationNameOf(resource)}`;
130
+ }
124
131
  async function recordTriggerSkip(resource, status, reason, errorCode) {
125
132
  await recordTriggerExecutionOutcome(resource, {
126
133
  lastCheck: new Date().toISOString(),
@@ -193,7 +200,7 @@ failed) {
193
200
  countAutomationCredentialState(failed.failure.code);
194
201
  if (failed && pausedAfter !== undefined) {
195
202
  trackAutomationPaused({
196
- name: resource.path.replace(/^jobs\//, "").replace(/\.md$/, ""),
203
+ name: automationNameOf(resource),
197
204
  failure: failed.failure,
198
205
  consecutiveFailures: pausedAfter,
199
206
  surface: failed.pauseImmediately ? "preflight" : "trigger",
@@ -979,7 +986,13 @@ async function dispatchQueuedAutomationEvent(queued, deps, hardDeadlineAt) {
979
986
  : {}),
980
987
  }, deps, meta.model);
981
988
  if (!credentialCheck.ok) {
982
- await recordTriggerExecutionOutcome(resource, { lastCheck: new Date().toISOString() }, { failure: credentialCheck.failure });
989
+ await recordTriggerExecutionOutcome(resource, { lastCheck: new Date().toISOString() }, { failure: credentialCheck.failure, eventId: queued.eventId });
990
+ // An unreadable credential store or a provider outage clears by itself,
991
+ // so the event stays queued, as it does when an unconditional run fails.
992
+ // A missing or rejected key does not, so the event ends here.
993
+ if (isTransientAutomationFailureCode(credentialCheck.failure.code)) {
994
+ throw new BackgroundAutomationRunError(credentialCheck.failure.message, credentialCheck.failure.code);
995
+ }
983
996
  return "completed";
984
997
  }
985
998
  classifierEngine = credentialCheck.engine;
@@ -1004,6 +1017,7 @@ async function dispatchQueuedAutomationEvent(queued, deps, hardDeadlineAt) {
1004
1017
  deadlineAt: hardDeadlineAt,
1005
1018
  engine: classifierEngine,
1006
1019
  resolvedModel: classifierModel,
1020
+ usageLabel: automationUsageLabel(resource),
1007
1021
  }));
1008
1022
  }
1009
1023
  catch (error) {
@@ -1099,7 +1113,11 @@ export async function dispatchAutomationWebhookTask(task) {
1099
1113
  userEmail: identity.userEmail,
1100
1114
  orgId: identity.orgId,
1101
1115
  appId: deps.appId,
1102
- }, { engine: classifierEngine, resolvedModel: classifierModel }));
1116
+ }, {
1117
+ engine: classifierEngine,
1118
+ resolvedModel: classifierModel,
1119
+ usageLabel: automationUsageLabel(resource),
1120
+ }));
1103
1121
  }
1104
1122
  catch (err) {
1105
1123
  const reason = err instanceof Error ? err.message : "Condition evaluation failed";
@@ -1136,7 +1154,7 @@ export async function dispatchAutomationWebhookTask(task) {
1136
1154
  async function dispatchAgentic(resource, payload, eventMeta, identity, hardDeadlineAt) {
1137
1155
  if (!_deps)
1138
1156
  return false;
1139
- const triggerName = resource.path.replace(/^jobs\//, "").replace(/\.md$/, "");
1157
+ const triggerName = automationNameOf(resource);
1140
1158
  const now = new Date();
1141
1159
  const jobUserEmail = identity.userEmail;
1142
1160
  const jobOrgId = identity.orgId;
@@ -1249,7 +1267,7 @@ async function dispatchAgentic(resource, payload, eventMeta, identity, hardDeadl
1249
1267
  }),
1250
1268
  threadTitle: `Trigger: ${triggerName} — ${now.toLocaleDateString()}`,
1251
1269
  runIdPrefix: `automation-${triggerName}`,
1252
- usageLabel: `automation:${triggerName}`,
1270
+ usageLabel: automationUsageLabel(resource),
1253
1271
  usageRefId: eventMeta.eventId,
1254
1272
  eventId: eventMeta.eventId,
1255
1273
  requestContext,
package/docs/AGENTS.md CHANGED
@@ -66,22 +66,22 @@ preserve public action, state, auth, and agent-chat runtime contracts.
66
66
 
67
67
  ## What To Read First
68
68
 
69
- | Task | Start with |
70
- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
71
- | Define or call app operations | `content/actions.md`, `content/client.md` |
72
- | Add PostgreSQL data, schema, or access rules | `content/server-database.mdx`, `content/security.md`, `content/sharing.md` |
73
- | Keep the UI and agent in sync | `content/context-awareness.md`, `content/client.md` |
74
- | Localize UI copy and language choices | `content/internationalization.md`, `content/client.md` |
75
- | Build headless or chat-first apps | `content/pure-agent-apps.md`, `content/agent-surfaces.md`, `content/using-your-agent.md` |
76
- | Add automations or scheduled work | `content/automations.md`, `content/recurring-jobs.md` |
77
- | Compose apps or call sibling agents | `content/a2a-protocol.md`, `content/multi-app-workspace.md`, `content/agent-resources.mdx` |
78
- | Expose tools to external agents | `content/external-agents.md`, `content/mcp-protocol.md`, `content/mcp-apps.md`, `content/mcp-clients.md` |
79
- | Add integrations, setup, or secrets | `content/onboarding.md`, `content/workspace-connections.md`, `content/security.md` |
80
- | Build extensions or custom widgets | `content/extensions.md`, `content/agent-web-surfaces.md` |
81
- | Deploy or configure hosting | `content/deployment.md`, `content/server-overview.md` |
82
- | Write agent instructions or skills | `content/skills-guide.md`, `content/writing-agent-instructions.md` |
83
- | Plan a breaking package API change | `migrations/README.md` |
84
- | Migrate the Core chat API to AgentKit | `migrations/agentkit-chat.md`, `content/native-chat-ui.mdx` |
69
+ | Task | Start with |
70
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
71
+ | Define or call app operations | `content/actions-overview.mdx`, `content/client-overview.mdx` |
72
+ | Add PostgreSQL data, schema, or access rules | `content/server-database.mdx`, `content/security.mdx`, `content/sharing.mdx` |
73
+ | Keep the UI and agent in sync | `content/context-awareness.mdx`, `content/client-overview.mdx` |
74
+ | Localize UI copy and language choices | `content/internationalization.mdx`, `content/client-overview.mdx` |
75
+ | Build headless or chat-first apps | `content/pure-agent-apps.mdx`, `content/agent-surfaces.mdx`, `content/using-your-agent.mdx` |
76
+ | Add automations or scheduled work | `content/automations.mdx`, `content/recurring-jobs.mdx` |
77
+ | Compose apps or call sibling agents | `content/a2a-protocol.mdx`, `content/multi-app-workspace.mdx`, `content/agent-resources.mdx` |
78
+ | Expose tools to external agents | `content/external-agents.mdx`, `content/mcp-protocol.mdx`, `content/mcp-apps.mdx`, `content/mcp-clients.mdx` |
79
+ | Add integrations, setup, or secrets | `content/onboarding.mdx`, `content/workspace-connections.mdx`, `content/security.mdx` |
80
+ | Build extensions or custom widgets | `content/extensions.mdx`, `content/agent-web-surfaces.mdx` |
81
+ | Deploy or configure hosting | `content/deployment.mdx`, `content/server-overview.mdx` |
82
+ | Write agent instructions or skills | `content/skills-guide.mdx`, `content/writing-agent-instructions.mdx` |
83
+ | Plan a breaking package API change | `migrations/README.md` |
84
+ | Migrate the Core chat API to AgentKit | `migrations/agentkit-chat.md`, `content/native-chat-ui.mdx` |
85
85
 
86
86
  ## Rules
87
87
 
@@ -36,10 +36,14 @@ it. Use that name wherever a step below says APP_NAME.
36
36
  4. Read APP_NAME/AGENTS.md so you know how the app is laid out.
37
37
  5. Start the app with `pnpm dev --no-open` and keep it running in the background.
38
38
  6. Read the local URL that `pnpm dev` prints. Check that it responds,
39
- for example with `curl -I <url>`. Do not open a browser and do not
40
- use headless browser tools. I will open the app myself.
41
- 7. Show me the result. Give me the local URL and the response you got
42
- from it. Do not just say that setup is complete.
39
+ with `curl "<url>/_agent-native/ping?configuration=1"`. The reply
40
+ should include `"message":"pong"` and a configuration status of
41
+ `"ok"`. Do not open a browser and do not use headless browser
42
+ tools. I will open the app myself.
43
+ 7. In the APP_NAME folder, run `pnpm action hello`. It runs the
44
+ app's sample action and should print "Hello, world!".
45
+ 8. Show me the result. Give me the local URL and what both checks
46
+ printed. Do not just say that setup is complete.
43
47
 
44
48
  Rules:
45
49
 
@@ -57,6 +61,8 @@ A good run ends with these results, and you can check each one yourself:
57
61
  - A new app folder, named `my-app` unless you changed it, that contains `actions/`, `app/`, and an `AGENTS.md` file.
58
62
  - `pnpm dev` running without errors.
59
63
  - A local URL, usually `http://localhost:8080`, that the agent has checked responds.
64
+ - A reply from `<url>/_agent-native/ping?configuration=1` with a configuration status of `ok`.
65
+ - `pnpm action hello` printing "Hello, world!".
60
66
 
61
67
  Then open the local URL in your own browser. On the Welcome screen, choose **Continue as local dev**. The Get started page appears.
62
68
 
@@ -83,7 +83,7 @@ When a run resumes (soft timeout, gateway timeout, or any resumable transport er
83
83
  - which tool calls **already completed** (with short results) so it reuses them and does **not** re-run them, and
84
84
  - which tool calls were **interrupted with unknown outcome** so it verifies state before assuming success or failure.
85
85
 
86
- When the journal is empty (a turn with no tool activity, or a clean continuation), nothing extra is appended and resume behavior is byte-for-byte what it was before. The note is best-effort: a failed ledger read never blocks a recovery that would otherwise succeed.
86
+ When the journal is empty (a turn with no tool activity, or a clean continuation), nothing extra is appended and resume behavior is byte-for-byte what it was before. The journal itself is required, though: if the ledger can't be read, even after one retry, the resumed run stops before the model runs, with `tool_call_journal_unreadable`, and asks you to send the request again. Without the ledger it can't tell which steps already finished.
87
87
 
88
88
  ## Layer 2: tool-layer hard-block {#hard-block}
89
89
 
@@ -103,13 +103,22 @@ Key properties:
103
103
  The hard-block stops only an **exact repeat**: the same write tool with the same input, after an earlier attempt in the same turn finished and recorded its result. It does not rule out every duplicate email, charge, or ticket:
104
104
 
105
105
  - **A reworded retry isn't matched.** If the model changes any argument, such as the email body, the call no longer matches a completed call, so the hard-block doesn't stop it.
106
- - **A crash mid-send can repeat.** If the process dies after the side effect lands but before its result is recorded, the call counts as interrupted, and the hard-block covers only completed calls. A separate guard refuses an identical retry of an interrupted write, but only when the resumed run's history shows the interruption.
106
+ - **After a crash mid-send, a repeat is held back only for the rest of the turn.** If the process dies after the side effect lands but before its result is recorded, the call counts as interrupted, and the hard-block covers only completed calls. The resumed run holds back that write tool instead, as [Writes with an unknown outcome](#unknown-outcome) describes. A new message starts a new turn, where nothing holds it back.
107
107
  - **A crashed run can resume in the same turn through either of two separate paths.** One is a [durable background run](/docs/durable-background-runs). The other is a later chunk of a normal foreground turn using the [foreground self-chain](/docs/durable-background-runs#foreground-self-chain). Any other crashed run ends with an error. Choosing **Continue** on it resumes the same turn, so both layers apply.
108
108
 
109
109
  An action whose side effect must never repeat needs its own idempotency check. The hard-block never applies to read-only actions.
110
110
 
111
111
  </Callout>
112
112
 
113
+ ## Writes with an unknown outcome {#unknown-outcome}
114
+
115
+ A write's outcome is unknown when it started but no result was recorded, when it timed out, or when it failed after it began writing without saying whether anything was saved. Running it again could apply the change twice, so for the rest of the turn the loop holds back that tool:
116
+
117
+ - **The same call again.** If the model repeats the call with the same arguments, the loop first checks whether that call's own result was recorded after all, and returns it if so. Otherwise the call doesn't run: the run stops with `write_tool_outcome_unknown` and asks you to check what was saved, then reply "continue".
118
+ - **Any other call to that tool.** A call with different arguments, such as a reworded email, doesn't run either. The model is told that the earlier call may already have applied, and to check the current state with a read and tell you what it found. The turn goes on, unless the earlier call can't be identified because it recorded no arguments to compare or was left out of the replayed history. Then any call to that tool could be a repeat, so it stops the run the way the same call does.
119
+
120
+ An action marked `retrySafe` is exempt, because running it again can't duplicate or corrupt data: it validates before it writes, or it sets an end state, such as replacing a record by ID. The model can correct its arguments and try again. Read-only actions aren't held back unless a write ran inside one.
121
+
113
122
  ## Automatic continuation after a time limit {#auto-continue}
114
123
 
115
124
  When the server stops a turn at its run time limit and nothing server-side picks it up, the chat continues it on its own, in the **same turn**. The resumed run gets the thread's tool calls and results plus the journal note above, so finished steps are reused, and an identical repeat of a write — including a cross-app `call-agent` delegation, whose idempotency key is derived from the turn — returns the recorded result instead of being sent again. The chat shows a _Resuming_ status, and Stop keeps working.
@@ -75,6 +75,58 @@ If the resource is already registered with the Sharing Kit, the review actions
75
75
  reuse that access model. Add a review registration when the app needs a custom
76
76
  access resolver or wants to make the resource type explicit in setup code.
77
77
 
78
+ ## App Rules {#app-rules}
79
+
80
+ A registration can also set how comments behave on its resource:
81
+
82
+ ```ts
83
+ registerReviewableResource({
84
+ type: "deck",
85
+ comments: { minimumRole: "viewer", unmentionedRoute: "human" },
86
+ anchorKinds: {
87
+ "slide-point": { version: 2, parse: parseSlidePoint },
88
+ },
89
+ async onCommentWrite(event, tx) {
90
+ await tx.execute(/* keep the deck's comment count in step */);
91
+ },
92
+ });
93
+ ```
94
+
95
+ - `comments.minimumRole` is the lowest role that may comment, reply, react and
96
+ edit or delete their own comments (default `commenter`). Suggesting needs it
97
+ too, and never less than `commenter`.
98
+ `comments.unmentionedRoute` sends a new thread with no mention to the agent
99
+ (the default) or to people.
100
+ - `anchorKinds` types the anchor. Once a resource declares any, every anchored
101
+ comment names its `anchorKind`, and `parse(anchor, version)` checks it and
102
+ returns what's stored, so an older version can be read forward. A comment is
103
+ written at the kind's current `version` unless it gives an older one. A
104
+ suggestion's thread uses the kit's own `review-suggestion` kind, which
105
+ every resource accepts and only the kit writes: the comment actions refuse
106
+ it.
107
+ - `onCommentWrite` runs inside the comment's own database transaction for
108
+ `created`, `edited`, `anchor-moved`, `resolved`, `reopened` and `deleted`.
109
+ Resolving a thread that is already resolved, or reopening an open one,
110
+ fires no event, though a new resolution note is still saved. Throwing
111
+ refuses the write. Use the `tx` handle for database work there. A database
112
+ client without transactions refuses a hooked write.
113
+
114
+ Every comment also carries a `surfaceId` (the slide, page or screen a thread
115
+ sits on; `list-review-comments` filters by it) and a `revision` that goes up
116
+ when it changes, including a thread's routing on its first comment. Pass the
117
+ revision you read as `expectedRevision` to
118
+ `update-review-comment` or `update-review-comment-anchor`, and a newer one
119
+ refuses the write with `revision_conflict`. `list-review-comments` pages oldest
120
+ first: pass back its `nextCursor` as `cursor` to read past the `limit` of 500.
121
+ A cursor resumes only the list it came from, at any `limit`; with another
122
+ resource or filter it's refused with `invalid_cursor`. A newest-first list isn't
123
+ paged and returns no `nextCursor`.
124
+
125
+ `linkReviewComment()` ties a comment to an ID in another system, such as a
126
+ Notion discussion, so an import skips what it already copied.
127
+ `findReviewCommentLink()` and `listReviewCommentLinks()` read those links back.
128
+ An ID links to one comment, and a link outlives the comment's deletion.
129
+
78
130
  ## UI Usage {#ui-usage}
79
131
 
80
132
  ```tsx
@@ -96,7 +148,8 @@ export function DocumentReview({ documentId }: { documentId: string }) {
96
148
 
97
149
  The `anchor` field is intentionally `unknown`: editors, canvases, diagrams, and
98
150
  documents can store the shape they need while still using the shared review
99
- actions and thread UI.
151
+ actions and thread UI. Declare [anchor kinds](#app-rules) to have the kit check
152
+ that shape for you.
100
153
 
101
154
  ## UX Standard {#ux-standard}
102
155