Tutorials
Ren Okabe7 min read8 views

Unknown hook type: Claude Agent SDK 0.3.257 Drops the Hook, Not the File (2026)

Since @anthropic-ai/claude-agent-sdk 0.3.257 a malformed hook entry is deleted from settings.json and the rest of the file loads normally, so the hook just never runs. PreToolUse and PermissionRequest are the two exceptions, where the whole file is voided. Bisected against 0.3.252, with a runnable detector.

Dark split-panel illustration of a JSON settings file: the left lane is dimmed out behind a shield and a grey X, the right lane stays lit with one line dissolving into particles.
Dark split-panel illustration of a JSON settings file: the left lane is dimmed out behind a shield and a grey X, the right lane stays lit with one line dissolving into particles.
On this page

Quick Answer (2026)

text
Unknown hook type "commnad"; entry ignored.
Valid types: command, prompt, agent, http, mcp_tool

Anthropic Since @anthropic-ai/claude-agent-sdk 0.3.257 (published 1 September 2026), a malformed hook entry in settings.json is deleted from your config and the rest of the file loads normally. Your hook simply never fires. Before 0.3.257 the same typo threw the entire settings file away, which you noticed immediately.

There is one exception, and it is the whole story: on PreToolUse and PermissionRequest a malformed entry still voids the file. Those two events are treated as security guards. The other 31 of 33 hook events are stripped quietly.

So the same typo has two opposite outcomes depending only on which event name it sits under:

Scroll to see more

Hook eventMalformed entryRest of the file
PreToolUse, PermissionRequestfatalnothing loads, including your permissions.deny rules
the other 31 eventsstripped, warning onlyloads normally, hook never runs

Everything below is reproduced on @anthropic-ai/claude-agent-sdk 0.3.270 with Node 24.8.0, in September 2026, and bisected against 0.3.252.

Reproduce it in one file

Put this in .claude/settings.json. The only defect is a transposed commnad.

json
{
  "permissions": { "deny": ["Bash(rm:*)"] },
  "hooks": {
    "PostToolUse": [
      { "matcher": "Write", "hooks": [{ "type": "commnad", "command": "echo post" }] }
    ]
  }
}

TypeScript Now read it back through the SDK's own resolver:

javascript
import { resolveSettings } from '@anthropic-ai/claude-agent-sdk';

const r = await resolveSettings({ cwd: process.cwd(), settingSources: ['project'] });
console.log(JSON.stringify(r.effective.hooks, null, 2));
text
{
  "PostToolUse": [
    {
      "matcher": "Write",
      "hooks": []
    }
  ]
}

The matcher survived. Its hooks array is empty. That empty array is the entire symptom. No exception was thrown, and the process wrote zero bytes to stderr. The permissions.deny rule beside it loaded fine.

Move the same typo to PreToolUse and re-run:

text
effective: {}
sources:   []

The file contributed nothing at all. The deny rule is gone too.

The split is exactly two events, across every malformation

I ran four malformation shapes against seven events. The split holds for all of them.

Scroll to see more

MalformationPreToolUsePermissionRequestPostToolUseSessionStartStopUserPromptSubmit
unknown typefile voidedfile voidedstrippedstrippedstrippedstripped
entry is a string, not an objectfile voidedn/astrippedn/an/an/a
matcher is a string, not an objectfile voidedn/astrippedn/an/an/a
hooks is a string, not an arrayfile voidedn/astrippedn/an/an/a

The guard set is a two-element literal in the shipped bundle, and HOOK_EVENTS exports 33 names, so the ratio really is 2 against 31:

javascript
import { HOOK_EVENTS } from '@anthropic-ai/claude-agent-sdk';
console.log(HOOK_EVENTS.length); // 33

What changed, and when

Bisected against the registry. The behaviour arrives in a single release.

Scroll to see more

VersionPublishedMalformed PostToolUse hookMalformed PreToolUse hook
0.3.25231 Aug 2026file voidedfile voided
0.3.2571 Sep 2026stripped, file loadsfile voided
0.3.27012 Sep 2026stripped, file loadsfile voided

This matters if you upgraded across that boundary. A typo that used to break your whole configuration loudly, on the first run, now removes one hook and says nothing. If you fixed a broken settings file some time in late August and never went back, check it again.

npm The package moves fast: 295 published versions, with 0.3.270 landing on 12 September 2026. Pin it if you depend on this behaviour either way.

Why the split exists

The reasoning is in the bundle, and it is sound.

Source, verbatim from the shipped sdk.mjs: "a PreToolUse/PermissionRequest hook that cannot be loaded may be what guards the permissions declared beside it, so nothing it sits in is applied until the entry is fixed or removed"

A PreToolUse hook is frequently the thing that decides whether a tool call is allowed. If it silently vanished while the permissive rules in the same file stayed active, you would be running with a gate you believe exists and does not. Discarding the whole file is the conservative choice. A PostToolUse logger has no such property, so dropping it is cheap.

The design is defensible. The problem is that the SDK surfaces neither outcome to a caller: resolveSettings returns a plain object in both cases and throws nothing.

Detect a stripped hook

Two signatures, two checks. Save this as check-hooks.mjs and run it against a project directory.

javascript
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { resolveSettings } from '@anthropic-ai/claude-agent-sdk';

const cwd = resolve(process.argv[2] ?? process.cwd());
const resolved = await resolveSettings({ cwd });
const loaded = new Set(resolved.sources.map((s) => s.path).filter(Boolean));
let found = 0;

// 1. A file that exists on disk but contributed nothing was voided outright.
for (const [label, file] of [
  ['project', resolve(cwd, '.claude/settings.json')],
  ['local', resolve(cwd, '.claude/settings.local.json')],
]) {
  let raw;
  try { raw = JSON.parse(await readFile(file, 'utf8')); } catch { continue; }
  if (loaded.has(file)) continue;
  found++;
  const guards = Object.keys(raw.hooks ?? {}).filter(
    (e) => e === 'PreToolUse' || e === 'PermissionRequest',
  );
  console.log(`VOIDED    ${label} ${file}`);
  console.log(guards.length
    ? `          loaded nothing. Check hooks.${guards.join(' and hooks.')} first.`
    : '          loaded nothing, and declares no guard hooks. Check JSON syntax.');
}

// 2. A matcher that survived with an empty hooks array had its entries stripped.
for (const { source, path, settings } of resolved.sources) {
  for (const [event, matchers] of Object.entries(settings.hooks ?? {})) {
    if (!Array.isArray(matchers)) continue;
    if (matchers.length === 0) {
      found++;
      console.log(`STRIPPED  ${source} ${path ?? ''}`);
      console.log(`          hooks.${event} is [] (every matcher was dropped)`);
    }
    matchers.forEach((m, i) => {
      if (Array.isArray(m?.hooks) && m.hooks.length === 0) {
        found++;
        console.log(`STRIPPED  ${source} ${path ?? ''}`);
        console.log(`          hooks.${event}[${i}] matcher=${JSON.stringify(m.matcher)} has hooks: []`);
      }
    });
  }
}

console.log(found === 0 ? 'OK: every hook entry survived.' : `${found} problem(s) found.`);
process.exit(found === 0 ? 0 : 1);

Against the three configurations above it prints:

text
$ node check-hooks.mjs ./post-tool-use-typo
STRIPPED  project /.../.claude/settings.json
          hooks.PostToolUse[0] matcher="Write" has hooks: []
1 problem(s) found.

$ node check-hooks.mjs ./pre-tool-use-typo
VOIDED    project /.../.claude/settings.json
          loaded nothing. Check hooks.PreToolUse first.
1 problem(s) found.

$ node check-hooks.mjs ./all-valid
OK: every hook entry survived.

Check 1 exists because the voided case cannot be spotted by reading effective: an absent file and a rejected file look identical there. You have to compare what is on disk against what sources reports.

The five valid hook types

type is a discriminated union. Anything else is an unknown type.

Scroll to see more

typeWhat it does
commandruns a shell command, or an executable directly when you pass args
promptevaluates a prompt with an LLM
agentruns an agentic verifier
httpcalls an HTTP endpoint
mcp_toolinvokes an MCP tool

Note mcp_tool is snake_case while every option key around it is camelCase. That one is easy to get wrong, and getting it wrong is now silent on 31 of 33 events.

What this does not affect

JavaScript This is settings-file validation only. Hooks you pass programmatically are a different surface:

javascript
// Not affected: these are functions, validated by your type checker.
for await (const message of query({
  prompt: 'Review the auth module',
  options: {
    hooks: {
      PreToolUse: [{ hooks: [async (input) => ({ continue: true })] }],
    },
  },
})) { /* ... */ }

Options.hooks is typed as a record of HookCallbackMatcher arrays, so a bad shape is a compile error rather than a runtime strip. If you register hooks only that way, none of this applies to you.

It does apply to SDK callers who never touch settings deliberately, though. settingSources loads every source when you omit it, matching the CLI defaults, so a stray .claude/settings.json in the working directory is read by default. Pass settingSources: [] if you want none of it.

For the broader picture of what hooks can do once they actually load, see the Claude Agent SDK hooks tutorial. For the related trap where an allowedTools entry silently switches tools back on, see TodoWrite no longer available.

Checklist

  1. Run the detector above against every project that ships a .claude/settings.json.
  2. Look for "hooks": [] under a matcher you did not intend to empty.
  3. If a settings file seems to do nothing at all, suspect PreToolUse or PermissionRequest before you suspect JSON syntax.
  4. Spell mcp_tool with an underscore.
  5. Pin the SDK if the fail-loud behaviour of 0.3.252 and earlier is something your CI relied on.
  6. Remember this is a settings-file concern, not a programmatic hooks option concern.

Sources

R

Written by

Ren Okabe

Ren builds and breaks agent tooling, then writes down the parts the documentation assumes you already know.

Frequently asked questions

What does 'Unknown hook type; entry ignored' mean in Claude Code?

It means the 'type' field on one of your hook entries is not one of the five values the SDK accepts: command, prompt, agent, http or mcp_tool. Since @anthropic-ai/claude-agent-sdk 0.3.257, published 1 September 2026, that entry is deleted from your resolved settings and the rest of the file loads normally. The hook never runs. On PreToolUse and PermissionRequest the same defect is fatal instead, and the entire settings file is discarded.

Why does my Claude Code hook not fire even though settings.json looks fine?

The most likely cause is that the entry failed schema validation and was stripped. Read the file back with resolveSettings from the SDK and look at effective.hooks. A matcher that survived with an empty hooks array is the signature: the matcher loaded, its entries did not. Nothing is thrown and nothing is written to stderr, so the only way to see it is to inspect the resolved object.

Which hook events are treated as security guards?

Exactly two of the thirty-three events in HOOK_EVENTS: PreToolUse and PermissionRequest. A malformed entry under either of those voids the whole settings file, including permission rules declared beside it. The other thirty-one events strip the bad entry and keep going. The SDK's own reasoning is that a guard hook may be what enforces the permissions in the same file, so leaving those rules active without it would be worse than loading nothing.

When did this behaviour change?

In @anthropic-ai/claude-agent-sdk 0.3.257, published 1 September 2026. Bisected against 0.3.252 from 31 August 2026, where a malformed hook on any event, guard or not, discarded the entire settings file. If you upgraded across that boundary, a typo that used to break loudly now fails silently on thirty-one of the thirty-three events.

Does this affect hooks passed to query() in code?

No. Options.hooks is typed as a record of HookCallbackMatcher arrays, which hold JavaScript functions rather than JSON objects with a 'type' field. A bad shape there is a type error at compile time, not a runtime strip. This is settings-file validation only. It still reaches SDK callers indirectly, because settingSources loads every filesystem source when you omit it, so a stray .claude/settings.json in the working directory is read by default.

How do I check whether a hook was silently removed?

Call resolveSettings and test two things. First, compare the settings files that exist on disk against the paths listed in the returned sources array: a file present on disk but missing from sources was voided outright, which points at a guard hook. Second, walk effective.hooks for any matcher whose hooks array is empty, which means its entries were stripped individually. The article includes a runnable script that does both and exits non-zero on either.