@trigger.dev/sdk 4.6.4 → 4.7.1

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 (123) hide show
  1. package/dist/commonjs/v3/ai.d.ts +5 -2
  2. package/dist/commonjs/v3/ai.js +189 -266
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/chat-client.js +7 -0
  5. package/dist/commonjs/v3/chat-client.js.map +1 -1
  6. package/dist/commonjs/v3/chat-server.d.ts +1 -0
  7. package/dist/commonjs/v3/chat-server.js +8 -0
  8. package/dist/commonjs/v3/chat-server.js.map +1 -1
  9. package/dist/commonjs/v3/chat.d.ts +28 -4
  10. package/dist/commonjs/v3/chat.js +41 -9
  11. package/dist/commonjs/v3/chat.js.map +1 -1
  12. package/dist/commonjs/v3/chatRouteWait.d.ts +21 -0
  13. package/dist/commonjs/v3/chatRouteWait.js +43 -0
  14. package/dist/commonjs/v3/chatRouteWait.js.map +1 -0
  15. package/dist/commonjs/v3/compactionResponse.js +5 -0
  16. package/dist/commonjs/v3/compactionResponse.js.map +1 -1
  17. package/dist/commonjs/v3/concurrency-shared.d.ts +13 -0
  18. package/dist/commonjs/v3/concurrency-shared.js +35 -0
  19. package/dist/commonjs/v3/concurrency-shared.js.map +1 -0
  20. package/dist/commonjs/v3/concurrencyLimits.d.ts +73 -0
  21. package/dist/commonjs/v3/concurrencyLimits.js +166 -0
  22. package/dist/commonjs/v3/concurrencyLimits.js.map +1 -0
  23. package/dist/commonjs/v3/index.d.ts +2 -1
  24. package/dist/commonjs/v3/index.js +3 -1
  25. package/dist/commonjs/v3/index.js.map +1 -1
  26. package/dist/commonjs/v3/managedChatResponse.d.ts +44 -0
  27. package/dist/commonjs/v3/managedChatResponse.js +233 -0
  28. package/dist/commonjs/v3/managedChatResponse.js.map +1 -0
  29. package/dist/commonjs/v3/queues.d.ts +31 -0
  30. package/dist/commonjs/v3/queues.js +31 -0
  31. package/dist/commonjs/v3/queues.js.map +1 -1
  32. package/dist/commonjs/v3/shared.d.ts +18 -1
  33. package/dist/commonjs/v3/shared.js +137 -47
  34. package/dist/commonjs/v3/shared.js.map +1 -1
  35. package/dist/commonjs/v3/steeringContext.d.ts +41 -0
  36. package/dist/commonjs/v3/steeringContext.js +118 -0
  37. package/dist/commonjs/v3/steeringContext.js.map +1 -0
  38. package/dist/commonjs/v3/transcriptStorage.d.ts +4 -1
  39. package/dist/commonjs/v3/transcriptStorage.js +51 -4
  40. package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
  41. package/dist/commonjs/version.js +1 -1
  42. package/dist/esm/v3/ai.d.ts +5 -2
  43. package/dist/esm/v3/ai.js +189 -266
  44. package/dist/esm/v3/ai.js.map +1 -1
  45. package/dist/esm/v3/chat-client.js +7 -0
  46. package/dist/esm/v3/chat-client.js.map +1 -1
  47. package/dist/esm/v3/chat-server.d.ts +1 -0
  48. package/dist/esm/v3/chat-server.js +8 -0
  49. package/dist/esm/v3/chat-server.js.map +1 -1
  50. package/dist/esm/v3/chat.d.ts +28 -4
  51. package/dist/esm/v3/chat.js +41 -9
  52. package/dist/esm/v3/chat.js.map +1 -1
  53. package/dist/esm/v3/chatRouteWait.d.ts +21 -0
  54. package/dist/esm/v3/chatRouteWait.js +40 -0
  55. package/dist/esm/v3/chatRouteWait.js.map +1 -0
  56. package/dist/esm/v3/compactionResponse.js +5 -0
  57. package/dist/esm/v3/compactionResponse.js.map +1 -1
  58. package/dist/esm/v3/concurrency-shared.d.ts +13 -0
  59. package/dist/esm/v3/concurrency-shared.js +31 -0
  60. package/dist/esm/v3/concurrency-shared.js.map +1 -0
  61. package/dist/esm/v3/concurrencyLimits.d.ts +73 -0
  62. package/dist/esm/v3/concurrencyLimits.js +158 -0
  63. package/dist/esm/v3/concurrencyLimits.js.map +1 -0
  64. package/dist/esm/v3/index.d.ts +2 -1
  65. package/dist/esm/v3/index.js +2 -1
  66. package/dist/esm/v3/index.js.map +1 -1
  67. package/dist/esm/v3/managedChatResponse.d.ts +44 -0
  68. package/dist/esm/v3/managedChatResponse.js +228 -0
  69. package/dist/esm/v3/managedChatResponse.js.map +1 -0
  70. package/dist/esm/v3/queues.d.ts +31 -0
  71. package/dist/esm/v3/queues.js +31 -0
  72. package/dist/esm/v3/queues.js.map +1 -1
  73. package/dist/esm/v3/shared.d.ts +18 -1
  74. package/dist/esm/v3/shared.js +136 -47
  75. package/dist/esm/v3/shared.js.map +1 -1
  76. package/dist/esm/v3/steeringContext.d.ts +41 -0
  77. package/dist/esm/v3/steeringContext.js +113 -0
  78. package/dist/esm/v3/steeringContext.js.map +1 -0
  79. package/dist/esm/v3/transcriptStorage.d.ts +4 -1
  80. package/dist/esm/v3/transcriptStorage.js +51 -4
  81. package/dist/esm/v3/transcriptStorage.js.map +1 -1
  82. package/dist/esm/version.js +1 -1
  83. package/docs/ai-chat/client-protocol.mdx +3 -1
  84. package/docs/ai-chat/error-handling.mdx +44 -76
  85. package/docs/ai-chat/fast-starts.mdx +1 -1
  86. package/docs/ai-chat/frontend.mdx +27 -21
  87. package/docs/ai-chat/patterns/branching-conversations.mdx +95 -230
  88. package/docs/ai-chat/patterns/human-in-the-loop.mdx +166 -164
  89. package/docs/ai-chat/patterns/tool-result-auditing.mdx +28 -27
  90. package/docs/ai-chat/patterns/version-upgrades.mdx +4 -4
  91. package/docs/ai-chat/pending-messages.mdx +19 -5
  92. package/docs/ai-chat/quick-start.mdx +26 -20
  93. package/docs/ai-chat/reference.mdx +21 -3
  94. package/docs/ai-chat/sessions.mdx +1 -1
  95. package/docs/ai-chat/testing.mdx +16 -4
  96. package/docs/concurrency.mdx +384 -0
  97. package/docs/config/config-file.mdx +2 -0
  98. package/docs/database-connections.mdx +3 -3
  99. package/docs/deploy-environment-variables.mdx +6 -0
  100. package/docs/deployment/atomic-deployment.mdx +416 -132
  101. package/docs/deployment/overview.mdx +2 -2
  102. package/docs/deployment/preview-branches.mdx +2 -2
  103. package/docs/github-actions.mdx +26 -16
  104. package/docs/github-integration.mdx +2 -2
  105. package/docs/idempotency.mdx +43 -5
  106. package/docs/introduction.mdx +1 -1
  107. package/docs/limits.mdx +16 -6
  108. package/docs/manual-setup.mdx +1 -1
  109. package/docs/observability/query.mdx +25 -0
  110. package/docs/queues.mdx +271 -0
  111. package/docs/reports.mdx +1 -1
  112. package/docs/runs/priority.mdx +2 -25
  113. package/docs/self-hosting/env/webapp.mdx +9 -0
  114. package/docs/self-hosting/kubernetes.mdx +14 -3
  115. package/docs/tasks/overview.mdx +3 -5
  116. package/docs/troubleshooting-alerts.mdx +124 -1
  117. package/docs/troubleshooting.mdx +12 -0
  118. package/docs/vercel-integration.mdx +6 -7
  119. package/docs/versioning.mdx +1 -1
  120. package/docs/writing-tasks-introduction.mdx +2 -1
  121. package/package.json +2 -2
  122. package/docs/deployment/version-skew-protection.mdx +0 -492
  123. package/docs/queue-concurrency.mdx +0 -358
@@ -1,52 +1,49 @@
1
1
  ---
2
2
  title: "Human-in-the-loop"
3
3
  sidebarTitle: "Human-in-the-loop"
4
- description: "Pause the agent mid-response to ask the user a clarifying question, then resume with their answer."
4
+ description: "Ask users to choose an option or approve a tool call, then continue the agent with their response."
5
5
  ---
6
6
 
7
- Some turns need to stop and ask the user something before they can finish — picking between options, confirming a destructive action, or clarifying an ambiguous request. The AI SDK calls this **human-in-the-loop** (HITL), and the building block is a tool with no `execute` function.
7
+ **A chat agent can wait for a user to supply a tool result or approve a tool before it runs.** Choose the pattern based on what the user needs to provide:
8
8
 
9
- When the LLM calls a tool that has no `execute`, `streamText` ends with the tool call still pending. The turn completes cleanly, the frontend renders UI to collect the answer, and when the user responds, a new turn resumes with the answer merged into the same assistant message.
9
+ | User interaction | Tool definition | Frontend response |
10
+ | --- | --- | --- |
11
+ | Answer a question or choose a candidate | Omit `execute` | `addToolOutput` supplies the result |
12
+ | Approve or deny an action with known inputs | Set `needsApproval: true` and keep `execute` | `addToolApprovalResponse` authorizes or denies execution |
13
+
14
+ The examples below use AI SDK 6 or later and build on the [Quick start](/ai-chat/quick-start), including its session and token server actions. For approve/deny buttons, see [Approve a tool before execution](#approve-a-tool-before-execution).
10
15
 
11
16
  ## How it works
12
17
 
13
- ```
14
- Turn N:
15
- User message → run()
16
- LLM streams text → calls askUser tool (no execute)
17
- streamText ends with tool-call in `input-available` state
18
- onTurnComplete fires (finishReason = "tool-calls")
19
- Agent suspends (compute freed) — maxDuration does not tick while paused
20
-
21
- Frontend:
22
- Renders question + option buttons from tool input
23
- User clicks → addToolOutput({ tool, toolCallId, output })
24
- sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls
25
- → sendMessage() fires next turn
26
-
27
- Turn N+1:
28
- hydrateMessages / accumulator sees the updated assistant message
29
- run() is called, LLM continues from the tool result
30
- onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
18
+ When the model calls a tool without `execute`, `streamText` finishes with a pending tool call. Your frontend renders the question. Calling `addToolOutput` supplies the answer, and `sendAutomaticallyWhen` starts the next turn.
19
+
20
+ ```mermaid
21
+ sequenceDiagram
22
+ participant Agent
23
+ participant UI as Your frontend
24
+ participant User
25
+ Agent->>UI: askUser tool call (input-available)
26
+ UI->>User: Show question and options
27
+ User->>UI: Choose an option
28
+ UI->>Agent: addToolOutput + automatic send
29
+ Agent->>UI: Continue with the answer
31
30
  ```
32
31
 
33
- The AI SDK's `toUIMessageStream` automatically reuses the assistant message ID across the pause (we pass `originalMessages` internally), so `responseMessage` in the post-resume `onTurnComplete` is the **full merged message** — the original text, the completed tool call, and any follow-up content — not just the new parts.
32
+ The assistant message keeps its ID across the pause. After resuming, `onTurnComplete` receives the full merged `responseMessage`, including the question, tool result, and follow-up text.
34
33
 
35
34
  ## Duration and cost while paused
36
35
 
37
- A pause doesn't hold compute. After the model calls a no-execute tool, the turn finishes and the run stays warm for `idleTimeoutInSeconds` (default 30s), then **suspends** and frees its compute, the same way [`wait.for`](/wait-for) does. The user's `addToolOutput` wakes it back up.
38
-
39
- Because the run is suspended while it waits, the human's thinking time is not billed and does **not** count against [`maxDuration`](/runs/max-duration). `maxDuration` measures active CPU time and excludes suspended waitpoint time, exactly like `wait.for`, so a user can take minutes, hours, or days to answer without the run hitting `maxDuration`. The only time that counts is each turn's actual compute plus the short warm window before each suspend.
36
+ After the turn finishes, the run stays active for `idleTimeoutInSeconds` (30 seconds by default), then suspends and releases compute. Set it to `0` to suspend immediately. The active idle window counts toward compute usage and [`maxDuration`](/runs/max-duration); suspended time doesn't.
40
37
 
41
- You don't need to raise `maxDuration` or end the run to support long human waits. How long a single suspended pause stays open is governed by the run's suspend timeout, not `maxDuration`; if a wait outlives it the run ends, and the next `addToolOutput` boots a fresh continuation that picks up the resolved tool result.
38
+ [`turnTimeout`](/ai-chat/reference#chatagent) controls how long a run waits for the next message (default `"1h"`). When it expires, the run ends. A later answer starts a continuation run that restores the conversation from [transcript storage](/ai-chat/transcript-storage). Raising `maxDuration` isn't necessary for time spent suspended.
42
39
 
43
40
  ## Backend: define the tool
44
41
 
45
- A HITL tool has an `inputSchema` describing what the model can ask, but **no `execute` function**. When the LLM calls it, `streamText` returns control to your agent.
42
+ Define an `inputSchema` for the question and omit `execute` so the frontend can supply the answer. When the model calls `askUser`, `streamText` returns control to your agent.
46
43
 
47
44
  ```ts trigger/my-chat.ts
48
- import { chat } from "@trigger.dev/sdk/ai";
49
- import { streamText, tool, stepCountIs } from "ai";
45
+ import { chat, type InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
46
+ import { tool, stepCountIs } from "ai";
50
47
  import { anthropic } from "@ai-sdk/anthropic";
51
48
  import { z } from "zod";
52
49
 
@@ -67,14 +64,17 @@ const askUser = tool({
67
64
  .min(2)
68
65
  .max(4),
69
66
  }),
70
- // No execute function — streamText ends, the frontend supplies the output
71
- // via addToolOutput, and the next turn continues from the result.
67
+ outputSchema: z.object({ optionId: z.string(), label: z.string() }),
68
+ // The frontend supplies the result with addToolOutput.
72
69
  });
73
70
 
74
- export const myChat = chat.agent({
71
+ const tools = { askUser };
72
+ export type ChatMessage = InferChatUIMessageFromTools<typeof tools>;
73
+
74
+ export const myChat = chat.withUIMessage<ChatMessage>().agent({
75
75
  id: "my-chat",
76
- tools: { askUser },
77
- run: async ({ messages, tools, signal }) => {
76
+ tools,
77
+ run: async ({ messages, tools, signal, streamText }) => {
78
78
  return streamText({
79
79
  model: anthropic("claude-sonnet-4-5"),
80
80
  messages,
@@ -90,190 +90,192 @@ Declaring `tools` on the config (and reading them back from the payload) is the
90
90
 
91
91
  ## Frontend: render the question and collect the answer
92
92
 
93
- Two pieces on the client:
93
+ Render the question when its tool part reaches `input-available`. Use the AI SDK's [`lastAssistantMessageIsCompleteWithToolCalls`](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage) helper to submit the answer automatically once every pending tool call has a result.
94
94
 
95
- 1. **UI for the pending tool call** — render when the tool part is in `input-available` state, i.e. the LLM has called the tool but there's no output yet.
96
- 2. **Auto-send on resolution** — use `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` so answering kicks off the next turn without the user having to hit "send."
95
+ ```tsx app/components/chat.tsx
96
+ "use client";
97
97
 
98
- ```tsx
99
- import { useChat, lastAssistantMessageIsCompleteWithToolCalls } from "@ai-sdk/react";
98
+ import { useChat } from "@ai-sdk/react";
99
+ import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
100
100
  import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
101
+ import { mintChatAccessToken, startChatSession } from "@/app/actions";
102
+ import type { ChatMessage, myChat } from "@/trigger/my-chat";
101
103
 
102
- function ChatView({ chatId }: { chatId: string }) {
103
- const transport = useTriggerChatTransport({
104
+ export function ChatView({ chatId }: { chatId: string }) {
105
+ const transport = useTriggerChatTransport<typeof myChat>({
104
106
  task: "my-chat",
105
107
  accessToken: ({ chatId }) => mintChatAccessToken(chatId),
106
108
  startSession: ({ chatId, clientData }) =>
107
109
  startChatSession({ chatId, clientData }),
108
110
  });
109
- const { messages, sendMessage, addToolOutput } = useChat({
111
+ const { messages, sendMessage, addToolOutput, status } = useChat<ChatMessage>({
110
112
  id: chatId,
111
113
  transport,
112
114
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
113
115
  });
116
+ const busy = status === "submitted" || status === "streaming";
114
117
 
115
118
  return (
116
119
  <>
117
- {messages.map((m) =>
118
- m.parts.map((part, i) => {
119
- if (part.type === "tool-askUser" && part.state === "input-available") {
120
- return (
121
- <AskUserCard
122
- key={i}
123
- question={part.input.question}
124
- options={part.input.options}
125
- onAnswer={(opt) =>
126
- addToolOutput({
127
- tool: "askUser",
128
- toolCallId: part.toolCallId,
129
- output: { optionId: opt.id, label: opt.label },
130
- })
131
- }
132
- />
133
- );
134
- }
135
- if (part.type === "text") return <Markdown key={i}>{part.text}</Markdown>;
136
- return null;
137
- })
138
- )}
120
+ {messages.map((message) => (
121
+ <div key={message.id}>
122
+ {message.parts.map((part, i) => {
123
+ if (part.type === "tool-askUser" && part.state === "input-available") {
124
+ return (
125
+ <fieldset key={part.toolCallId} disabled={busy}>
126
+ <legend>{part.input.question}</legend>
127
+ {part.input.options.map((option) => (
128
+ <button
129
+ key={option.id}
130
+ type="button"
131
+ onClick={() =>
132
+ addToolOutput({
133
+ tool: "askUser",
134
+ toolCallId: part.toolCallId,
135
+ output: { optionId: option.id, label: option.label },
136
+ })
137
+ }
138
+ >
139
+ {option.label}
140
+ {option.description && <span> {option.description}</span>}
141
+ </button>
142
+ ))}
143
+ </fieldset>
144
+ );
145
+ }
146
+ if (part.type === "text") return <p key={i}>{part.text}</p>;
147
+ return null;
148
+ })}
149
+ </div>
150
+ ))}
151
+ <button disabled={busy} onClick={() => sendMessage({ text: "Help me choose a database." })}>
152
+ Start a conversation
153
+ </button>
139
154
  </>
140
155
  );
141
156
  }
142
157
  ```
143
158
 
144
- `addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage` — the backend picks it up as the next turn.
159
+ `addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage`. The backend picks it up as the next turn.
145
160
 
146
- ## Detecting a paused turn in `onTurnComplete`
161
+ ## Choose from tool results
147
162
 
148
- Two ways to detect "this turn paused for user input" vs "this turn finished normally":
163
+ When a tool returns several candidates, run the search first, then ask the user to choose one before taking action. For example, a search might find several matching contacts, files, or brands.
149
164
 
150
- ### Via `finishReason` (recommended)
165
+ Keep the search, selection, and action as separate tool calls:
151
166
 
152
- The AI SDK's finish reason is surfaced on every `onTurnComplete` event. If the model stopped on tool calls, it's `"tool-calls"`:
167
+ 1. A search tool with `execute` returns candidates and saves them under a `searchId`.
168
+ 2. A selection tool without `execute` asks the frontend to show those candidates.
169
+ 3. An action tool with `execute` uses the selected ID after checking it on the server.
153
170
 
154
- ```ts
155
- onTurnComplete: async ({ finishReason, responseMessage }) => {
156
- if (finishReason === "tool-calls") {
157
- // Turn paused — assistant message has pending tool call(s)
158
- const pending = responseMessage?.parts.filter(
159
- (p) => p.type.startsWith("tool-") && p.state === "input-available"
160
- );
161
- // Persist as a checkpoint / partial turn
162
- } else {
163
- // finishReason === "stop" — normal completion
164
- // Persist as a completed turn
165
- }
166
- };
171
+ ```ts trigger/tools/select-result.ts
172
+ import { tool } from "ai";
173
+ import { z } from "zod";
174
+
175
+ export const selectResult = tool({
176
+ description:
177
+ "Ask the user to choose from a completed search. " +
178
+ "Copy searchId from the search result and wait for their answer.",
179
+ inputSchema: z.object({ searchId: z.string() }),
180
+ outputSchema: z.object({ selectedId: z.string().nullable() }),
181
+ });
167
182
  ```
168
183
 
169
- <Note>
170
- `finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
171
- </Note>
184
+ Render this tool's `input-available` part using the same approach as `askUser` above. Load the candidates from the saved search, display their original labels, and call `addToolOutput` with the selected ID. Return `{ selectedId: null }` for a "None of these" option. If a search has no results, ask for a different query before requesting a selection.
172
185
 
173
- ### Via response parts
186
+ Save search results durably and scope them to the authorized conversation so the picker still works after a reload or continuation. Use stable candidate IDs; names can be ambiguous.
174
187
 
175
- If you need more nuance (e.g. which specific tool is pending), use `chat.history.getPendingToolCalls()`:
188
+ Before executing the action, read the completed selection from `chat.history`, validate its output, and check that the requested ID matches the user's answer and belongs to that saved search. A null answer must prevent the action. Enforce these checks in server code; tool descriptions alone don't enforce the choice.
176
189
 
177
- ```ts
178
- const pending = chat.history.getPendingToolCalls();
179
- // [{ toolCallId, toolName, messageId }]
180
- ```
190
+ Use the selection's `toolCallId`, scoped to the chat, as the action's idempotency key. That keeps retries of the same choice from creating duplicate work, even if the model makes another action call. If the next step must be the action, use `prepareStep` to set `toolChoice` on the first model step after a valid selection.
181
191
 
182
- The result reflects the most recent assistant message: the one waiting on `addToolOutput`. Use it from `onAction` to gate fresh user turns ("can't send a new message while a HITL is open"), or from `onTurnComplete` to decide what to persist.
192
+ You can also require approval on the action tool. The selection supplies an input; the approval confirms whether to execute with that input.
183
193
 
184
- Both `finishReason === "tool-calls"` and `chat.history.getPendingToolCalls().length > 0` are equivalent in practice. Use `finishReason` for dispatch, the helper for detail.
194
+ ## Approve a tool before execution
185
195
 
186
- ### Acting once per net-new tool result
196
+ With **AI SDK 6 or later**, set `needsApproval: true` on a tool that has an `execute` function. The SDK requests approval before calling `execute`. Approving the call runs it on the next turn; denying it skips execution.
187
197
 
188
- When the user's `addToolOutput` round-trips a tool answer back to the agent, the wire message carries the resolved tool part. If you want to fire side-effects (audit log, billing, notifications) exactly once per resolved tool call, do it in `hydrateMessages` before the runtime merges. `chat.history.extractNewToolResults(message)` returns only the parts whose `toolCallId` isn't already resolved on the chain:
189
-
190
- ```ts
191
- hydrateMessages: async ({ incomingMessages }) => {
192
- for (const msg of incomingMessages) {
193
- if (msg.role !== "assistant") continue;
194
- for (const r of chat.history.extractNewToolResults(msg)) {
195
- await auditLog.record({
196
- toolCallId: r.toolCallId,
197
- toolName: r.toolName,
198
- output: r.output,
199
- errorText: r.errorText, // set only for output-error parts
200
- });
201
- }
202
- }
203
- return incomingMessages;
204
- },
198
+ ```ts trigger/tools/send-email.ts
199
+ import { tool } from "ai";
200
+ import { z } from "zod";
201
+ import { sendEmail } from "@/lib/email";
202
+
203
+ export const sendEmailTool = tool({
204
+ description: "Send an email after the user approves.",
205
+ inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
206
+ needsApproval: true,
207
+ execute: async (input) => sendEmail(input),
208
+ });
205
209
  ```
206
210
 
207
- `extractNewToolResults` compares against the current `chat.history`. By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so the helper returns `[]` there. Use it where the message is from outside the accumulator: `hydrateMessages`, `onAction` if the action carries a message, or any custom pre-merge code path.
211
+ `sendEmail` is your application's email function. Keep authorization and idempotency checks there. Declare the tool on the agent and pass it to the `streamText` supplied to `run`, as in the question example.
208
212
 
209
- ## Persistence: one message vs one record per pause
213
+ On the frontend, render approve/deny buttons for `approval-requested` parts. Call `addToolApprovalResponse` with `part.approval.id` and the `approved` boolean, and use `lastAssistantMessageIsCompleteWithApprovalResponses` for automatic submission. The [frontend approval example](/ai-chat/frontend#tool-approvals) shows the full wiring.
210
214
 
211
- Because the AI SDK reuses the assistant message ID across the pause, the "same turn" from the user's perspective maps to **two `onTurnComplete` firings** on the server — but both receive a `responseMessage` with the **same `id`**, and the second firing's `responseMessage` contains the fully merged content.
215
+ For conditional approval, [`needsApproval` also accepts a function](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#dynamic-approval) that checks the tool input. Approval happens before `execute`, so a tool that searches inside `execute` can't use that approval prompt to display its search results. Run the search first, collect the selection, then call the action tool.
212
216
 
213
- Two common persistence patterns:
217
+ If your agent uses both questions and approvals, combine the helpers:
214
218
 
215
- ### Overwrite on every turn (simplest)
219
+ ```ts app/components/chat.tsx
220
+ import {
221
+ lastAssistantMessageIsCompleteWithApprovalResponses,
222
+ lastAssistantMessageIsCompleteWithToolCalls,
223
+ type UIMessage,
224
+ } from "ai";
216
225
 
217
- Just store the latest `uiMessages` array on every `onTurnComplete`. The paused-turn write is overwritten by the resume-turn write; the final DB state has the full merged message.
226
+ export function sendAutomaticallyWhen({ messages }: { messages: UIMessage[] }) {
227
+ return (
228
+ lastAssistantMessageIsCompleteWithToolCalls({ messages }) ||
229
+ lastAssistantMessageIsCompleteWithApprovalResponses({ messages })
230
+ );
231
+ }
232
+ ```
218
233
 
219
- ```ts
220
- onTurnComplete: async ({ chatId, uiMessages }) => {
221
- await db.chat.update({
222
- where: { id: chatId },
223
- data: { messages: uiMessages },
224
- });
234
+ ## Detecting a paused turn in `onTurnComplete`
235
+
236
+ Use `chat.history.getPendingToolCalls()` to find unanswered tool calls on the latest assistant message:
237
+
238
+ ```ts trigger/my-chat.ts
239
+ onTurnComplete: async ({ stopped }) => {
240
+ if (stopped) return;
241
+ const pending = chat.history.getPendingToolCalls();
242
+ if (pending.length > 0) {
243
+ console.log("Waiting for user input", pending);
244
+ }
225
245
  },
226
246
  ```
227
247
 
228
- Use this unless you specifically need an audit trail.
248
+ Each entry contains `toolCallId`, `toolName`, and `messageId`. Approval requests use a separate state: inspect `responseMessage.parts` for `approval-requested` when tracking approvals.
229
249
 
230
- ### Checkpoint nodes (immutable history)
250
+ `finishReason === "tool-calls"` describes why the model stopped. It can also occur when a step limit ends a loop of server-executed tools, so check the tool states before treating it as a human pause. Check `stopped` separately for interrupted turns.
231
251
 
232
- For apps that want every pause point recorded as its own immutable snapshot (branching, replay, diff review), save a checkpoint when paused and a sibling when complete:
252
+ ### Acting once per net-new tool result
233
253
 
234
- ```ts
235
- onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
236
- if (!responseMessage) return;
254
+ `chat.history.extractNewToolResults(message)` returns resolved tool parts whose `toolCallId` isn't already resolved in the current history. Call it before the incoming message is merged. By `onTurnComplete`, the result is already in history and the helper returns `[]`.
237
255
 
238
- if (finishReason === "tool-calls") {
239
- // Paused — save a checkpoint
240
- await db.turnCheckpoint.create({
241
- data: {
242
- chatId,
243
- messageId: responseMessage.id,
244
- parts: responseMessage.parts,
245
- kind: "partial",
246
- },
247
- });
248
- } else {
249
- // Completed — save a sibling with the merged full message
250
- await db.turnCheckpoint.create({
251
- data: {
252
- chatId,
253
- messageId: responseMessage.id,
254
- parts: responseMessage.parts,
255
- kind: "final",
256
- },
257
- });
258
- }
256
+ This filters results against the current transcript; durable side effects still need an idempotency key. Use `chatId` and `toolCallId` to deduplicate a sync or audit write across retries. See [Tool result auditing](/ai-chat/patterns/tool-result-auditing) for existing `hydrateMessages` integrations.
259
257
 
260
- // Always update the canonical chat record for `hydrateMessages` to load
261
- await db.chat.update({
262
- where: { id: chatId },
263
- data: { messages: uiMessages },
264
- });
265
- };
266
- ```
258
+ ## Persistence: one message vs one record per pause
267
259
 
268
- Both writes see `responseMessage.id` as the same value — they're checkpoints of the same logical message. Grouping by `messageId` + ordering by `createdAt` gives you the progression.
260
+ The default [transcript storage](/ai-chat/transcript-storage) saves the pending call and its resolved result. Use a storage adapter if you need the conversation in your own database.
261
+
262
+ The pause and continuation share the assistant message ID. Upsert by `message.id` so the resolved message replaces the pending version. For an audit trail, keep separate snapshots alongside that canonical message and record whether it has pending questions, approval requests, or a stopped response.
269
263
 
270
264
  ## Multi-pause turns
271
265
 
272
- A single logical turn can pause more than once — the LLM asks question A, gets the answer, thinks, then asks question B before finishing. Each pause fires its own `onTurnComplete` with `finishReason === "tool-calls"`; only the last firing has `finishReason === "stop"`. The checkpoint pattern above handles this naturally — each pause adds a new checkpoint sharing the same `responseMessage.id`.
266
+ A conversation can ask another question after receiving an answer. Each pause and continuation fires `onTurnComplete`; inspect the message's tool states at each callback. The merged assistant message includes the answers collected so far.
267
+
268
+ ## Verify the flow
269
+
270
+ Send a request that needs clarification. Confirm that the question appears, choose an option, and check that the agent continues without another click on Send. The question's tool part should change from `input-available` to `output-available`.
271
+
272
+ For a selection from search results, check that only the selected item is used. Choosing "None of these" must leave the action unexecuted. Reload a pending selection and confirm that the same candidates appear.
273
+
274
+ For an approval tool, deny the first request and confirm that `execute` doesn't run. Approve a fresh request and confirm that it runs with the displayed inputs.
273
275
 
274
- ## Gotchas
276
+ ## Common mistakes
275
277
 
276
- - **Don't set an `execute` function on the HITL tool.** If it has one, `streamText` will call it immediately instead of handing control back.
277
- - **The frontend must use `sendAutomaticallyWhen`.** Without it, the user has to press Enter after answering — `addToolOutput` updates local state but doesn't fire a new turn by itself.
278
- - **Don't mutate `responseMessage` in `onTurnComplete`.** It's the captured snapshot. To add custom parts, use `chat.response.append()` in `onBeforeTurnComplete` (while the stream is open).
279
- - **Stop handling.** If the user stops the run while a pause is active (`chat.stop()` on the transport), `onTurnComplete` fires with `stopped: true` and `finishReason` reflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.
278
+ - Give question tools no `execute` function. For tools that execute after approval, keep `execute` and set `needsApproval`.
279
+ - `addToolOutput` updates local state. Configure `sendAutomaticallyWhen` or explicitly send the updated message to start the next turn.
280
+ - Only show interactive controls for pending calls, and disable them while a request is streaming or submitted.
281
+ - To add custom response parts, use `chat.response.append()` in `onBeforeTurnComplete` while the stream is open. Treat `responseMessage` in `onTurnComplete` as a snapshot.
@@ -1,23 +1,24 @@
1
1
  ---
2
2
  title: "Tool result auditing"
3
3
  sidebarTitle: "Tool result auditing"
4
- description: "Fire side effects exactly once per resolved tool call — audit logs, billing, notifications — using extractNewToolResults inside hydrateMessages or onTurnComplete."
4
+ description: "Find new tool results before they merge into chat history, and deduplicate audit writes with a durable idempotency key."
5
5
  ---
6
6
 
7
- When a chat agent uses [tools](/ai-chat/tools) (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:
7
+ Use `chat.history.extractNewToolResults(message)` to find incoming tool results that aren't resolved in the current transcript. Make the resulting audit write idempotent in your database so retries can't create duplicate records.
8
8
 
9
- - **Audit logs** — record every tool result for compliance.
10
- - **Billing** — charge per tool invocation.
11
- - **Notifications** — alert downstream systems when a specific tool resolves.
12
- - **Search-index updates** — reflect tool outputs into a derived store.
9
+ The helper applies to [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) answers sent with `addToolOutput`. It compares the incoming message with the current history; it doesn't track whether an external write succeeded.
13
10
 
14
- The naive approach — "log every tool part you see" — over-counts. The same assistant message gets re-shown across re-renders, replays, and retries. You want a function of the form **"is this tool result one I haven't already logged?"** That's exactly what [`chat.history.extractNewToolResults`](/ai-chat/backend#chat-history) returns.
11
+ <Note>
12
+ The `hydrateMessages` examples below apply to existing integrations. That hook is deprecated. For new conversation persistence, use [transcript storage](/ai-chat/transcript-storage).
13
+ </Note>
15
14
 
16
15
  ## The pattern
17
16
 
18
17
  ```ts
19
18
  import { chat } from "@trigger.dev/sdk/ai";
20
19
  import { auditLog } from "@/lib/audit";
20
+ import { db } from "@/lib/db";
21
+ import { anthropic } from "@ai-sdk/anthropic";
21
22
 
22
23
  export const myChat = chat.agent({
23
24
  id: "my-chat",
@@ -35,7 +36,7 @@ export const myChat = chat.agent({
35
36
  }
36
37
  return await db.getMessages(chatId);
37
38
  },
38
- run: async ({ messages, signal }) => {
39
+ run: async ({ messages, signal, streamText }) => {
39
40
  return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
40
41
  },
41
42
  });
@@ -43,10 +44,10 @@ export const myChat = chat.agent({
43
44
 
44
45
  The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-length, see [v4.5 wire format change](/ai-chat/upgrade-guide#v45-wire-format-change)). For each new tool result on that message, write one audit row. Then return the canonical chain from your DB.
45
46
 
46
- `extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. That's what makes the call exactly-once:
47
+ `extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. For results already present in that history:
47
48
 
48
- - A re-emitted message (same id, same toolCallId) returns `[]` — no duplicate log.
49
- - A genuinely new tool result on a known assistant message returns just the new ones.
49
+ - A re-emitted message with the same resolved `toolCallId` returns `[]`.
50
+ - A new tool result on a known assistant message is returned.
50
51
  - A first-time tool result returns the full set.
51
52
 
52
53
  ## Why `hydrateMessages` is the right hook
@@ -54,13 +55,13 @@ The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-leng
54
55
  The pattern works in any pre-merge callback, but `hydrateMessages` is the canonical spot for two reasons:
55
56
 
56
57
  1. **It fires before the runtime merges** the incoming message into the accumulator. Once merged, the tool results are already on the chain, and `extractNewToolResults` returns `[]` for them.
57
- 2. **It always fires per turn** — including HITL turns where the user resolved a tool with `addToolOutput`, which is the highest-volume audit event in most apps.
58
+ 2. **It fires on each turn**, including turns that receive a user's answer through `addToolOutput`.
58
59
 
59
60
  By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so calling `extractNewToolResults(responseMessage)` there returns `[]`. Don't put audit logging there for the resolution path.
60
61
 
61
- ## Without `hydrateMessages` — `onTurnComplete` for self-emitted tool calls
62
+ ## Audit server-executed tools in `onTurnComplete`
62
63
 
63
- If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` — but compare against the prior message rather than the just-emitted one:
64
+ If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` by iterating the emitted parts and using an idempotent audit writer:
64
65
 
65
66
  ```ts
66
67
  onTurnComplete: async ({ chatId, newUIMessages }) => {
@@ -87,23 +88,23 @@ onTurnComplete: async ({ chatId, newUIMessages }) => {
87
88
  },
88
89
  ```
89
90
 
90
- `newUIMessages` is just the messages this turn produced — no prior-chain noise. Each tool part shows up exactly once.
91
+ `newUIMessages` contains the messages this turn produced. Retries and merged assistant messages can expose a tool result again, so deduplicate these writes in your database too.
91
92
 
92
- This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn — use `hydrateMessages` for those.
93
+ This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn. Use `hydrateMessages` for those in existing integrations.
93
94
 
94
95
  ## Idempotency at the storage layer
95
96
 
96
- Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `toolCallId`:
97
+ Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `(chatId, toolCallId)`:
97
98
 
98
99
  ```ts
99
100
  await auditLog.upsert({
100
- where: { toolCallId: r.toolCallId },
101
+ where: { chatId_toolCallId: { chatId, toolCallId: r.toolCallId } },
101
102
  create: { /* ... */ },
102
103
  update: { /* timestamp, retry count, etc. */ },
103
104
  });
104
105
  ```
105
106
 
106
- `toolCallId` is unique per tool invocation (assigned by the AI SDK when the model emits the tool call) and stable across retries — perfect for an idempotency key.
107
+ A replay of the same tool call keeps its `toolCallId`. A new model-generated invocation can have a new ID; use an application operation ID as well when separate calls must refer to the same external action.
107
108
 
108
109
  ## What `extractNewToolResults` returns
109
110
 
@@ -116,17 +117,17 @@ type ChatNewToolResult = {
116
117
  };
117
118
  ```
118
119
 
119
- Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) are not returned — only **resolved** results count.
120
+ Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) aren't returned. The helper returns resolved results.
120
121
 
121
122
  ## Combining with HITL
122
123
 
123
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the just-resolved tool — exactly one audit row per user resolution:
124
+ [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the newly resolved tool. An idempotent audit writer keeps one row per resolution:
124
125
 
125
126
  ```ts
126
127
  hydrateMessages: async ({ chatId, incomingMessages }) => {
127
128
  for (const msg of incomingMessages) {
128
129
  for (const r of chat.history.extractNewToolResults(msg)) {
129
- // Fires once per ask_user / approval / similar resolution
130
+ // Deduplicate the write by chatId and toolCallId.
130
131
  await auditLog.record({ chatId, /* ... */ });
131
132
  }
132
133
  }
@@ -134,11 +135,11 @@ hydrateMessages: async ({ chatId, incomingMessages }) => {
134
135
  }
135
136
  ```
136
137
 
137
- This is the original motivator for the helper — see the [HITL pattern's net-new-tool-result section](/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result).
138
+ See the [HITL pattern's net-new-tool-result section](/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result).
138
139
 
139
140
  ## See also
140
141
 
141
- - [`chat.history`](/ai-chat/backend#chat-history) — full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
142
- - [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) — the pattern this auditing hook complements
143
- - [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — where pre-merge auditing lives
144
- - [Persistence and replay](/ai-chat/patterns/persistence-and-replay) — how the runtime rebuilds chains, and why `extractNewToolResults` works against them
142
+ - [`chat.history`](/ai-chat/backend#chat-history): full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
143
+ - [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): the pattern this auditing hook complements
144
+ - [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): where pre-merge auditing lives
145
+ - [Persistence and replay](/ai-chat/patterns/persistence-and-replay): how the runtime rebuilds chains, and why `extractNewToolResults` works against them