Your Claude Code hook can say "allow" and be ignored, without a single error
You wrote a PermissionRequest hook. It decides, it prints "allow", your log says "allowed", and Claude Code shows the ordinary prompt anyway. No error, no warning. It looks exactly like a hook that chose to leave the que
You wrote a PermissionRequest hook. It decides, it prints "allow", your log says "allowed", and Claude Code shows the ordinary prompt anyway. No error, no warning. It looks exactly like a hook that chose to leave the question to you.
We shipped that bug. Every approval tapped on a remote device was thrown away, on every install, for weeks. Here is the one-line cause, and two habits that would have caught it.
The cause: a string where Claude Code wants an object
Older examples print the decision as a string:
"decision": "allow"
Claude Code validates a hook's output. A string fails validation, and a hook that fails validation is treated as non-blocking: the permission flow carries on as if your hook had never answered. The CLI's own validator says what it wants:
PermissionRequest decision must be {"behavior": "allow"} or {"behavior": "deny", "message": "..."}
When we checked, the published hook docs still showed the string form. Trust the validator.
What to print instead
Standard output carries the answer and nothing else. Logs go to standard error.
Allow:
{"hookSpecificOutput": {"hookEventName": "PermissionRequest",
"decision": {"behavior": "allow"}}}
Deny, with a reason Claude reads and acts on:
{"hookSpecificOutput": {"hookEventName": "PermissionRequest",
"decision": {"behavior": "deny", "message": "Not on main. Push to a branch."}}}
Leave it to the person by sending no decision at all:
{"hookSpecificOutput": {"hookEventName": "PermissionRequest"}}
Two habits that would have caught it
-
One function builds the output, and a test checks the exact bytes it prints. Assert that
decisionis an object withbehavior, and that a deny carries amessage. Test what you print, not what you meant to print. - Prove one real allow, end to end. Create a marker file, ask a session to delete it, approve the delete through your hook, and check that the file is gone. If it is still there and the ordinary prompt appeared, your answer was ignored.
Wiring, briefly
In ~/.claude/settings.json, or in a plugin's hooks/hooks.json:
{"hooks": {"PermissionRequest": [{"hooks": [
{"type": "command", "command": "python3 /path/to/your-hook.py", "timeout": 86460}]}]}}
The timeout is in seconds. A hook that waits for a person needs a long one, or the question disappears while they are still reading it. The event arrives as JSON on standard input, with tool_name, tool_input, cwd, and permission_suggestions when the prompt would offer "don't ask again".
Three more traps
- Fail closed. If the hook can't parse the event, or crashes partway, print the "leave it to the person" block. Never allow on a guess.
-
Headless runs skip the hook. A
claude -prun has no prompt to show, so it never reaches aPermissionRequesthook. Use--permission-prompt-toolwith an MCP tool there. - A lingering prompt isn't your bug. The Claude app on a phone can keep showing a prompt your hook already answered (claude-code#32493). The command ran; the marker test settles it.
Checked against Claude Code 2.1.280, September 2026.
I build Tapproval, an open-source PermissionRequest hook in standard-library Python, with an Apple Watch app that answers these prompts from the wrist. Its tests check the exact JSON, because we learned this the hard way.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.