Claude Code's PermissionRequest hook: the decision shape that works
Written from building Tapproval, which answers these prompts from an Apple Watch. Checked against Claude Code 2.1.280, September 2026.
When Claude Code is about to show a permission prompt ("May I run this command?", "May I edit this file?"), it first runs any PermissionRequest hooks you have configured. A hook can answer the prompt, deny it with a reason Claude reads, or say nothing and let the ordinary prompt appear. That makes it the one supported place to route approvals somewhere else: a phone, a chat, or a watch.
It also has a trap that cost us every wrist approval for weeks, and nothing reported it.
Configuring the hook
In ~/.claude/settings.json (or 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. Claude Code stops a hook at its timeout, so a hook that waits for a person (a tap on a watch, say) needs a long one, or the question vanishes mid-glance.
What the hook receives
The event arrives as JSON on standard input. The fields that matter for a decision:
tool_name:Bash,Edit,Write,AskUserQuestion,ExitPlanMode, anmcp__…tool, and so on.tool_input: the call's arguments: the command, the file path and new text, the question and its options, or the plan.cwd: where the session is working.permission_suggestions: present when the ordinary prompt would offer "don't ask again".
What the hook must print
Standard output carries the answer and nothing else: one JSON object. Diagnostics 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: print the block with no decision at all. The ordinary prompt then appears exactly as if no hook were installed.
{"hookSpecificOutput": {"hookEventName": "PermissionRequest"}}
Allow, and change the session's mode. An allow may also carry updatedPermissions. This is how "approve this plan and switch to auto mode" works:
{"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {"behavior": "allow",
"updatedPermissions": [{"type": "setMode", "mode": "auto",
"destination": "session"}]}}}
The trap: a string decision is silently ignored
Older examples show the decision as a string: "decision": "allow". Claude Code validates the shape, and a string fails that check. A hook whose output fails validation is treated as non-blocking: the permission flow carries on as if the hook had not answered.
Nothing looks wrong. There is no error in the session. The ordinary prompt appears, which is exactly what a hook that deliberately left the question to a person would produce. Your own log says "allowed". We shipped that for weeks: every approval tapped on a watch was discarded, on every install, until we read the CLI's own validator message:
PermissionRequest decision must be {"behavior": "allow"} or {"behavior": "deny", "message": "..."}
Two habits would have caught it:
- Build the output in one function, and test the exact bytes it prints, not what you meant it to print.
- Prove a real allow end to end once. Create a marker file, have a session delete it behind a prompt, allow it from the hook, and check the file is gone.
Other things worth knowing
- Fail closed. If the hook cannot parse the event or crashes, print the "leave it to the person" block. Never allow on a guess.
- Questions and plans are prompts too.
AskUserQuestionandExitPlanModereach the hook. A deny whose message carries the chosen option ("The user answered: …") is how an answer travels back from outside. - Headless runs. A
claude -prun has no prompt to show, so it does not reach aPermissionRequesthook. Use--permission-prompt-toolwith an MCP tool there instead. - A known display bug. When a hook answers, the Claude app on a phone can keep showing the prompt anyway (claude-code#32493). The command did run; any message from the phone clears it.
A worked example
Tapproval Base is an open-source PermissionRequest hook, standard-library Python only. It sorts each call into five risk tiers, sends it to an Apple Watch, and prints the answer in the shape above. _permission_output() in ClaudeRiskClassifier.py is the one function that builds the decision, and its tests check the exact JSON.