SC26AI agents lab + dinner on the river · Nov 17
Parallel Works

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.

MatcherMatches
Omitted, "", or "*"Every occurrence
A single name, such as Bash or code-reviewerExactly that value, not a substring: code-reviewer does not match my-code-reviewer-2
Names separated by | or ,, such as WriteFile|EditFileExactly 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:

PriorityFileLayer
1 (lowest)~/.config/agents/settings.jsonUser, shared with other agent tools
2~/.config/pw/code.jsonUser
3<workspace>/.agents/settings.jsonProject
4 (highest)<repo-root>/.agents/settings.local.jsonLocal, 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: true turns 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:

  1. Run /hooks and review the pending hooks, marked untrusted.
  2. Run /hooks trust to 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

typeRunsNotes
command (default)A shell commandPayload 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.
httpAn HTTP POSTPayload 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_toolAn MCP toolTakes server and tool; string leaves in input may interpolate payload fields as ${payload.path}.
promptOne 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.
agentA small read-only agentCan 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.

FieldValue
session_idThe session's ID
transcript_pathPath to the session's transcript file
cwdThe workspace directory
hook_event_nameThe event, such as PreToolUse
permission_modereadOnly, acceptEdits, bypassPermissions, or plan
EventEvent-specific fields
SessionStartsource (startup, resume, clear, compact), model
SessionEndreason
UserPromptSubmitprompt
UserPromptExpansionexpansion_type, command_name, command_args, command_source, prompt
Stopstop_hook_active, last_assistant_message
StopFailureerror, error_details, last_assistant_message
PreToolUsetool_name, tool_input, tool_use_id
PostToolUsetool_name, tool_input, tool_response (text, at most 50 KB), tool_use_id
PostToolUseFailuretool_name, tool_input, error, tool_use_id
PostToolBatchtool_calls: a list of {tool_name, tool_use_id, is_error}
PermissionRequesttool_name, tool_input, reason
PermissionDeniedtool_name, tool_input, reason, denial_source (hook, mode, or user), tool_use_id
Notificationnotification_type (permission_prompt, idle_prompt, user_input_request), message
MessageDisplaymessage, delta, index, final
SubagentStartagent_id, agent_type, description
SubagentStopagent_id, agent_type, agent_transcript_path, last_assistant_message, stop_hook_active
ConfigChangesource, key, old_value, new_value
FileChangedfile_path, event, change_type, tool_name
InstructionsLoadedfile_path, load_reason
PreCompacttrigger, custom_instructions
PostCompacttrigger, compact_summary, original_message_count, compacted_message_count

tool_use_id is omitted when the provider gave no ID.

Hook Output

Exit Codes

Exit codeMeaning
0Success. 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.
2Block, on events marked blockable below. stderr is the reason. On other events it is a non-blocking error.
Anything elseNon-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

FieldEffect
continue: falseStop 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.
systemMessageA notice shown to you.
additionalContextText added to the agent's context.
updatedInputReplacement tool arguments (PreToolUse) or replacement prompt (UserPromptSubmit, UserPromptExpansion; a string or {"prompt": "…"}).
hookSpecificOutputAn 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

EventBlock or denyOther effects
PreToolUseThe 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.
PermissionRequestThe 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.
PostToolUseThe reason is appended to the tool's result as feedback for the agent.
PostToolBatchThe turn ends.
UserPromptSubmit, UserPromptExpansionThe prompt is rejected.updatedInput replaces the prompt.
Stop, SubagentStopThe 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.
PreCompactCompaction 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.

EventWhen it firesMatcherBlockableStatus
SessionStartA session starts, resumes, is cleared, or is compactedsourceActive
SessionEndThe session endsreasonActive
Setuppw code runs an init/maintenance modetriggerDormant
UserPromptSubmitYou submit a promptYesActive
UserPromptExpansionA custom command expands into a promptcommand_nameYesActive
StopRight before the agent concludes its responseYesActive
StopFailureA turn ends in an errorerrorActive
PreToolUseBefore tool executiontool_nameYesActive
PostToolUseAfter tool executiontool_nameYesActive
PostToolUseFailureAfter a tool call failstool_nameActive
PostToolBatchAfter a parallel batch of tool calls completesYesActive
PermissionRequestBefore a permission dialog is showntool_nameYesActive
PermissionDeniedA tool call is deniedtool_nameActive
NotificationA notification is sentnotification_typeActive
MessageDisplayAn assistant message is displayedActive
SubagentStartA subagent startsagent_typeActive
SubagentStopRight before a subagent concludes its responseagent_typeYesActive
TeammateIdleAn agent teammate goes idleDormant
TaskCreatedA task is createdDormant
TaskCompletedA task is completedDormant
ConfigChangeSettings change mid-sessionsourceActive
CwdChangedThe working directory changesDormant
DirectoryAddedA directory is added to the workspace mid-sessionDormant
FileChangedpw code edits a workspace filefile_pathActive
InstructionsLoadedInstruction files are loadedfile_pathActive
WorktreeCreateA git worktree is createdDormant
WorktreeRemoveA git worktree is removedDormant
PreCompactBefore conversation compactiontriggerYesActive
PostCompactAfter conversation compactiontriggerActive
ElicitationAn MCP server requests user inputserverDormant
ElicitationResultAn MCP elicitation request resolvesserverDormant

Ten events are dormant, and /hooks explains why for each:

EventWaiting on
SetupAn init/maintenance mode in pw code
TeammateIdleAgent teams
TaskCreated, TaskCompletedA task system
CwdChangedA movable working directory; today the workspace is fixed for the life of a run
DirectoryAddedAdding directories mid-session; today they are fixed at launch via --add-dir
WorktreeCreate, WorktreeRemoveWorktree events; the worktrees pw code creates do not fire them yet
Elicitation, ElicitationResultSurfacing MCP elicitation requests

Reviewing Hooks

  • /hooks opens 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 -p runs print a text listing instead.
  • /hooks trust enables the project hooks you just reviewed for this workspace.
  • /hooks reload re-reads every settings file mid-session. Project hooks still need trust.

Behavior Notes and Limits

  • FileChanged fires only for pw code's own WriteFile and EditFile edits. There is no filesystem watcher, so edits made by a shell command the agent ran do not trigger it.
  • ConfigChange reports in-app changes with the sources model, permission_mode, and allowlist. Settings files are not watched; run /hooks reload after editing one.
  • The per-hook fields if, statusMessage, once, shell, and asyncRewake are accepted but not implemented. Each is warned about and ignored, so an if-narrowed hook runs on every matched occurrence and an asyncRewake hook never wakes the agent.
  • An http hook refuses cross-origin redirects, since following one would forward your custom secret headers to a host you did not configure.
  • 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_tool hook can call
  • Non-Interactive Mode: One-shot runs, where guards fail closed