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" }] }
]
}
}matcher selects which occurrences the group runs for, testing the payload field named in the Matcher column of the event table. Omit it, or use "*", to match every occurrence. Matchers are compared exactly, not as substrings, so code-reviewer does not match my-code-reviewer-2.
Edits take effect on the next run, or immediately with /hooks reload.
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, vendor-neutral |
| 2 | ~/.config/pw/code.json | User |
| 3 | <workspace>/.mcp.json | Project, the cross-tool MCP file |
| 4 | <workspace>/.pw/code/settings.json | Project, legacy |
| 5 | <workspace>/.agents/settings.json | Project |
| 6 | <workspace>/.pw/code/settings.local.json | Local, legacy |
| 7 (highest) | <workspace>/.agents/settings.local.json | Local, git-ignored |
The pw-specific user file is ~/.config/pw/code.json, seeded on first access from the vendor-neutral ~/.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 the vendor-neutral file too, so a hooks block kept there keeps working. The legacy <workspace>/.pw/code/ files are read only when the .agents/ equivalents are absent.
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 and which value wins for the scalar keys below. (This differs from mcpServers, where the highest-priority file wins outright. See MCP Servers.) 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 scalar 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 is bound to the exact bytes, so any later edit re-gates the hooks — including edits you did not make. A teammate's commit or an automated formatter touching a settings file silently re-gates them until you review and trust again. 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. Exit code 2 blocks. 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. headers values may interpolate allowlisted environment variables as $VAR or ${VAR}. |
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": "…"}. Optional model overrides the session model. |
agent | A bounded read-only mini-agent | Inspects the workspace, then returns the same {"ok", "reason"} verdict. |
Every payload carries a common envelope — session_id, transcript_path, cwd, hook_event_name, permission_mode — plus the event-specific fields. All field names are snake_case.
Blocking and Rewriting
Events marked blockable in the table below can stop the action: a command hook exits 2, or any hook returns {"decision": "block", "reason": "…"}. Hooks can also inject additionalContext, or replace the tool input or prompt through updatedInput — a rewritten tool call is re-checked by the permission engine rather than trusted as-is.
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.
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, the hook's entire process group is killed.
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 — they light up automatically when it lands. 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 new session is started | 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 permission request 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 isolation |
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.tool_responsein aPostToolUsepayload is the tool's text output, truncated to 50 KB.- 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. permissionDecision: "defer"is treated as"ask"with a notice: interactive runs prompt, and one-shot-pruns fail closed and deny the tool call.suppressOutputis accepted but has no effect.- 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