Add Human-in-the-Loop Approval to Claude Agent SDK (2026)
July 13, 2026

A human-in-the-loop approval gate pauses an AI agent before it runs a risky tool call — like Bash or Write — and waits for a person to approve or deny it. In the Claude Agent SDK, you build this with a canUseTool callback, a PreToolUse hook, and an audit-log hook1.
TL;DR
You'll build a ~90-line TypeScript script that wraps @anthropic-ai/claude-agent-sdk with three layers of control: a PreToolUse hook that auto-approves read-only tools and hard-blocks writes to secrets files no matter what anyone decides later, a canUseTool callback that prompts a human in the terminal before Bash, Write, or Edit run, and a PostToolUse hook that appends every tool call to a JSON-lines audit log. Runtime: Node 18+ (Node 22 or 24 LTS recommended), SDK @anthropic-ai/claude-agent-sdk@0.3.150. Build time: 20-25 minutes.
What you'll learn
- How the SDK's five-step permission evaluation flow decides whether a tool call needs a human at all
- How to write a
canUseToolcallback that shows the user what Claude wants to do and returnsallow/deny - How to add a
PreToolUsehook that blocks a whole category of dangerous calls beforecanUseToolis even invoked - How to log every tool call with a
PostToolUsehook, independent of whether it was approved automatically or by a human - The difference between
PreToolUsehooks and thecanUseToolcallback, and when to use each - Which permission modes (
default,dontAsk,acceptEdits,bypassPermissions,plan,auto) skip the human prompt entirely — and why that matters for production agents
Prerequisites
- Node.js 18.0.0 or later — the SDK's
package.jsonsets"engines": {"node": ">=18.0.0"}2; this tutorial was built and type-checked against Node 22.22.3 @anthropic-ai/claude-agent-sdk@0.3.150(the currentlateston npm as of this writing)2, plus its peer dependencieszod@^4.0.0,@anthropic-ai/sdk@>=0.93.0, and@modelcontextprotocol/sdk@^1.29.02- TypeScript 5.x with
strictandnoUncheckedIndexedAccessenabled - An
ANTHROPIC_API_KEYfrom the Claude Platform console if you want to actually run the agent loop end to end (the code in this tutorial is type-checked against the real SDK, but running it costs a small amount of API usage — see the Verification section for what was and wasn't executed)
Why isn't a permission mode enough?
The Claude Agent SDK already ships permission modes — default, dontAsk, acceptEdits, bypassPermissions, plan, and (TypeScript-only) auto1. bypassPermissions runs every tool with no prompts at all; acceptEdits auto-approves file edits and filesystem commands like mkdir/rm/mv. Neither of those gives you a human checkpoint — they're blanket policies, not per-call review. default is the mode that actually reaches a human: it falls through hooks, deny rules, and allow rules, and only then calls your canUseTool callback1. That callback — plus a hook or two to keep the callback from being your only line of defense — is what a real approval gate is built from.
Step 1: Set up the project
mkdir agent-approval-gate && cd agent-approval-gate
npm init -y
npm install @anthropic-ai/claude-agent-sdk@0.3.150 zod@4
npm install -D typescript@5 @types/node@22
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict
Add "type": "module" to package.json so Node treats .js output as ESM (the SDK itself ships as "type": "module", and tsc --moduleResolution NodeNext expects the consuming project to match)2.
Step 2: Understand the permission evaluation flow
Before writing the callback, know what it's actually being asked to decide. When Claude requests a tool, the SDK checks permissions in this fixed order1:
- Hooks —
PreToolUsehooks run first and can allow, deny, or pass through - Deny rules — from
disallowedToolsorsettings.json; these hold even inbypassPermissionsmode - Permission mode —
bypassPermissionsapproves everything that reaches this step;acceptEditsapproves file operations;planapproves read-only tools and blocks the rest - Allow rules — from
allowedToolsorsettings.json canUseToolcallback — only reached if nothing above resolved the call; indontAskmode this step is skipped and the tool is denied outright
The practical takeaway: canUseTool is not "the" gate, it's the last gate. A tool that's already covered by an allow rule, or a mode like acceptEdits, never reaches your callback at all3. If you need a rule that applies to literally every tool call regardless of mode, you need a PreToolUse hook, not canUseTool.
Step 3: Write the deterministic guardrail hook
Hooks run before the rest of the permission flow, so they're the right place for rules that should never be skippable — not even by a human clicking "approve" too fast. This hook does two things: it hard-blocks any file write that touches a secrets path, and it auto-approves read-only tools so a human isn't asked to bless a Grep call.
import type { HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
const READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
const blockSecretWrites: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse") return {};
const preInput = input as PreToolUseHookInput;
const toolInput = preInput.tool_input as Record<string, unknown>;
const filePath = (toolInput?.file_path as string) ?? "";
// Matches .env, .env.local, .env.production, .envrc, and anything
// under a /secrets/ directory.
if (/\.env(rc|\..+)?$|\/secrets\//.test(filePath)) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `${filePath} matches a protected-secrets pattern`,
},
};
}
if (READ_ONLY_TOOLS.includes(preInput.tool_name)) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "read-only tool auto-approved",
},
};
}
return {};
};
Two details that matter here, both verified against the SDK's own type definitions, not just the docs: PreToolUseHookInput carries tool_name and tool_input (typed as unknown, hence the cast)4, and updatedInput/permissionDecision must live inside hookSpecificOutput — a bare top-level permissionDecision is silently ignored3. Returning {} means "no opinion, keep evaluating" — it does not mean deny.
A regex gotcha worth knowing about: the pattern above catches .env, .env.local, and .envrc, but a naive /\.env(\..+)?$/ (without the rc alternation) misses .envrc entirely — I caught this by running both patterns against a small test-case list before publishing (.env → blocked, .env.local → blocked, .envrc → blocked, notes.txt → allowed, src/environment.ts → allowed). If your team uses a different secrets convention (.secrets.yaml, credentials.json), extend the pattern rather than trusting this one verbatim.
Step 4: Write the canUseTool approval callback
Everything that isn't resolved by the hook above — in this demo, that's Bash, Write, and Edit — falls through to canUseTool. This callback prints what Claude wants to do and waits for a y/n answer in the terminal5:
import * as readline from "node:readline/promises";
import type { CanUseTool } from "@anthropic-ai/claude-agent-sdk";
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return rl.question(question).finally(() => rl.close());
}
const approveInTerminal: CanUseTool = async (toolName, input) => {
console.log(`\nClaude wants to use: ${toolName}`);
console.log(JSON.stringify(input, null, 2));
const answer = await prompt("Approve this tool call? (y/n): ");
if (answer.trim().toLowerCase() === "y") {
return { behavior: "allow", updatedInput: input };
}
return { behavior: "deny", message: "User denied this action in the approval gate" };
};
The return type is PermissionResult, which the SDK's compiled types define as a discriminated union: { behavior: "allow", updatedInput?, updatedPermissions?, ... } or { behavior: "deny", message: string, interrupt?: boolean, ... }4. message on a denial isn't just for logs — it's handed back to Claude, which can read it and try a different approach5. The callback's third argument also exposes suggestions (ready-made permission-update entries you can echo back for an "always allow this" option) and toolUseID for correlating with PostToolUse events4 — this demo doesn't use them, but they're there once you outgrow a plain y/n prompt.
One thing that's easy to miss: canUseTool is only reached for tools nothing earlier resolved. If you set permissionMode: "acceptEdits", this callback will never fire for Write or Edit — the mode auto-approves them before evaluation gets this far1.
Step 5: Write the audit-log hook
PostToolUse fires after a tool finishes, whether it was approved by the hook, by a human, or by a permission mode — which makes it the right place for an audit trail that doesn't depend on how the call got approved3.
import { appendFile } from "node:fs/promises";
import type { HookCallback, PostToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";
const auditLog: HookCallback = async (input) => {
if (input.hook_event_name !== "PostToolUse") return {};
const postInput = input as PostToolUseHookInput;
const line = JSON.stringify({
ts: new Date().toISOString(),
tool: postInput.tool_name,
input: postInput.tool_input,
});
await appendFile("audit.log", line + "\n");
return {};
};
This writes one JSON object per line — a format you can tail -f | jq during a live session or ingest into a log pipeline later.
Step 6: Wire it all together
import { query } from "@anthropic-ai/claude-agent-sdk";
async function main() {
for await (const message of query({
prompt:
"Create a file named notes.txt with the text 'hello from the agent', " +
"then run `ls -la` to confirm it exists.",
options: {
permissionMode: "default",
allowedTools: ["Bash", "Write", "Edit", "Read", "Glob", "Grep"],
hooks: {
PreToolUse: [{ hooks: [blockSecretWrites] }],
PostToolUse: [{ hooks: [auditLog] }],
},
canUseTool: approveInTerminal,
},
})) {
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "text") console.log(block.text);
}
}
if ("result" in message) {
console.log("\n--- result ---");
console.log((message as { result: string }).result);
}
}
}
main();
query()'s prompt field accepts a plain string here — you don't need the streaming-generator workaround that the Python SDK's can_use_tool requires in some cases5; that workaround is specific to Python's finite-generator prompt path and doesn't apply to this TypeScript call shape. permissionMode: "default" is what makes canUseTool reachable at all — set it to bypassPermissions and the callback above becomes dead code, since every tool clears the permission-mode step before evaluation gets to it1.
Verification
What was actually run: the full script (all four snippets combined, ~95 lines) was saved to a real project, installed against the real @anthropic-ai/claude-agent-sdk@0.3.150 package from npm, and type-checked with tsc --strict --noUncheckedIndexedAccess. It compiles cleanly. One real finding from that check: the SDK's own sdk.d.ts at this version has unrelated internal type errors (Cannot find name 'RemoteControlHandle', several missing SDKControl*Request types) that surface unless your tsconfig.json sets "skipLibCheck": true — this isn't a bug in the code above, it's a pre-existing issue in the package's shipped types, and every project consuming this SDK version needs skipLibCheck: true (or a patched d.ts) to type-check cleanly.
The blockSecretWrites regex was executed standalone against six test paths:
| Path | Blocked? |
|---|---|
.env | Yes |
.env.local | Yes |
.envrc | Yes |
config/secrets/db.yml | Yes |
notes.txt | No |
src/environment.ts | No |
What was not run: the live query() call against the real Claude API. Doing so requires a paid ANTHROPIC_API_KEY, which this automated writing pipeline doesn't have access to. If you have a key, run:
export ANTHROPIC_API_KEY=sk-ant-...
npx tsx approval-gate.ts
You should see two canUseTool prompts (one for Write, one for Bash) and, after approving both, an audit.log file containing two JSON lines and a notes.txt file in your working directory.
Troubleshooting
canUseTool never fires, even for Bash. Check your permissionMode. bypassPermissions and acceptEdits both resolve tool calls before the callback step is reached1. If you want every non-hook-resolved tool to hit a human, use permissionMode: "default" (or leave it unset, since default is the SDK's own default).
Modified updatedInput from a hook doesn't take effect. You must also return permissionDecision: "allow" (or "ask") in the same hookSpecificOutput object — updatedInput alone, or updatedInput at the top level instead of inside hookSpecificOutput, is ignored3.
Hook never runs at all. Event names are case-sensitive — PreToolUse, not preToolUse or pretooluse3. Also check that your matcher (if you set one) isn't scoped too narrowly; an omitted matcher runs for every tool of that event type.
A subagent bypassed the approval gate entirely. If your top-level options set permissionMode: "bypassPermissions", subagents inherit that mode and it cannot be overridden per-subagent — they get full, autonomous tool access with no prompts1. Don't set bypassPermissions at the top level if any subagent might touch something sensitive.
Both a hook and canUseTool returned different answers. They can't conflict in the sense of one overriding the other after the fact — the evaluation flow is sequential and stops at the first step that resolves the call. But if you register multiple hooks for the same event, and they disagree, deny beats defer, which beats ask, which beats allow3.
How is a PreToolUse hook different from canUseTool?
A PreToolUse hook runs first, for every tool call of the matched type, in every permission mode except when a deny rule from disallowedTools overrides it — it's the layer for rules that must never be skippable13. canUseTool is a runtime callback that only fires for whatever the hooks, deny rules, permission mode, and allow rules didn't already resolve — it's the layer for interactive, session-specific, human-in-the-loop decisions1. Most production agents that need both determinism and human oversight use a hook for the invariants (block /etc, block secrets, auto-approve read-only tools) and canUseTool for everything that's left over.
Next steps and further reading
This tutorial builds the approval layer on top of the SDK's built-in tools (Bash, Write, Edit, Read, Glob, Grep). If your agent needs to call external services instead of local files, the same canUseTool callback and PreToolUse hooks apply to MCP tools you expose over stdio — MCP tool names just follow the mcp__<server>__<action> pattern for matchers. If you're running an MCP server that itself needs auth, see the production MCP server tutorial with OAuth and streamable HTTP. And if you're building the underlying tool-call loop from the raw Messages API instead of the Agent SDK, the Claude tool use agentic loop tutorial covers tool_use/tool_result handling at that lower level.
For notifying a human out-of-band instead of blocking in a terminal, the SDK's PermissionRequest hook exists specifically to forward "Claude is waiting for approval" events to Slack, email, or a push notification service3 — worth reaching for once your approval gate needs to work for a team, not just a terminal session.
Footnotes
-
Anthropic, "Configure permissions" — https://platform.claude.com/docs/en/agent-sdk/permissions (permission evaluation order, permission modes table, bypassPermissions subagent-inheritance warning; fetched 2026-07-13) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
npm registry,
@anthropic-ai/claude-agent-sdk— https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/latest (version 0.3.150,engines.node >=18.0.0, peerDependencieszod ^4.0.0/@anthropic-ai/sdk >=0.93.0/@modelcontextprotocol/sdk ^1.29.0, bundledclaudeCodeVersion2.1.150; fetched 2026-07-13 directly from the registry API. A web-search summary separately reported a newer "0.3.205" version, but that could not be independently confirmed against the registry's ownlatestendpoint at fetch time, so this tutorial is pinned to the registry-confirmed 0.3.150.) ↩ ↩2 ↩3 ↩4 -
Anthropic, "Intercept and control agent behavior with hooks" — https://code.claude.com/docs/en/agent-sdk/hooks (hook event table, PreToolUseHookInput/PreToolUseHookSpecificOutput shape, deny > defer > ask > allow priority, PermissionRequest hook; fetched 2026-07-13) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
@anthropic-ai/claude-agent-sdk@0.3.150compiled type definitions,sdk.d.ts(CanUseTool,PermissionResult,PermissionMode,PreToolUseHookInputtype declarations; installed from npm and inspected directly, 2026-07-13) ↩ ↩2 ↩3 -
Anthropic, "Handle approvals and user input" — https://platform.claude.com/docs/en/agent-sdk/user-input (canUseTool callback walkthrough, PermissionResultAllow/Deny response shapes, Python streaming-mode requirement for can_use_tool; fetched 2026-07-13) ↩ ↩2 ↩3


