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.
On this page
Quick Answer (2026)
Unknown hook type "commnad"; entry ignored.
Valid types: command, prompt, agent, http, mcp_tool
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 event | Malformed entry | Rest of the file |
|---|---|---|
PreToolUse, PermissionRequest | fatal | nothing loads, including your permissions.deny rules |
| the other 31 events | stripped, warning only | loads 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.
{
"permissions": { "deny": ["Bash(rm:*)"] },
"hooks": {
"PostToolUse": [
{ "matcher": "Write", "hooks": [{ "type": "commnad", "command": "echo post" }] }
]
}
}
Now read it back through the SDK's own resolver:
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));
{
"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:
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
| Malformation | PreToolUse | PermissionRequest | PostToolUse | SessionStart | Stop | UserPromptSubmit |
|---|---|---|---|---|---|---|
unknown type | file voided | file voided | stripped | stripped | stripped | stripped |
| entry is a string, not an object | file voided | n/a | stripped | n/a | n/a | n/a |
| matcher is a string, not an object | file voided | n/a | stripped | n/a | n/a | n/a |
hooks is a string, not an array | file voided | n/a | stripped | n/a | n/a | n/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:
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
| Version | Published | Malformed PostToolUse hook | Malformed PreToolUse hook |
|---|---|---|---|
| 0.3.252 | 31 Aug 2026 | file voided | file voided |
| 0.3.257 | 1 Sep 2026 | stripped, file loads | file voided |
| 0.3.270 | 12 Sep 2026 | stripped, file loads | file 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.
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.
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:
$ 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
type | What it does |
|---|---|
command | runs a shell command, or an executable directly when you pass args |
prompt | evaluates a prompt with an LLM |
agent | runs an agentic verifier |
http | calls an HTTP endpoint |
mcp_tool | invokes 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
This is settings-file validation only. Hooks you pass programmatically are a different surface:
// 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
- Run the detector above against every project that ships a
.claude/settings.json. - Look for
"hooks": []under a matcher you did not intend to empty. - If a settings file seems to do nothing at all, suspect
PreToolUseorPermissionRequestbefore you suspect JSON syntax. - Spell
mcp_toolwith an underscore. - Pin the SDK if the fail-loud behaviour of 0.3.252 and earlier is something your CI relied on.
- Remember this is a settings-file concern, not a programmatic
hooksoption concern.
Sources
- Claude Code hooks reference, the vendor's first-party documentation for hook events and entry shapes.
@anthropic-ai/claude-agent-sdkon npm, read 14 September 2026: version 0.3.270, published 12 September 2026, 295 versions.- Unknown hook type: sessionStart on the Cursor forum, an example of the same error class in a competing agent CLI, which reports it as a hard config error rather than stripping it.
claude-hook-utils, a third-party helper whose own README documents how it handles an unknown hook type.
Written by
Ren OkabeRen 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.
Related tutorials
Claude Agent SDK Hooks: Block, Modify, and Audit Tool Calls (2026)
Register PreToolUse and PostToolUse hooks in the Claude Agent SDK to block dangerous tool calls, rewrite a tool's arguments, and audit every action. Copy-paste runnable Python and TypeScript, August 2026.
TodoWrite No Longer Available: Claude Code's 2.1.233 Tool Gate (2026)
"TodoWrite no longer available" is not a bug. Claude Code v2.1.233 drops the five task-tracking tools on Sonnet 5 and newer unless you opt in. The gate follows the session, not the model, and the SDK opt-in line behaves differently in TypeScript and Python (2026).
Claude Agent SDK Subagents: Isolate Context and Run Tasks in Parallel (2026)
Define subagents in the Claude Agent SDK with the agents parameter and AgentDefinition: isolate context, run focused subtasks in parallel, restrict each one's tools, and confirm delegation. Runnable Python and TypeScript, August 2026.