---
title: Add Human-in-the-Loop Approval to Claude Agent SDK (2026)
description: 'Build a human-in-the-loop approval gate for the Claude Agent SDK in TypeScript: canUseTool prompts, PreToolUse hooks, and a full audit log for every tool call.'
url: https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial
language: en
date: '2026-07-13'
category: ai-ml
tags:
  - claude agent sdk
  - human-in-the-loop
  - ai agents
  - typescript
  - agent safety
  - tool use
  - canUseTool
  - guardrails
markdown_url: https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Add Human-in-the-Loop Approval to Claude Agent SDK (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 hook[^1].

## 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 `canUseTool` callback that shows the user what Claude wants to do and returns `allow`/`deny`
- How to add a `PreToolUse` hook that blocks a whole category of dangerous calls before `canUseTool` is even invoked
- How to log every tool call with a `PostToolUse` hook, independent of whether it was approved automatically or by a human
- The difference between `PreToolUse` hooks and the `canUseTool` callback, 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.json` sets `"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 current `latest` on npm as of this writing)[^2], plus its peer dependencies `zod@^4.0.0`, `@anthropic-ai/sdk@>=0.93.0`, and `@modelcontextprotocol/sdk@^1.29.0`[^2]
- TypeScript 5.x with `strict` and `noUncheckedIndexedAccess` enabled
- An `ANTHROPIC_API_KEY` from the [Claude Platform console](https://platform.claude.com/settings/keys) 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) `auto`[^1]. `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` callback[^1]. 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

```bash
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 order[^1]:

1. **Hooks** — `PreToolUse` hooks run first and can allow, deny, or pass through
2. **Deny rules** — from `disallowedTools` or `settings.json`; these hold even in `bypassPermissions` mode
3. **Permission mode** — `bypassPermissions` approves everything that reaches this step; `acceptEdits` approves file operations; `plan` approves read-only tools and blocks the rest
4. **Allow rules** — from `allowedTools` or `settings.json`
5. **`canUseTool` callback** — only reached if nothing above resolved the call; in `dontAsk` mode 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 all[^3]. 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.

```typescript
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 ignored[^3]. 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 terminal[^5]:

```typescript
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 approach[^5]. 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` events[^4] — 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 far[^1].

## 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 approved[^3].

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

```typescript
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 cases[^5]; 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 it[^1].

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

```bash
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 reached[^1]. 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 ignored[^3].

**Hook never runs at all.** Event names are case-sensitive — `PreToolUse`, not `preToolUse` or `pretooluse`[^3]. 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 prompts[^1]. 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 `allow`[^3].

## 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 skippable[^1][^3]. `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 decisions[^1]. 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](https://nerdleveltech.com/mcp-client-typescript-stdio-tools-tutorial) — 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](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial). 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](https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-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 service[^3] — worth reaching for once your approval gate needs to work for a team, not just a terminal session.

## Related reads

- [Mastra Durable Agents: A 2026 TypeScript Tutorial](https://nerdleveltech.com/mastra-durable-agents-typescript-tutorial)
- [Claude Agent SDK Observability With OpenTelemetry (2026)](https://nerdleveltech.com/claude-agent-sdk-opentelemetry-typescript-tutorial)
- [Google ADK 2.0 Workflow: Retries and Timeouts Tested (2026)](https://nerdleveltech.com/google-adk-workflow-retry-timeout-routing-tested)
- [OpenAI Agents SDK needsApproval: Null Bug Tested](https://nerdleveltech.com/openai-agents-js-needsapproval-null-optional-tested)
- [ADK ToolCallIntegrityPlugin: Tamper Test Results](https://nerdleveltech.com/google-adk-toolcallintegrityplugin-tamper-tested)
- [OpenAI Agents SDK 0.23: Tool Error Redaction Tested](https://nerdleveltech.com/openai-agents-python-tool-error-redaction-tested)
- [OpenAI Agents SDK maxTurns After Approval, Tested](https://nerdleveltech.com/openai-agents-js-maxturns-approval-resume-tested)

[^1]: 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]: 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`, peerDependencies `zod ^4.0.0` / `@anthropic-ai/sdk >=0.93.0` / `@modelcontextprotocol/sdk ^1.29.0`, bundled `claudeCodeVersion` 2.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 own `latest` endpoint at fetch time, so this tutorial is pinned to the registry-confirmed 0.3.150.)
[^3]: 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)
[^4]: `@anthropic-ai/claude-agent-sdk@0.3.150` compiled type definitions, `sdk.d.ts` (`CanUseTool`, `PermissionResult`, `PermissionMode`, `PreToolUseHookInput` type declarations; installed from npm and inspected directly, 2026-07-13)
[^5]: 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)
