NewIntroducing Integrations
All guides
Claude Agent SDK
Updated
11 min read
by Anlyon Team

Claude Agent SDK: require human approval before a tool runs, and keep the key out of the agent

How to gate a Claude Agent SDK tool on human approval with canUseTool, and how to route the call through an Anlyon action so the approval is bound to the exact request, a shared limit applies, and the Stripe key never enters the agent's process.

claude agent sdktypescriptapprovalsstripetool callingmcp

To require human approval before a Claude Agent SDK tool runs, pass a canUseTool callback to query() and leave the tool out of allowedTools. The SDK pauses the tool call, your callback asks a reviewer, and it returns allow or deny. That pause lives in your process, so the tool still runs with whatever key your process holds. If the tool spends money or writes to production, route it through a named Anlyon action instead: Anlyon holds the key, binds the approval to the exact request, counts it against a shared limit, and returns a graded receipt.

TL;DR

  • Native: canUseTool (or a PreToolUse hook) gates the call in your process. Good for confirming with the user who is talking to the agent.
  • What it leaves to you: the credential, a limit across sessions and agents, binding the decision to what actually runs, and the record.
  • With Anlyon: the tool calls actions.invoke('refund', ...). The agent's process holds an Anlyon key that can only ask. Anlyon makes the Stripe call after an approver decides.

Statements about the Claude Agent SDK on this page come from Anthropic's documentation as checked on 2026-10-18: permissions, approvals and user input and custom tools.

How do you require approval with the Claude Agent SDK alone?

As of 2026-10-18, the SDK checks every tool call in a fixed order: hooks, deny rules, ask rules, the permission mode, allow rules, and then your canUseTool callback. The callback only fires for a call nothing earlier resolved. Anthropic's docs are explicit that an allow rule or a mode such as bypassPermissions skips it, so a gated tool must not appear bare in allowedTools.

Here is a refund tool gated by the native callback:

// native.ts: the approval runs in your process, and so does the Stripe key.
import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
import Stripe from 'stripe';
import { z } from 'zod';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const refundOrder = tool(
  'refund_order',
  'Refund a Stripe payment. Amount is in cents.',
  { paymentIntent: z.string(), amount: z.number().int() },
  async ({ paymentIntent, amount }) => {
    const refund = await stripe.refunds.create({ payment_intent: paymentIntent, amount });
    return { content: [{ type: 'text', text: `Refund ${refund.id} is ${refund.status}.` }] };
  },
);

const billing = createSdkMcpServer({ name: 'billing', version: '1.0.0', tools: [refundOrder] });

for await (const message of query({
  prompt: 'Order ord_1042 arrived broken. Refund payment pi_3P9x in full: $240.00.',
  options: {
    mcpServers: { billing },
    permissionMode: 'default',
    canUseTool: async (toolName, input) => {
      const ok = await askReviewer(toolName, input); // your UI: a web page, Slack, a terminal
      return ok
        ? { behavior: 'allow', updatedInput: input }
        : { behavior: 'deny', message: 'A reviewer declined this refund. Do not retry.' };
    },
  },
})) {
  if (message.type === 'result' && message.subtype === 'success') console.log(message.result);
}

Three details from Anthropic's docs that matter here:

  • Return updatedInput on allow. The docs note that before Claude Code v2.1.207 an allow without it was rejected.
  • The callback can wait indefinitely. For a reviewer who may take longer than the process should live, the docs point to a PreToolUse hook that returns defer, so the session can be resumed later.
  • Pass permissionMode: 'default' explicitly. When it is omitted, a session can start in auto mode, where a classifier reviews some calls instead.

What does canUseTool not cover?

canUseTool answers one question: may this handler run now? Everything after that is your code.

canUseTool in your processAn Anlyon action
Where the decision is enforcedYour process, while it runs that code pathAnlyon, on its side of the call
Who holds the Stripe keyYour processThe Anlyon vault, attached at dispatch
What runs after approvalWhatever your handler does with updatedInputThe request snapshot the approver reviewed
A limit across sessions and agentsBuild it yourselfOne shared limit per environment, reserved before dispatch
The recordWhat you logThe invocation, the approval and a receipt with a grade
A slow reviewerThe process waits, or you defer and resumeThe tool returns; approval dispatches without the agent running

The credential row matters more in this SDK than in most. The Agent SDK runs Claude Code's built-in tools, including Bash and Read, unless you remove them. Anthropic's docs say the TypeScript env option replaces the subprocess environment, which means that by default it inherits yours. With those tools enabled, a Stripe key in that environment is within reach of a shell command the model can run.

How do you keep the Stripe key out of the agent?

Move the call behind a named action. The agent asks for refund by name. Anlyon resolves the key and calls Stripe.

Claude Agent SDK query()
  -> tool "refund_order" (your process: an Anlyon key that can only ask)
    -> anlyon.actions.invoke("refund", input)
      -> approval bound to the exact request; an approver decides in Anlyon
        -> shared limit reserved; Anlyon attaches the Stripe key and calls api.stripe.com
          -> receipt graded from Stripe's own record of the refund

1. One-time setup, with an operator key

Run this from a deploy script, not from the agent.

// setup.ts
import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.secrets.put('STRIPE_KEY', {
  value: process.env.STRIPE_SECRET_KEY!,
  allowedHosts: ['api.stripe.com'],
});

await ops.actions.create({
  name: 'refund',
  description: 'Refund a Stripe payment intent. Amount is in cents.',
  requiresApproval: true,
  adapter: {
    type: 'stripe.refund',
    config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd' }, // or mode: 'live' with a live key
    credentialSecret: 'STRIPE_KEY',
  },
});

// Every agent, session and key in this environment draws from this one limit.
await ops.impactLimits.create({
  name: 'Refunds per day',
  dimension: 'money',
  currency: 'usd',
  period: 'day',
  amount: 50_000, // $500.00
});

The stripe.refund adapter accepts a live or a test key. The action's mode must match the key.

2. The tool

The handler invokes the action and tells the model, in plain words, what happened and whether to retry.

// refund-tool.ts
import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
import { Client } from '@anlyonhq/sdk';
import { z } from 'zod';

const anlyon = new Client({ apiKey: process.env.ANLYON_AGENT_KEY! });

// Fail at startup if this key can do more than ask.
await anlyon.auth.requireScopes(['actions:invoke', 'actions:read', 'approvals:read'], {
  forbidden: ['actions:write', 'secrets:read', 'secrets:write', 'approvals:decide', 'impact-limits:write'],
});

const say = (text: string) => ({ content: [{ type: 'text' as const, text }] });

/** After a decision the invocation runs. Wait briefly for its final status. */
async function settle(invocationId: string) {
  for (let attempt = 0; attempt < 30; attempt++) {
    const { data } = await anlyon.actions.invocation(invocationId);
    if (data!.status !== 'pending_approval' && data!.status !== 'running') return data!;
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
  return null;
}

const refundOrder = tool(
  'refund_order',
  'Refund a Stripe payment. Amount is in cents. Every refund waits for an approver.',
  {
    orderId: z.string().describe('Your order id. One refund per order.'),
    paymentIntent: z.string().describe('Stripe payment intent id, starting with pi_.'),
    amount: z.number().int().describe('Amount to refund, in cents.'),
  },
  async ({ orderId, paymentIntent, amount }) => {
    const { data } = await anlyon.actions.invoke(
      'refund',
      { paymentIntent, amount, currency: 'usd' },
      { operationKey: `refund:${orderId}` },
    );
    let invocation = data!;

    if (invocation.pendingApproval) {
      const decided = await anlyon.approvals.waitForDecision(invocation.approvalId!, { timeoutMs: 50_000 });
      if (decided.status === 'pending') {
        return say(`The refund is waiting for an approver (${decided.id}). It runs exactly as requested if approved. Do not retry.`);
      }
      if (decided.status !== 'approved') {
        return say(`The refund was ${decided.status}. Nothing was sent to Stripe. Do not retry.`);
      }
      const settled = await settle(invocation.id);
      if (!settled) return say(`The refund was approved and is still running (${invocation.id}). Do not retry.`);
      invocation = { ...settled, pendingApproval: false };
    }

    switch (invocation.grade) {
      case 'confirmed':
        return say(`Refunded ${amount} cents on ${paymentIntent}. Stripe's own record confirms it.`);
      case 'unknown':
        return say('The refund may already have happened. An operator must check Stripe. Do not retry.');
      case 'refused':
        return say(`Not refunded: ${invocation.error ?? 'refused before dispatch'}. Do not retry.`);
      default:
        return say(`Refund status: ${invocation.status} (${invocation.grade ?? 'no grade'}). Do not retry.`);
    }
  },
);

export const billing = createSdkMcpServer({
  name: 'billing',
  version: '1.0.0',
  tools: [refundOrder],
  alwaysLoad: true,
});

Three details that matter:

  • The operation key comes from the order. If the model calls the tool twice for the same order with the same request, the second call returns the existing operation and nothing is sent again.
  • The wait is short on purpose. If no approver has decided within 50 seconds, the tool says so and returns. The approval stays open, and approving it is what dispatches. The agent does not have to be running.
  • unknown is not failed. If Stripe's response was lost, the refund may have happened. The tool says so instead of inviting a retry.

3. The agent

// agent.ts
import { query } from '@anthropic-ai/claude-agent-sdk';
import { billing } from './refund-tool';

for await (const message of query({
  prompt: 'Order ord_1042 arrived broken. Refund payment pi_3P9x in full: $240.00.',
  options: {
    tools: [], // no Bash, Read or Write in this agent
    mcpServers: { billing },
    allowedTools: ['mcp__billing__refund_order'], // the approval that counts is in Anlyon
    permissionMode: 'default',
  },
})) {
  if (message.type === 'result' && message.subtype === 'success') console.log(message.result);
}

The process now holds an Anthropic key and an Anlyon agent key. The agent key can invoke refund and read its own invocations. It cannot read the vault, change the action, raise the limit or decide an approval. The credential that requested an approval can never be the one that decides it.

What does the reviewer approve, and what runs?

The human approves the exact change, and that exact change is what runs. The approval is bound to a SHA-256 digest of the request, target, versions, preconditions and expiry, and Anlyon recomputes it before dispatch and refuses on any difference. Stripe has no compare-and-set, so the refundable amount the reviewer sees is as of review, and the preview labels it that way.

Approval requests notify over Slack, email, Discord or a webhook. The decision happens in the Anlyon console or through the API. Any workspace member can decide an approval today, and there are no approver groups. See binding approval to the action that runs.

What happens when two sessions refund at once?

Every agent, session and key in the environment draws from the same limit, reserved before dispatch. A new session or key does not get a new allowance. If two Claude Agent SDK workers each ask for an approved $80 refund with $100 left, one refund runs and the other is refused before anything is sent to Stripe, with impact_limit_exceeded in the error. The limit covers governed actions routed through Anlyon in one environment. A refund your code makes with its own Stripe key is outside it. See the shared allowance model.

What does the receipt say?

Every action returns a receipt with a grade. For stripe.refund, every success is read from Stripe's own record of the refund.

GradeMeaning
confirmedA read-back matched the request.
acknowledgedThe provider accepted the request. Nothing read it back.
unknownNo usable response. Anlyon does not send it again. It stays unknown until a read-back or an operator settles it.
failedThe provider rejected the request, or it never left Anlyon.
refusedAnlyon refused it at dispatch.
deniedThe approval was denied.
pendingNo receipt yet.

A receipt is a database record with provider evidence. It is not signed and not tamper-evident. Map each grade to a sentence the model can act on, as the tool above does. See structured tool outcomes.

Can you keep canUseTool as well?

Yes. Leave mcp__billing__refund_order out of allowedTools and your callback runs first, which suits a "refund $240 to this customer?" check with the user in the chat. The Anlyon approval still gates the Stripe call, and it holds even when that callback is skipped, because the Stripe key is not in your process. Asking the same reviewer twice is rarely worth it, so pick one place for each question: the user confirms intent in the chat, an approver decides in Anlyon.

Prefer no custom tool code?

Connect the Anlyon MCP server instead. Every enabled action appears as a tool named action_<name>, and wait_for_approval waits up to 120 seconds a call for a decision. The Claude Agent SDK docs page shows the mcpServers entry and the scopes for the agent key.

Anlyon governs the actions routed through its hosted executor. A tool your own code calls directly is not routed through Anlyon and is not governed by it.

Free during Early Beta Access, no credit card. Start building →

Frequently asked questions

How do I require human approval before a tool runs in the Claude Agent SDK?

Pass a canUseTool callback to query() and do not list the tool in allowedTools. As of 2026-10-18, Anthropic's docs say the SDK calls canUseTool for any tool call that no hook, rule or permission mode has already resolved, and your callback returns { behavior: 'allow', updatedInput } or { behavior: 'deny', message }. Use a PreToolUse hook when a check must run on every call.

Does canUseTool keep my API key away from the agent?

No. canUseTool decides whether your tool handler runs. The handler still runs in your process with whatever credential it uses. To keep a provider key out of the agent, have the tool invoke a named Anlyon action. The key stays in the Anlyon vault and Anlyon attaches it at dispatch.

Should I use canUseTool and Anlyon approvals together?

You can. canUseTool is a good place for a quick confirmation from the user in front of the agent. The Anlyon approval is the decision that gates the call to Stripe, and it holds even when that code path is skipped, as long as no other copy of the Stripe key is in your process. Listing the Anlyon tool in allowedTools means the reviewer is asked once.

Continue with a related task

Free tier, no credit card. One command if you use Claude or Cursor.

$ claude mcp add anlyon -- npx -y @anlyonhq/mcp-server