Skip to main content
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:
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.

Default behavior

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.

Handle the decision with onToolApproval

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:
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
  },
});
When you register onToolApproval, you own card cleanup. Novu no longer auto-deletes the card — call decision.approvalMessage.edit() or decision.approvalMessage.delete() yourself.
The decision object:
FieldTypeDescription
toolCallAgentToolCallThe gated tool (id, name, input?)
approvedbooleantrue if approved, false if denied
approvalMessageReplyHandleHandle to the card — edit() or delete() it. See Edit sent messages

Edit the card in place

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}.`);
},

Customize the card

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:
ArgDescription
toolCallThe 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):
FieldChannelsDescription
titleAllCard title. Defaults to Tool approval required
subtitleAllSubtitle; auto-generated from the tool when omitted
approveLabel / denyLabelAllButton labels. Default Approve / Deny
iconSlack onlyCatalog id, https:// URL (32×32), or omit to auto-match the tool name
bodySlack onlyOptional 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>
  ),
},

AI SDK

Automatic gating with needsApproval: true and auto-resume.

LangChain

Automatic gating with needsApproval callback and auto-resume.

Edit sent messages

Edit and delete cards in place with ReplyHandle.

Handlers and context

All handler events and the context object.