Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions docs/tool-approval.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
---
title: Tool Approval
sidebar_label: "Tool Approval"
sidebar_position: 510
description: "Human-in-the-loop tool approval for the Agent component"
---

Tool approval lets you require human confirmation before a tool call is
executed. This is useful for dangerous or irreversible operations — deleting
data, spending money, sending emails — where you want a person to review the
action before it happens.

## Defining tools with approval

Add `needsApproval` to any tool created with `createTool`. It can be a
boolean or an async function that receives the tool context and input:

```ts
import { createTool } from "@convex-dev/agent";
import { z } from "zod/v4";

// Always requires approval
const deleteFileTool = createTool({
description: "Delete a file from the system",
inputSchema: z.object({
filename: z.string().describe("The name of the file to delete"),
}),
needsApproval: () => true,
execute: async (_ctx, input) => {
return `Deleted file: ${input.filename}`;
},
});

// Conditionally requires approval (only for large amounts)
const transferMoneyTool = createTool({
description: "Transfer money to an account",
inputSchema: z.object({
amount: z.number().describe("The amount to transfer"),
toAccount: z.string().describe("The destination account"),
}),
needsApproval: async (_ctx, input) => {
return input.amount > 100;
},
execute: async (_ctx, input) => {
return `Transferred $${input.amount} to ${input.toAccount}`;
},
});
```

Tools without `needsApproval` (or with `needsApproval` returning `false`)
execute immediately as usual.

## Server-side flow

The typical approval flow involves four server functions:

1. **Save the user's message** and schedule generation.
2. **Generate a response.** If the model calls a tool that needs approval,
generation pauses and the `tool-approval-request` is persisted in the
thread.
3. **Submit an approval or denial** for each pending tool call.
4. **Continue generation** once all pending approvals have been resolved.

```ts
import { approvalAgent } from "../agents/approval";

// 1. Save message and schedule generation
export const sendMessage = mutation({
args: { prompt: v.string(), threadId: v.string() },
handler: async (ctx, { prompt, threadId }) => {
const { messageId } = await approvalAgent.saveMessage(ctx, {
threadId,
prompt,
});
await ctx.scheduler.runAfter(0, internal.chat.approval.generateResponse, {
threadId,
promptMessageId: messageId,
});
return { messageId };
},
});

// 2. Generate (stops if approval is needed)
export const generateResponse = internalAction({
args: { promptMessageId: v.string(), threadId: v.string() },
handler: async (ctx, { promptMessageId, threadId }) => {
const result = await approvalAgent.streamText(
ctx,
{ threadId },
{ promptMessageId },
);
await result.consumeStream();
},
});

// 3. Submit an approval decision
export const submitApproval = mutation({
args: {
threadId: v.string(),
approvalId: v.string(),
approved: v.boolean(),
reason: v.optional(v.string()),
},
returns: v.object({ messageId: v.string() }),
handler: async (ctx, { threadId, approvalId, approved, reason }) => {
const { messageId } = approved
? await approvalAgent.approveToolCall(ctx, {
threadId, approvalId, reason,
})
: await approvalAgent.denyToolCall(ctx, {
threadId, approvalId, reason,
});
return { messageId };
},
});

// 4. Continue generation after all approvals resolved.
// Pass the last approval message ID as promptMessageId so the agent
// resumes generation from where the approval was issued.
export const continueAfterApprovals = internalAction({
args: { threadId: v.string(), lastApprovalMessageId: v.string() },
handler: async (ctx, { threadId, lastApprovalMessageId }) => {
const result = await approvalAgent.streamText(
ctx,
{ threadId },
{ promptMessageId: lastApprovalMessageId },
);
await result.consumeStream();
},
});
```

## Handling multiple tool calls

When the model calls several tools in a single step, some or all of them may
require approval. **Every** pending approval must be resolved (approved or
denied) before you continue generation.

If a new generation starts while approvals are still unresolved, the
unresolved approvals are **automatically denied** with the reason
`"auto-denied: new generation started"`. This prevents broken message
histories where tool calls lack results.

## Client-side flow

On the client, use `useUIMessages` to detect pending approvals and show
Approve/Deny buttons. Tool parts with `state === "approval-requested"` are
waiting for a decision.

```tsx
import { useEffect, useRef } from "react";
import { useMutation } from "convex/react";
import { useUIMessages, type UIMessage } from "@convex-dev/agent/react";
import type { ToolUIPart } from "ai";

function Chat({ threadId }: { threadId: string }) {
const lastApprovalMessageIdRef = useRef<string | null>(null);

const { results: messages } = useUIMessages(
api.chat.approval.listThreadMessages,
{ threadId },
{ initialNumItems: 10, stream: true },
);

const submitApproval = useMutation(api.chat.approval.submitApproval);
const triggerContinuation = useMutation(
api.chat.approval.triggerContinuation,
);

const hasPendingApprovals = messages.some((m) =>
m.parts.some(
(p) =>
p.type.startsWith("tool-") &&
(p as ToolUIPart).state === "approval-requested",
),
);

// When all approvals are resolved, trigger continuation
useEffect(() => {
if (!hasPendingApprovals && lastApprovalMessageIdRef.current) {
void triggerContinuation({
threadId,
lastApprovalMessageId: lastApprovalMessageIdRef.current,
});
lastApprovalMessageIdRef.current = null;
}
}, [hasPendingApprovals, threadId, triggerContinuation]);

// Render approval buttons for tool parts with state "approval-requested"
// ...
}
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

The `ToolUIPart` states relevant to approval are:

| State | Meaning |
|-------|---------|
| `approval-requested` | Waiting for the user to approve or deny |
| `approval-responded` | User responded; tool is being executed (if approved) |
| `output-available` | Tool executed successfully |
| `output-denied` | Tool was denied |
| `output-error` | Tool execution failed |

## Example files

For a complete working example, see:

- **Agent definition:** [`example/convex/agents/approval.ts`](https://github.com/get-convex/agent/blob/main/example/convex/agents/approval.ts)
- **Server functions:** [`example/convex/chat/approval.ts`](https://github.com/get-convex/agent/blob/main/example/convex/chat/approval.ts)
- **React UI:** [`example/ui/chat/ChatApproval.tsx`](https://github.com/get-convex/agent/blob/main/example/ui/chat/ChatApproval.tsx)
9 changes: 4 additions & 5 deletions src/client/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ describe("search.ts", () => {
expect(result[1]._id).toBe("2");
});

it("should filter out tool calls with approval request but NO approval response", () => {
it("should keep tool calls with approval request but NO approval response (auto-deny handles them)", () => {
const messages: MessageDoc[] = [
{
_id: "1",
Expand Down Expand Up @@ -321,19 +321,18 @@ describe("search.ts", () => {

const result = filterOutOrphanedToolMessages(messages);
expect(result).toHaveLength(1);
// The assistant message should have the tool-call filtered out
// The assistant message should keep the tool-call (auto-deny resolves it downstream)
const assistantContent = result[0].message?.content;
expect(Array.isArray(assistantContent)).toBe(true);
if (Array.isArray(assistantContent)) {
// Text and approval-request should remain, but tool-call should be filtered
expect(assistantContent).toHaveLength(2);
expect(assistantContent).toHaveLength(3);
expect(assistantContent.find((p) => p.type === "text")).toBeDefined();
expect(
assistantContent.find((p) => p.type === "tool-approval-request"),
).toBeDefined();
expect(
assistantContent.find((p) => p.type === "tool-call"),
).toBeUndefined();
).toBeDefined();
}
});

Expand Down
26 changes: 18 additions & 8 deletions src/client/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
} from "./types.js";
import { inlineMessagesFiles } from "./files.js";
import {
autoDenyUnresolvedApprovals,
docsToModelMessages,
mergeApprovalResponseMessages,
toModelMessage,
Expand Down Expand Up @@ -289,6 +290,12 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) {
return approvalId !== undefined && approvalResponseIds.has(approvalId);
};

// Helper: check if tool call has a pending approval request
// (auto-deny handles these downstream, so they must survive the filter)
const hasApprovalRequest = (toolCallId: string) => {
return approvalRequestsByToolCallId.has(toolCallId);
};

for (const doc of docs) {
if (
doc.message?.role === "assistant" &&
Expand All @@ -298,7 +305,8 @@ export function filterOutOrphanedToolMessages(docs: MessageDoc[]) {
(p) =>
p.type !== "tool-call" ||
toolResultIds.has(p.toolCallId) ||
hasApprovalResponse(p.toolCallId),
hasApprovalResponse(p.toolCallId) ||
hasApprovalRequest(p.toolCallId),
);
if (content.length) {
result.push({
Expand Down Expand Up @@ -641,13 +649,15 @@ export async function fetchContextWithPrompt(
const inputPrompt = promptArray.map(toModelMessage);
const existingResponses = docsToModelMessages(existingResponseDocs);

const allMessages = mergeApprovalResponseMessages([
...search,
...recent,
...inputMessages,
...inputPrompt,
...existingResponses,
]);
const allMessages = autoDenyUnresolvedApprovals(
mergeApprovalResponseMessages([
...search,
...recent,
...inputMessages,
...inputPrompt,
...existingResponses,
]),
);
let processedMessages = args.contextHandler
? await args.contextHandler(ctx, {
allMessages,
Expand Down
Loading
Loading