> ## Documentation Index
> Fetch the complete documentation index at: https://docs.novu.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Tool approval

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

<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 theme={null}
    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](/agents/custom-code-agent/frameworks/ai-sdk#automatic-tool-approval) 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 theme={null}
    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](/agents/custom-code-agent/frameworks/langchain#automatic-tool-approval) 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 theme={null}
    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 |
  </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](/agents/custom-code-agent/building-blocks/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 theme={null}
    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 theme={null}
    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 theme={null}
    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:

| 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](/agents/custom-code-agent/building-blocks/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 theme={null}
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 theme={null}
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:

```tsx theme={null}
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>
  ),
},
```

## Related

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