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.
Հայերեն
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
- Hooks (
PreToolUse) can deny, or pass the call on. A hook’sallowdoesn’t skip the deny and ask rules. - Deny rules (
disallowed_tools, settings) block the call, even inbypassPermissions. - Ask rules send the call to your callback, even in
bypassPermissions. - The permission mode may approve it.
bypassPermissionsapproves almost everything, andacceptEditsapproves file edits and simple filesystem commands in your working directories. - Allow rules (
allowed_tools, settings) approve matching calls. - Otherwise your
canUseToolcallback decides. IndontAskmode 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-runor 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 throughcanUseTool, and you returnanswers. If you restricttools, 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
PreToolUsehook returndefer. The turn ends withstop_reasontool_deferredand you resume the call later from the saved session. - Notify. A
PermissionRequesthook 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. canUseToolreturns allow (optionally withupdatedInput) or deny with a message Claude can act on.- Use
PreToolUsehooks for checks that must cover every call, anddeferfor approvals that take longer than a process should wait. - Use plan mode for “propose first, then I approve”, and
AskUserQuestionfor clarifying questions.
$ ./try-it.sh
Try it: an approval gate for destructive infra commands
- 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 asgate.ts. - Run
npx tsx gate.tswith the prompt "Clean up the stale helm release demo-old in namespace sandbox". Check that a read-onlyhelm listruns without asking, whilehelm uninstallstops at your y/n prompt. - 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. - Add
"Bash"toallowedToolsand run again. Watch the uninstall go through with no prompt (theCLAUDE_SDK_CAN_USE_TOOL_SHADOWEDwarning Node prints now listsBash). Then remove it again. - Add a
PreToolUsehook that denies any command containing--auto-approve, and check that it blocks even when you answery.
Check yourself
5 questions. Original practice questions, not taken from the real exam.