PreToolUse hook + background subagent: tools in the notification-woken turn are cancelled as "user doesn't want to take this action"

Status Open
Reported on v2.1.237
Maintainer reply None cached
Activity 2 comments · opened Aug 20, 2026

Summary

When a PreToolUse hook is registered, every tool call made in a turn that was woken by a background subagent's <task-notification> is cancelled at tool entry. The model receives:

The user doesn't want to take this action right now.
STOP what you are doing and wait for the user to tell you how to proceed.

The user did nothing. The transcript records these results with toolDenialKind: "cancelled", and the registered PreToolUse hook is never invoked for the cancelled call — so the cancellation happens before the hook, at the common tool entry point. Read-only tools (Read, Grep, ToolSearch) are cancelled the same way, in 3–70 ms.

The practical damage is that the model reads that message as a user rejection and stops, mid-workflow, with no way to tell that nothing was actually rejected. In our case an agent driving a multi-task implementation workflow halted four separate times over 40 minutes; the two parallel reviewer subagents it dispatches with run_in_background: true were producing the notifications.

Reproduction

// npm i @anthropic-ai/claude-agent-sdk
// CLAUDE_CLI=~/.local/share/claude/versions/2.1.237 node repro.mjs            -> CANCELLED
// CLAUDE_CLI=~/.local/share/claude/versions/2.1.237 node repro.mjs --no-hook  -> all ok
import { query } from "@anthropic-ai/claude-agent-sdk";

const useHook = !process.argv.includes("--no-hook");

const prompt = `Do exactly this, nothing else:
1. Launch two general-purpose subagents in one message, both with run_in_background: true.
   Their tasks: "run echo alpha and report" and "run echo beta and report".
   End your turn right after launching them; do not wait.
2. Each time you receive a task-notification, call Read on /etc/hostname and report what you read.`;

const stats = { tool: 0, ok: 0, cancelled: 0 };
const log = [];

for await (const ev of query({
  prompt,
  options: {
    cwd: "/tmp",
    model: "claude-sonnet-5",
    permissionMode: "default",
    settingSources: [],
    ...(process.env.CLAUDE_CLI && { pathToClaudeCodeExecutable: process.env.CLAUDE_CLI }),
    canUseTool: async () => ({ behavior: "allow", updatedInput: {} }),
    ...(useHook && {
      hooks: {
        // A no-op hook is enough; its logic is irrelevant.
        PreToolUse: [{ hooks: [async (input) => { log.push(`  [hook ran] ${input?.tool_name}`); return {}; }] }],
      },
    }),
  },
})) {
  if (ev.type === "assistant") {
    for (const b of ev.message?.content ?? []) {
      if (b.type === "tool_use") { stats.tool++; log.push(`tool_use ${b.name}`); }
    }
  }
  if (ev.type === "user") {
    for (const b of ev.message?.content ?? []) {
      if (b.type !== "tool_result") continue;
      if (String(b.content ?? "").includes("doesn't want to take this action")) {
        stats.cancelled++; log.push("  -> CANCELLED");
      } else { stats.ok++; log.push("  -> ok"); }
    }
  }
}

console.log(log.join("\n"));
console.log(`hook=${useHook}`, JSON.stringify(stats));

Typical output with the hook (3/3 runs on 2.1.237):

tool_use Agent
  [hook ran] Agent
tool_use Agent
  [hook ran] Agent
  -> ok
  -> ok
tool_use Bash
  -> CANCELLED
tool_use Read
  -> CANCELLED
hook=true {"tool":5,"ok":2,"cancelled":3}

Note that the cancelled calls have no [hook ran] line.

Expected vs actual

Expected: a background subagent finishing is not user input; per AIS/kIS in the bundle, task-notification is deliberately excluded from the "user intent" queue modes, so it should not interrupt anything. Tools in the woken turn should run normally.

Actual: the woken turn behaves as if an abort is already pending — tools are cancelled at entry with the shared cancel/user-reject message constant.

What isolates it

Each row is the same script with one variable changed, on 2.1.237:

| Configuration | Cancelled |
| --- | --- |
| background subagents, no PreToolUse hook | 0 (4 runs) |
| background subagents + settingSources: ["user","project","local"], no hook | 0 |
| background subagents + no-op PreToolUse hook | 1–3 every run (5 runs) |
| same, permissionMode = auto / acceptEdits / bypassPermissions / default | reproduces in all four |
| foreground subagents (run_in_background: false) + hook | 0 (4 runs, dispatched in the same message so they still run concurrently) |

So both conditions are required: a registered PreToolUse hook, and a turn woken by a background subagent notification. The hook's return value is irrelevant — a hook that returns {} is enough. permissionMode is irrelevant, including bypassPermissions, which is consistent with the cancellation sitting outside the permission path entirely.

Versions

  • @anthropic-ai/claude-agent-sdk 0.3.181, macOS 15 (darwin-arm64), Node 22.22
  • CLI 2.1.233 and 2.1.237: both reproduce
  • CLI 2.1.181 (the build bundled with SDK 0.3.181, used when pathToClaudeCodeExecutable is unset): does not reproduce, 4 runs

So this appears to have been introduced somewhere in (2.1.181, 2.1.233].

Two smaller notes

  1. Cancellation and user rejection share one message constant, so an agent cannot distinguish "the harness cancelled this" from "the human refused this". A distinct string for the cancel path, or surfacing toolDenialKind to the model, would let agents recover instead of halting.
  2. The cancellation is silent from the host application's point of view: no hook fires and canUseTool is never called, so an SDK host that logs its own permission decisions records nothing at all for these calls. That made this considerably harder to diagnose than it needed to be.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗