Skip to content

C1 · lesson 5 · 10 min read

Human-in-the-loop approvals

Put a person in front of the calls that matter, using the permission order, canUseTool, hooks and plan mode.

Ընտրեք տեքստը՝ հայերեն թարգմանությունը տեսնելու համար։Highlight text to see Armenian. Machine-assisted translation; English is the source of truth.

Official video by Anthropic on YouTube. Pressing play loads it from youtube-nocookie.com. ·open on YouTube

Autonomy is a dial, not a switch. Reading logs needs no approval. Running terraform apply against production needs a named human. The Agent SDK gives you several places to put that human. The skill is knowing which call reaches which place.

The order every tool call goes through

1 hooks2 deny3 ask4 mode5 allow6 canUseToolblockedrunsa call approved at steps 4–5 never reaches the human at step 6
Each step can decide the call. Your canUseTool callback, where a person can approve, only sees what the earlier steps left undecided.
  1. Hooks (PreToolUse) can deny, or pass the call on. A hook’s allow doesn’t skip the deny and ask rules.
  2. Deny rules (disallowed_tools, settings) block the call, even in bypassPermissions.
  3. Ask rules send the call to your callback, even in bypassPermissions.
  4. The permission mode may approve it. bypassPermissions approves almost everything, and acceptEdits approves file edits and simple filesystem commands in your working directories.
  5. Allow rules (allowed_tools, settings) approve matching calls.
  6. Otherwise your canUseTool callback decides. In dontAsk mode this step is skipped and the call is denied.

The consequence trips people up: a call approved at step 4 or 5 never reaches your callback. If allowedTools contains a bare "Bash", your carefully built approval prompt for helm uninstall never fires. Scope the allow rule (for example "Bash(helm list *)"), or put the check in a PreToolUse hook, which runs for approved calls too.

The canUseTool callback

import { query } from "@anthropic-ai/claude-agent-sdk";
import * as readline from "node:readline/promises";

const RISKY = /\b(helm uninstall|kubectl delete|terraform (apply|destroy))\b/;

async function ask(q: string): Promise<string> {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  try { return await rl.question(q); } finally { rl.close(); }
}

for await (const msg of query({
  prompt: process.argv[2] ?? "Clean up the stale helm release demo-old in namespace sandbox",
  options: {
    allowedTools: ["Read", "Grep", "Bash(helm list *)", "Bash(kubectl get *)"],
    canUseTool: async (toolName, input) => {
      const cmd = toolName === "Bash" ? String(input.command) : JSON.stringify(input);
      if (toolName === "Bash" && !RISKY.test(cmd)) {
        return { behavior: "deny", message: "Only read-only or explicitly approved commands are allowed." };
      }
      const answer = await ask(`\n${toolName}: ${cmd}\nApprove? (y/n) `);
      return answer.trim().toLowerCase() === "y"
        ? { behavior: "allow", updatedInput: input }
        : { behavior: "deny", message: "On-call declined. Propose a dry run or ask what to do instead." };
    },
  },
})) {
  if (msg.type === "result") console.log(msg.subtype, msg.subtype === "success" ? msg.result : "");
}

The callback receives the tool name, its input, and options (in TypeScript, an AbortSignal and suggestions). It returns one of two things:

  • Allow: { behavior: "allow", updatedInput }. You can change the input first, for example to add --dry-run or pin a namespace. Claude isn’t told that you changed it.
  • Deny: { behavior: "deny", message }. Claude sees the message and adapts, so make it say what to do instead.

In Python the return types are PermissionResultAllow(updated_input=...) and PermissionResultDeny(message=...). The documented Python examples use a streaming prompt plus a no-op PreToolUse hook to keep the stream open for the callback.

Other human touchpoints

  • AskUserQuestion. Claude asks a clarifying question with 2–4 options per question. It arrives through canUseTool, and you return answers. If you restrict tools, include it in the list. It isn’t available inside subagents.
  • Plan mode. With permission_mode="plan", Claude explores and proposes. Its file edits go to your callback even when an allow rule matches. Subagents inherit plan mode, so delegating doesn’t get around the review.
  • Long waits. The callback can stay pending forever. If approval may take hours (a change-advisory board, say), have a PreToolUse hook return defer. The turn ends with stop_reason tool_deferred and you resume the call later from the saved session.
  • Notify. A PermissionRequest hook can post to Slack or page someone when an approval is waiting.

Recap

  • Order: hooks, then deny, ask, mode, allow, and finally canUseTool. Auto-approved calls never reach the human.
  • canUseTool returns allow (optionally with updatedInput) or deny with a message Claude can act on.
  • Use PreToolUse hooks for checks that must cover every call, and defer for approvals that take longer than a process should wait.
  • Use plan mode for “propose first, then I approve”, and AskUserQuestion for clarifying questions.

$ ./try-it.sh

Try it: an approval gate for destructive infra commands

  1. In a new folder run npm init -y && npm pkg set type=module && npm install @anthropic-ai/claude-agent-sdk && npm install --save-dev tsx, then save the gate from this lesson as gate.ts.
  2. Run npx tsx gate.ts with the prompt "Clean up the stale helm release demo-old in namespace sandbox". Check that a read-only helm list runs without asking, while helm uninstall stops at your y/n prompt.
  3. Answer n. Check that Claude receives your deny message and suggests a dry-run or asks you what to do, rather than trying the same command again.
  4. Add "Bash" to allowedTools and run again. Watch the uninstall go through with no prompt (the CLAUDE_SDK_CAN_USE_TOOL_SHADOWED warning Node prints now lists Bash). Then remove it again.
  5. Add a PreToolUse hook that denies any command containing --auto-approve, and check that it blocks even when you answer y.

Check yourself

5 questions. Original practice questions, not taken from the real exam.

  1. You're building a chat-ops bot on the Agent SDK. When Claude wants to run a command that no rule covers, a human must approve or reject it.

    1. Which option provides that hook point?

  2. Your canUseTool callback is meant to prompt before kubectl delete, but the command ran without any prompt. The options include allowedTools: ["Read", "Bash"].

    2. Why didn't the callback fire?

  3. Your canUseTool callback receives a Bash call for terraform apply in the staging stack.

    3. Which two return values are valid ways to handle it? (Choose two.)

    Choose 2.

  4. Production changes need sign-off from a change-advisory board, which can take hours. Your agent runs on a spot instance that can't keep a process waiting that long.

    4. What's the best design?

  5. You want Claude to explore a Helm chart and propose a migration plan, but not to edit any file until you've reviewed the plan.

    5. Which permission mode fits?

Answer every question to check.