Skip to content

Commit 31a17c3

Browse files
committed
docs: add tool approvals and stop-after-resume documentation
1 parent c0687f2 commit 31a17c3

File tree

2 files changed

+122
-0
lines changed

2 files changed

+122
-0
lines changed

docs/ai-chat/backend.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,38 @@ This removes tool invocation parts stuck in `partial-call` state and marks any `
516516
This is expected and does not require special handling.
517517
</Note>
518518

519+
### Tool approvals
520+
521+
Tools with `needsApproval: true` pause execution until the user approves or denies via the frontend. Define the tool as normal and pass it to `streamText``chat.agent` handles the rest:
522+
523+
```ts
524+
const sendEmail = tool({
525+
description: "Send an email. Requires human approval.",
526+
inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
527+
needsApproval: true,
528+
execute: async ({ to, subject, body }) => {
529+
await emailService.send({ to, subject, body });
530+
return { sent: true };
531+
},
532+
});
533+
534+
export const myChat = chat.agent({
535+
id: "my-chat",
536+
run: async ({ messages, signal }) => {
537+
return streamText({
538+
model: openai("gpt-4o"),
539+
messages,
540+
tools: { sendEmail },
541+
abortSignal: signal,
542+
});
543+
},
544+
});
545+
```
546+
547+
When the model calls an approval-required tool, the turn completes with the tool in `approval-requested` state. After the user approves on the frontend, the updated message is sent back and `chat.agent` replaces it in the conversation accumulator by matching the message ID. `streamText` then executes the approved tool and continues.
548+
549+
See [Tool approvals](/ai-chat/frontend#tool-approvals) in the frontend docs for the UI setup.
550+
519551
### Persistence
520552

521553
#### What needs to be persisted

docs/ai-chat/frontend.mdx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,96 @@ const stop = useCallback(() => {
280280

281281
See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task.
282282

283+
## Tool approvals
284+
285+
The AI SDK supports tools that require human approval before execution. To use this with `chat.agent`, define a tool with `needsApproval: true` on the backend, then handle the approval UI and configure `sendAutomaticallyWhen` on the frontend.
286+
287+
### Backend: define an approval-required tool
288+
289+
```ts
290+
import { tool } from "ai";
291+
import { z } from "zod";
292+
293+
const sendEmail = tool({
294+
description: "Send an email. Requires human approval before sending.",
295+
inputSchema: z.object({
296+
to: z.string(),
297+
subject: z.string(),
298+
body: z.string(),
299+
}),
300+
needsApproval: true,
301+
execute: async ({ to, subject, body }) => {
302+
await emailService.send({ to, subject, body });
303+
return { sent: true, to, subject };
304+
},
305+
});
306+
```
307+
308+
Pass the tool to `streamText` in your `run` function as usual. When the model calls the tool, `chat.agent` streams a `tool-approval-request` chunk. The turn completes and the run waits for the next message.
309+
310+
### Frontend: approval UI
311+
312+
Import `lastAssistantMessageIsCompleteWithApprovalResponses` from the AI SDK and pass it to `sendAutomaticallyWhen`. This tells `useChat` to automatically re-send messages once all approvals have been responded to.
313+
314+
Destructure `addToolApprovalResponse` from `useChat` and wire it to your approval buttons:
315+
316+
```tsx
317+
import { useChat } from "@ai-sdk/react";
318+
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
319+
320+
function Chat({ chatId, transport }) {
321+
const { messages, sendMessage, addToolApprovalResponse, status } = useChat({
322+
id: chatId,
323+
transport,
324+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
325+
});
326+
327+
const handleApprove = (approvalId: string) => {
328+
addToolApprovalResponse({ id: approvalId, approved: true });
329+
};
330+
331+
const handleDeny = (approvalId: string) => {
332+
addToolApprovalResponse({ id: approvalId, approved: false, reason: "User denied" });
333+
};
334+
335+
return (
336+
<div>
337+
{messages.map((msg) =>
338+
msg.parts.map((part, i) => {
339+
if (part.state === "approval-requested") {
340+
return (
341+
<div key={i}>
342+
<p>Tool "{part.type}" wants to run with input:</p>
343+
<pre>{JSON.stringify(part.input, null, 2)}</pre>
344+
<button onClick={() => handleApprove(part.approval.id)}>Approve</button>
345+
<button onClick={() => handleDeny(part.approval.id)}>Deny</button>
346+
</div>
347+
);
348+
}
349+
// ... render other parts
350+
})
351+
)}
352+
</div>
353+
);
354+
}
355+
```
356+
357+
### How it works
358+
359+
1. Model calls a tool with `needsApproval: true` — the turn completes with the tool in `approval-requested` state
360+
2. Frontend shows Approve/Deny buttons
361+
3. User clicks Approve — `addToolApprovalResponse` updates the tool part to `approval-responded`
362+
4. `sendAutomaticallyWhen` returns `true``useChat` re-sends the updated assistant message
363+
5. The transport sends the message via input streams — the backend matches it by ID and replaces the existing assistant message in the accumulator
364+
6. `streamText` sees the approved tool, executes it, and streams the result
365+
366+
<Info>
367+
Message IDs are kept in sync between frontend and backend automatically. The backend always
368+
includes a `generateMessageId` function when streaming responses, ensuring the `start` chunk
369+
carries a `messageId` that the frontend uses. This makes the ID-based matching reliable
370+
for tool approval updates.
371+
</Info>
372+
283373
## Self-hosting
284374

285375
If you're self-hosting Trigger.dev, pass the `baseURL` option:

0 commit comments

Comments
 (0)