Pause an agent turn and ask the user to approve or deny a tool call before it runs, then resume with the decision.
Some tool calls should not run without a human in the loop — issuing a refund, deleting data, sending money. 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:
AI SDK
LangChain
Custom code
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.
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 },});
When you do not register an onToolApproval handler, Novu handles the click for you:
The user clicks Approve or Deny.
Novu deletes the approval card and restarts the typing indicator. See Typing indicator.
The decision is recorded in 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.
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.
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.
Register 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:
AI SDK
LangChain
Custom code
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.
When you register onToolApproval, you own card cleanup. Novu no longer auto-deletes the card — call decision.approvalMessage.edit() or decision.approvalMessage.delete() yourself.
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: