Back to Novu

Tool approval

docs/agents/custom-code-agent/building-blocks/tool-approval.mdx

3.19.011.2 KB
Original Source

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:

Channel support

CapabilitySlackTeamsWhatsAppTelegramEmail
Action / tool approvalYes (native block)Yes (Adaptive Card)Yes (quick replies)YesYes (action links)
MCP tool approvalYesYesYesYesYes
MCP connect cardYesYesYesYesYes

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.

typescript
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.

</Tab> <Tab title="LangChain">

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.

typescript
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.

</Tab> <Tab title="Custom code">

Call ctx.toolApproval.request() with the tool call and return it from onMessage. This posts the approval card and ends the turn:

typescript
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:

FieldTypeDescription
idstringYour identifier for this tool call
namestringTool name shown on the card
inputRecord<string, unknown> (optional)Arguments, available to card customization
</Tab> </Tabs>

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.

<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.

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:

<Tabs> <Tab title="AI SDK">
typescript
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
  },
});
</Tab> <Tab title="LangChain">
typescript
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
  },
});
</Tab> <Tab title="Custom code">
typescript
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.

</Tab> </Tabs> <Warning> When you register `onToolApproval`, **you own card cleanup.** Novu no longer auto-deletes the card - call `decision.approvalMessage.edit()` or `decision.approvalMessage.delete()` yourself. </Warning>

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:

typescript
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.

typescript
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:

tsx
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>
  ),
},
<Columns cols={2}> <Card icon="sparkles" href="/agents/custom-code-agent/frameworks/ai-sdk" title="AI SDK"> Automatic gating with `needsApproval: true` and auto-resume. </Card> <Card icon="link" href="/agents/custom-code-agent/frameworks/langchain" title="LangChain"> Automatic gating with `needsApproval` callback and auto-resume. </Card> <Card icon="pencil" href="/agents/custom-code-agent/building-blocks/edit-sent-messages" title="Edit sent messages"> Edit and delete cards in place with `ReplyHandle`. </Card> <Card icon="mouse-pointer-click" href="/agents/custom-code-agent/building-blocks/handle-events" title="Handlers and context"> All handler events and the context object. </Card> </Columns>