Request: clean up hooks
I think that JSON-hook-output should be the universal standard: all other hook behaviors should be expressible as JSON-output hook behaviors.
{Pre,Post}ToolUseHook json-output isn't as expressive as stdio-output hooks
In {Pre,Post}ToolUseHook, if stdout doesn't start with { and exit_code is not 0/2, then current behavior is that
- The tool's result goes into the transcript
- The hook's stderr is shown to the user in yellow
- The assistant gets to execute
This behavior isn't expressible within JSON-output for those two hooks. I think we should move towards JSON-output being the standard, and all behaviors being expressible as JSON-output hooks.
UserPromptSubmitHook only allows to append to the user prompt, not prepend
Currently for UserPromptSubmitHook, hookSpecificOutput.additionalContext is material that gets appended to the user prompt. However the hook schema doesn't allow us to express material that gets prepended to the user prompt. We should add it.
Why? Consider the first user message in a Claude Code session. That prepends "<system-reminder># claudeMd ..." to the first user prompt. We currently can't express this using hooks. But if UserPromptSubmitHook allowed prepending, then this system-reminder could be achieved cleanly through a hook (rather than hard-coded into Claude Code).
This is worth doing as part of a quest to move all Claude Code specific behavior out of the agentic loop, and into hooks in an MCP server: https://github.com/anthropics/claude-code/issues/6981
Documentation is unclear
I spent a day working painstakingly through every possible variant of hook output. I came up with precise specifications of the hook behaviors. https://github.com/ljw1004/mini_agent/blob/main/typedefs.py
I'd love it if the official documentations could become more precise about behaviors. The official docs could copy+paste the specifications I wrote. I think the style should be:
- Express all legacy hook outputs as rewrites into the latest JSON-hook-output standard
- Explain precisely the behavior that Claude Code will take in response to fields in JSON-hook-output
Here are precise specs for three of the hooks...
class UserPromptSubmitHookOutput(pydantic.BaseModel):
"""Runs when the user submits a prompt, before Claude processes it.
This allows you to add additional context based on the prompt/conversation,
validate prompts, or block certain types of prompts. Behavior:
1. Construct a JSON object from stdout if it starts with "{", or if not then like this:
- if exit_code=2 then {"continue":"true","decision":"block", "reason":STDERR}
- else {"continue":"true","additionalContext":STDOUT if exit_code=0 else undefined}
2. If decision="block" then print reason to user, and don't add anything to the transcript.
3. If continue="false" then print stopReason to the user, and don't add anything to the transcript.
4. Otherwise add user-prompt to transcript, plus additionalContext if defined."""
class PostToolUseHookOutput(pydantic.BaseModel):
"""Runs immediately after a tool completes successfully.
1. If stdout doesn't start with "{" and exit_code is not 0/2, then tool-result goes in transcript,
stderr is shown to user in yellow, and the assistant is invoked.
NOTE: this behavior can't be expressed in the PostToolUseHookOutput schema.
2. Otherwise, construct a JSON object from stdout if it starts with "{", or if not then like this:
- if exit_code=0 then {continue:"true"}
- else {continue:"true", decision:"block", reason:STDERR}
3. If continue="false" then stopReason is shown to the user in yellow, and tool-result goes
in the transcript, but the assistant is not invoked; the next user prompt will be appended to the
user message that contains that previous tool-result.
4. If continue="true" and decision="block", then reason is shown to the user in grey,
tool-result and reason both go in the transcript in the current user message,
and the assistant is invoked. Note that in the case of multiple tools with reasons,
Claude API requires that all tool-results first followed by all reasons: not interleaved.
5. If continue="true" and decision=None, then tool-result alone goes in the current user message
and the assistant is invoked."""
class PreToolUseHookOutput(pydantic.BaseModel):
"""Runs after Claude creates tool parameters and before processing the tool call.
Note: if this structure is serialized into Claude then it must be .model_dump(exclude_none=True),
because Claude requires to see an absent permissionDecision field, rather than one with value null.
1. If stdout doesn't start with "{" and exit_code is not 0/2, then tool-result goes in transcript,
stderr is shown to user in yellow, and and the assistant is allowed to run.
NOTE: this behavior can't be expressed in the PreToolUseHookOutput schema.
2. Otherwise, construct a JSON object from stdout if it starts with "{", or if not then like this:
- if exit_code=0 then {continue:"true", no permissionDecision field}
- if exit_code=2 then {continue:"true", permissionDecision:"deny", permissionDecisionReason:"[{command}]: {stderr}"}
where '{command}' is the command property for that hook in the .claude/settings.json file.
3. Before processing, do some JSON cleanup of legacy fields:
- If decision="approve" then use {permissionDecision:"allow", permissionDecisionReason:REASON} and remove the decision+reason fields
- If decision="block" then use {permissionDecision:"deny", permissionDecisionReason:REASON} and remove the decision+reason fields
4. If permissionDecision is "ask" then always ask the user for permission; if it's undefined then
ask the user or not per existing permissions.
If it is "deny" or if the ask was denied by user, then the tool is not invoked and tool_result
is "{tool} operation blocked by hook:\n- {permissionDecisionReason}" and also permissionDecisionReason is shown to the user in pink.
If it is "allow" or if the ask was approved by the user, then the tool is invoked and tool_result is normal.
5. If any tool resulted in continue=false, then its stopReason is shown to the user in yellow, and the assistant is not invoked.
But if all tools resulted in continue=true, then the assistant is invoked.
"""This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗