Hooks
Hooks are commands pw code runs automatically on lifecycle events: before a tool call, when you submit a prompt, when a session ends, and around thirty other moments. A hook can observe what the agent is doing, block an action, or rewrite it. Use them to audit shell commands, enforce a policy the agent cannot talk its way past, format files after every edit, or send a notification when a long run finishes.
A hook handler can be a shell command, an HTTP endpoint, an MCP tool, or an LLM prompt.
Configuring Hooks
Hooks live under a top-level hooks key in a settings file, keyed by event name. Each event holds a list of matcher groups, and each group holds a list of hook definitions:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.config/agents/hooks/bash-audit.sh", "timeout": 5 }
]
}
],
"Stop": [
{ "hooks": [{ "type": "prompt", "prompt": "Are the tests green? $ARGUMENTS" }] }
]
}
}Edits take effect on the next run, or immediately with /hooks reload.
Matchers
matcher selects which occurrences a group runs for, by testing the payload field named in the Matcher column of the event table. Matching is case-sensitive.
| Matcher | Matches |
|---|---|
Omitted, "", or "*" | Every occurrence |
A single name, such as Bash or code-reviewer | Exactly that value, not a substring: code-reviewer does not match my-code-reviewer-2 |
Names separated by | or ,, such as WriteFile|EditFile | Exactly any of those values |
Anything else, such as mcp__docs__.* | A regular expression, matched anywhere in the value |
Names may contain letters, digits, _, -, and spaces. Add ^ and $ to a regular expression to anchor it.
Where Hooks Are Configured
Hooks are read from these files, from lowest to highest priority:
| Priority | File | Layer |
|---|---|---|
| 1 (lowest) | ~/.config/agents/settings.json | User, shared with other agent tools |
| 2 | ~/.config/pw/code.json | User |
| 3 | <workspace>/.agents/settings.json | Project |
| 4 (highest) | <repo-root>/.agents/settings.local.json | Local, kept out of git |
The local file sits at the repository root (in a git worktree, the main checkout's root), not in each subdirectory you start in. .mcp.json supplies MCP servers only and is never read for hooks.
~/.config/pw/code.json is seeded on first access from ~/.config/agents/settings.json. Most settings are read only from code.json after that seed, but hooks, disableAllHooks, and allowedEnvVars are always read live from ~/.config/agents/settings.json too, so a hooks block kept there keeps working.
Hooks are additive, not overriding
Every layer's hooks for an event run. A higher-priority layer does not replace a lower one: priority only decides listing order. (This differs from mcpServers, where the highest-priority file wins outright. See MCP Servers.) A hook defined identically in more than one layer runs once. There is no per-hook opt-out: a higher layer cannot disable one hook from a lower layer. Your only levers are user-level disableAllHooks and leaving a project's hooks untrusted.
Two keys are honored from the user layer only. A project or local file that sets them is warned about and ignored, so a cloned repository cannot weaken your guards or exfiltrate secrets:
disableAllHooks: trueturns every hook off.allowedEnvVars: ["MY_TOKEN", …]names the environment variables an HTTP hook may interpolate into its headers. Referencing any other variable is a loud error, never a silent empty value.
Project Hooks Must Be Trusted
Project and local settings ship with a repository, so their hooks are arbitrary code written by whoever authored the clone. They stay inert until you trust the exact configuration:
- Run
/hooksand review the pending hooks, marked untrusted. - Run
/hooks trustto enable them for this workspace.
Trust covers the project and local hook definitions only. Any change to them, including one you did not make, such as a teammate's commit, re-gates them until you review and trust again. Edits to other settings in those files do not. This is separate from the workspace trust prompt at launch, which does not enable hooks. User-level hooks are always active and never need trusting.
Handler Types
type | Runs | Notes |
|---|---|---|
command (default) | A shell command | Payload arrives on stdin as JSON. Runs with cwd set to the workspace and PW_PROJECT_DIR / CLAUDE_PROJECT_DIR set. "async": true fires and forgets. Supplying args uses exec form, with no shell. |
http | An HTTP POST | Payload is the request body; the response body is read like a command's stdout. headers values may interpolate allowlisted environment variables as $VAR or ${VAR}. A non-2xx status is a non-blocking error. |
mcp_tool | An MCP tool | Takes server and tool; string leaves in input may interpolate payload fields as ${payload.path}. |
prompt | One LLM completion | $ARGUMENTS expands to the payload JSON. The hook replies {"ok": bool, "reason": "…"}; ok: false blocks with that reason. Optional model overrides the session model. |
agent | A small read-only agent | Can read and search the workspace, within a budget of about 30,000 tokens, then returns the same {"ok", "reason"} verdict. |
"async" is only valid on command hooks, and is ignored with a warning on PreToolUse and PermissionRequest: those hooks always run synchronously, since an async guard could never block.
All hooks matched by one event run in parallel.
Timeouts
The default timeout is 600 seconds. Shorter defaults apply to SessionEnd (30s), UserPromptSubmit (30s), and MessageDisplay (10s), and LLM-backed handlers are capped further: prompt hooks at 30s and agent hooks at 60s, or the event default if that is shorter. Set "timeout" in seconds on any hook to override. On timeout, a command hook's entire process group is killed.
Hook Input
Every payload is a JSON object with a common envelope plus event-specific fields. All field names are snake_case.
| Field | Value |
|---|---|
session_id | The session's ID |
transcript_path | Path to the session's transcript file |
cwd | The workspace directory |
hook_event_name | The event, such as PreToolUse |
permission_mode | readOnly, acceptEdits, bypassPermissions, or plan |
| Event | Event-specific fields |
|---|---|
SessionStart | source (startup, resume, clear, compact), model |
SessionEnd | reason |
UserPromptSubmit | prompt |
UserPromptExpansion | expansion_type, command_name, command_args, command_source, prompt |
Stop | stop_hook_active, last_assistant_message |
StopFailure | error, error_details, last_assistant_message |
PreToolUse | tool_name, tool_input, tool_use_id |
PostToolUse | tool_name, tool_input, tool_response (text, at most 50 KB), tool_use_id |
PostToolUseFailure | tool_name, tool_input, error, tool_use_id |
PostToolBatch | tool_calls: a list of {tool_name, tool_use_id, is_error} |
PermissionRequest | tool_name, tool_input, reason |
PermissionDenied | tool_name, tool_input, reason, denial_source (hook, mode, or user), tool_use_id |
Notification | notification_type (permission_prompt, idle_prompt, user_input_request), message |
MessageDisplay | message, delta, index, final |
SubagentStart | agent_id, agent_type, description |
SubagentStop | agent_id, agent_type, agent_transcript_path, last_assistant_message, stop_hook_active |
ConfigChange | source, key, old_value, new_value |
FileChanged | file_path, event, change_type, tool_name |
InstructionsLoaded | file_path, load_reason |
PreCompact | trigger, custom_instructions |
PostCompact | trigger, compact_summary, original_message_count, compacted_message_count |
tool_use_id is omitted when the provider gave no ID.
Hook Output
Exit Codes
| Exit code | Meaning |
|---|---|
0 | Success. If stdout starts with {, it is read as reply JSON. Otherwise, plain stdout is added to the agent's context for UserPromptSubmit, UserPromptExpansion, and SessionStart, and ignored for other events. |
2 | Block, on events marked blockable below. stderr is the reason. On other events it is a non-blocking error. |
| Anything else | Non-blocking error. It is shown to you and the action proceeds. |
Output beyond 100 KB is cut off. Reply JSON that cannot be parsed, including JSON cut off at that limit, is reported as an error rather than treated as a clean pass.
Reply JSON
| Field | Effect |
|---|---|
continue: false | Stop the whole turn. stopReason is shown to you. |
decision: "block" | Block, on blockable events. reason explains why. |
decision: "approve" | Older form of allowing a PreToolUse or PermissionRequest call. |
systemMessage | A notice shown to you. |
additionalContext | Text added to the agent's context. |
updatedInput | Replacement tool arguments (PreToolUse) or replacement prompt (UserPromptSubmit, UserPromptExpansion; a string or {"prompt": "…"}). |
hookSpecificOutput | An object with hookEventName, additionalContext, updatedInput, and for PreToolUse and PermissionRequest: permissionDecision (allow, deny, or ask), permissionDecisionReason, and decision ({behavior: "allow" | "deny", message, updatedInput}). |
permissionDecision takes precedence over the nested decision object and over the top-level decision field. permissionDecision: "defer" is treated as "ask" with a notice. suppressOutput is accepted but has no effect.
When several hooks answer the same event, deny beats ask, which beats allow; any block blocks; every additionalContext is kept; and if more than one rewrites the input, the hook that finished last wins. A rewritten tool call is re-checked by the permission engine rather than trusted as-is.
What Each Decision Does
| Event | Block or deny | Other effects |
|---|---|---|
PreToolUse | The tool call is denied, even in bypass-permissions mode. | allow skips the approval prompt, but never a command the shell checks block or a call the mode denies. ask forces an approval prompt; with no way to prompt, the call is denied. |
PermissionRequest | The call is denied without showing the dialog. | allow approves it without the dialog. Fires before the approval dialog for shell commands, MCP tools, file access outside the workspace, and changes to protected configuration. It also lets one-shot -p runs approve calls that would otherwise fail. |
PostToolUse | The reason is appended to the tool's result as feedback for the agent. | |
PostToolBatch | The turn ends. | |
UserPromptSubmit, UserPromptExpansion | The prompt is rejected. | updatedInput replaces the prompt. |
Stop, SubagentStop | The agent keeps working, with the reason as its next instruction. additionalContext alone also keeps it going. | After 8 consecutive blocks the hook is ignored and the turn ends. stop_hook_active is true while a hook is keeping the agent going. |
PreCompact | Compaction is skipped. | additionalContext is added to the summary instructions. |
Guards fail closed
For PreToolUse and PermissionRequest, a prompt or agent guard that times out or returns garbage is treated as a deny. A guard you can bypass by breaking it is not a guard. Every other event fails open, so a broken notification hook never wedges your session.
Supported Events
Every event below is valid in configuration and appears in the /hooks browser. Dormant events parse and list but never fire yet, because the feature they depend on does not exist in pw code. Configuring one warns at startup, so a guard that can never fire is never mistaken for an armed one.
| Event | When it fires | Matcher | Blockable | Status |
|---|---|---|---|---|
SessionStart | A session starts, resumes, is cleared, or is compacted | source | Active | |
SessionEnd | The session ends | reason | Active | |
Setup | pw code runs an init/maintenance mode | trigger | Dormant | |
UserPromptSubmit | You submit a prompt | Yes | Active | |
UserPromptExpansion | A custom command expands into a prompt | command_name | Yes | Active |
Stop | Right before the agent concludes its response | Yes | Active | |
StopFailure | A turn ends in an error | error | Active | |
PreToolUse | Before tool execution | tool_name | Yes | Active |
PostToolUse | After tool execution | tool_name | Yes | Active |
PostToolUseFailure | After a tool call fails | tool_name | Active | |
PostToolBatch | After a parallel batch of tool calls completes | Yes | Active | |
PermissionRequest | Before a permission dialog is shown | tool_name | Yes | Active |
PermissionDenied | A tool call is denied | tool_name | Active | |
Notification | A notification is sent | notification_type | Active | |
MessageDisplay | An assistant message is displayed | Active | ||
SubagentStart | A subagent starts | agent_type | Active | |
SubagentStop | Right before a subagent concludes its response | agent_type | Yes | Active |
TeammateIdle | An agent teammate goes idle | Dormant | ||
TaskCreated | A task is created | Dormant | ||
TaskCompleted | A task is completed | Dormant | ||
ConfigChange | Settings change mid-session | source | Active | |
CwdChanged | The working directory changes | Dormant | ||
DirectoryAdded | A directory is added to the workspace mid-session | Dormant | ||
FileChanged | pw code edits a workspace file | file_path | Active | |
InstructionsLoaded | Instruction files are loaded | file_path | Active | |
WorktreeCreate | A git worktree is created | Dormant | ||
WorktreeRemove | A git worktree is removed | Dormant | ||
PreCompact | Before conversation compaction | trigger | Yes | Active |
PostCompact | After conversation compaction | trigger | Active | |
Elicitation | An MCP server requests user input | server | Dormant | |
ElicitationResult | An MCP elicitation request resolves | server | Dormant |
Ten events are dormant, and /hooks explains why for each:
| Event | Waiting on |
|---|---|
Setup | An init/maintenance mode in pw code |
TeammateIdle | Agent teams |
TaskCreated, TaskCompleted | A task system |
CwdChanged | A movable working directory; today the workspace is fixed for the life of a run |
DirectoryAdded | Adding directories mid-session; today they are fixed at launch via --add-dir |
WorktreeCreate, WorktreeRemove | Worktree events; the worktrees pw code creates do not fire them yet |
Elicitation, ElicitationResult | Surfacing MCP elicitation requests |
Reviewing Hooks
/hooksopens an interactive browser: an arrow-navigable event list, then matcher groups, then hooks, then hook detail. It shows each hook's type, timeout, and source file, tags dormant events, marks untrusted project hooks, and lists recent hook activity. Non-interactive-pruns print a text listing instead./hooks trustenables the project hooks you just reviewed for this workspace./hooks reloadre-reads every settings file mid-session. Project hooks still need trust.
Behavior Notes and Limits
FileChangedfires only forpw code's ownWriteFileandEditFileedits. There is no filesystem watcher, so edits made by a shell command the agent ran do not trigger it.ConfigChangereports in-app changes with the sourcesmodel,permission_mode, andallowlist. Settings files are not watched; run/hooks reloadafter editing one.- The per-hook fields
if,statusMessage,once,shell, andasyncRewakeare accepted but not implemented. Each is warned about and ignored, so anif-narrowed hook runs on every matched occurrence and anasyncRewakehook never wakes the agent. - An
httphook refuses cross-origin redirects, since following one would forward your custom secret headers to a host you did not configure.
Related Documentation
- Skills: Packaged instruction sets invoked by you or the agent
- Settings: Settings files and workspace trust
- Permissions: Permission modes and allow rules
- MCP Servers: Connect MCP servers, including servers a
mcp_toolhook can call - Non-Interactive Mode: One-shot runs, where guards fail closed