docs/agents/custom-code-agent/building-blocks/tool-approval.mdx
Some tool calls should not run without user approval (refunds, deletes, payments). Tool approval posts an Approve / Deny card in the conversation and pauses the turn until the user decides.
This page covers ctx.toolApproval.request(), the onToolApproval handler, and card customization. How you gate a tool depends on your runtime:
| Capability | Slack | Teams | Telegram | ||
|---|---|---|---|---|---|
| Action / tool approval | Yes (native block) | Yes (Adaptive Card) | Yes (quick replies) | Yes | Yes (action links) |
| MCP tool approval | Yes | Yes | Yes | Yes | Yes |
| MCP connect card | Yes | Yes | Yes | Yes | Yes |
See Channels overview for the full matrix.
<Tabs> <Tab title="AI SDK">Set needsApproval: true on an AI SDK tool. When the model calls it, Novu posts the approval card and pauses the turn. After the user clicks Approve or Deny, Novu re-runs onMessage so generateText continues the tool loop - no ctx.toolApproval.request() needed.
import { agent, toModelMessages } from '@novu/framework/ai-sdk';
import { openai } from '@ai-sdk/openai';
import { generateText, tool } from 'ai';
import { z } from 'zod';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
return generateText({
model: openai('gpt-4o'),
messages: toModelMessages(ctx.history),
tools: {
issueRefund: tool({
inputSchema: z.object({ orderId: z.string() }),
needsApproval: true,
execute: async ({ orderId }) => refund(orderId),
}),
},
});
},
});
See AI SDK for auto-resume and optional onToolApproval hooks.
Set needsApproval on a LangChainAgentConfig. When the model calls a gated tool, Novu posts the approval card and pauses the turn. After the user clicks Approve or Deny, Novu re-runs onMessage and resumes from ctx.history - no ctx.toolApproval.request() needed.
import { tool } from '@langchain/core/tools';
import { agent } from '@novu/framework/langchain';
import { z } from 'zod';
const issueRefund = tool(
async ({ orderId }) => refund(orderId),
{
name: 'issueRefund',
description: 'Issue a refund for an order',
schema: z.object({ orderId: z.string() }),
},
);
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => ({
model: 'openai:gpt-4o',
tools: [issueRefund],
needsApproval: (toolCall) => toolCall.name === 'issueRefund',
}),
});
See LangChain for auto-resume and optional onToolApproval hooks.
Call ctx.toolApproval.request() with the tool call and return it from onMessage. This posts the approval card and ends the turn:
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
const toolCall = { id: 'call_1', name: 'issueRefund', input: { orderId: 'A-123' } };
if (needsApproval(toolCall)) {
return ctx.toolApproval.request(toolCall); // posts the card, pauses the turn
}
// ...run the tool and reply normally
},
});
request() takes an AgentToolCall:
| Field | Type | Description |
|---|---|---|
id | string | Your identifier for this tool call |
name | string | Tool name shown on the card |
input | Record<string, unknown> (optional) | Arguments, available to card customization |
When you do not register an onToolApproval handler, Novu handles the click for you:
ctx.history.If several tools need approval in the same turn, they surface one at a time - the next card appears after the current one is resolved.
<Note> If the user sends a new message while a card is pending, Novu auto-denies the pending tool(s), deletes the card, and processes the new message. </Note>This is the simplest path - post the card and let Novu clean it up. On the Custom code path, handle the decision in onToolApproval when you need to run the tool or post a reply after the click.
onToolApprovalRegister onToolApproval when you need to act on the click - run the tool, post a result, edit the card, or audit the decision. It receives a decision:
import { agent, toModelMessages } from '@novu/framework/ai-sdk';
import { openai } from '@ai-sdk/openai';
import { generateText, tool } from 'ai';
import { z } from 'zod';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
return generateText({
model: openai('gpt-4o'),
messages: toModelMessages(ctx.history),
tools: {
issueRefund: tool({
inputSchema: z.object({ orderId: z.string() }),
needsApproval: true,
execute: async ({ orderId }) => refund(orderId),
}),
},
});
},
onToolApproval: async (decision, ctx) => {
if (!decision.approved) {
await decision.approvalMessage.delete();
return 'No problem - I did not issue the refund.';
}
await decision.approvalMessage.delete();
// return nothing → onMessage auto-resumes
},
});
import { tool } from '@langchain/core/tools';
import { agent } from '@novu/framework/langchain';
import { z } from 'zod';
const issueRefund = tool(
async ({ orderId }) => refund(orderId),
{
name: 'issueRefund',
description: 'Issue a refund for an order',
schema: z.object({ orderId: z.string() }),
},
);
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => ({
model: 'openai:gpt-4o',
tools: [issueRefund],
needsApproval: (toolCall) => toolCall.name === 'issueRefund',
}),
onToolApproval: async (decision, ctx) => {
if (!decision.approved) {
await decision.approvalMessage.delete();
return 'No problem - I did not issue the refund.';
}
await decision.approvalMessage.delete();
// return nothing → onMessage auto-resumes
},
});
import { agent } from '@novu/framework';
export const myAgent = agent('my-agent', {
onMessage: async (message, ctx) => {
return ctx.toolApproval.request({ id: 'call_1', name: 'issueRefund', input: { orderId: 'A-123' } });
},
onToolApproval: async (decision, ctx) => {
// decision.toolCall - { id, name, input? }
// decision.approved - true | false
// decision.approvalMessage - ReplyHandle to the card (edit/delete)
if (!decision.approved) {
await decision.approvalMessage.delete();
return 'No problem - I did not issue the refund.';
}
const result = await issueRefund(decision.toolCall.input);
await decision.approvalMessage.delete();
return `Refund issued for order ${result.orderId}.`;
},
});
On the Custom code path, you run the tool yourself in onToolApproval - there is no automatic model resume.
The decision object:
| Field | Type | Description |
|---|---|---|
toolCall | AgentToolCall | The gated tool (id, name, input?) |
approved | boolean | true if approved, false if denied |
approvalMessage | ReplyHandle | Handle to the card - edit() or delete() it. See Edit sent messages |
Instead of deleting, edit the card to reflect the outcome so the thread keeps a record:
onToolApproval: async (decision, ctx) => {
if (!decision.approved) {
await decision.approvalMessage.edit('Refund request denied.');
return;
}
await decision.approvalMessage.edit('Refund approved - processing…');
const result = await issueRefund(decision.toolCall.input);
await decision.approvalMessage.edit(`Refund issued for order ${result.orderId}.`);
},
By default Novu renders an Approve / Deny card. Customize it with toolApproval.renderApproval. Novu always supplies the action ids and wires the buttons unless you build a fully custom card.
export const myAgent = agent('my-agent', {
toolApproval: {
renderApproval: ({ toolCall, actionIds, approvalCard }) =>
approvalCard({
// icon: omit → matched from the tool name against the Novu MCP catalog
// icon: 'stripe' → catalog id or server-name lookup
// icon: 'https://…' → your own image URL (Slack only, 32×32 recommended)
subtitle: `${toolCall.name} - order ${toolCall.input?.orderId}`,
approveLabel: 'Yes, refund',
denyLabel: 'Cancel',
}),
},
onMessage: async (message, ctx) => {
return ctx.toolApproval.request({ id: 'call_1', name: 'issueRefund', input: { orderId: 'A-123' } });
},
});
renderApproval receives:
| Arg | Description |
|---|---|
toolCall | The gated tool (id, name, input) |
actionIds | { approve, deny } - wire these to your own buttons for a fully custom card |
approvalCard(overrides?) | Helper returning Novu's native card descriptor |
approvalCard() fields (channel support noted):
| Field | Channels | Description |
|---|---|---|
title | All | Card title. Defaults to Tool approval required |
subtitle | All | Subtitle; auto-generated from the tool when omitted |
approveLabel / denyLabel | All | Button labels. Default Approve / Deny |
icon | Slack only | Catalog id, https:// URL (32×32), or omit to auto-match the tool name |
body | Slack only | Optional markdown body (e.g. argument preview) |
For a fully custom card, wire the action ids onto your own buttons:
toolApproval: {
renderApproval: ({ actionIds }) => (
<Card title="Approve this refund?">
<Actions>
<Button id={actionIds.deny} label="No" />
<Button id={actionIds.approve} label="Yes" style="primary" />
</Actions>
</Card>
),
},