Skip to main content

Tool Approval

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:

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. approveToolCall and denyToolCall work from both mutations and actions.
  4. Continue generation once all pending approvals have been resolved.
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 (can be a mutation or action)
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();
},
});

:::tip You can approve a tool call and continue generation in the same server function. For example, an action can call approveToolCall and then immediately call streamText to resume — no separate scheduling step needed. :::

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.

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"
// ...
}

The ToolUIPart states relevant to approval are:

StateMeaning
approval-requestedWaiting for the user to approve or deny
approval-respondedUser responded; tool is being executed (if approved)
output-availableTool executed successfully
output-deniedTool was denied
output-errorTool execution failed

Example files

For a complete working example, see: