Use this guide when a person or another agent needs to write or modify hooks.yaml safely.
- Prefer the global hook file unless the behavior is truly project-specific.
- Give every non-trivial hook an
id. - Use
bashfor deterministic automation. - Use
tool:only when you intentionally want to send the current PI session a follow-up instruction. - Use
tool.before.*only for checks that must happen before execution. - Use
file.changedorsession.idlefor post-processing work. - Add
conditionsso hooks do not fire more broadly than intended. - Use
async: truefor slow post-processing bash hooks. - Assume project hooks are disabled until the project is trusted.
- Verify with a small manual test after editing the file.
| Goal | Preferred event |
|---|---|
| Guard a command before it runs | tool.before.<name> |
| React to any tool call | tool.before.* or tool.after.* |
| React only to file mutations | file.changed |
| Run something after the turn settles | session.idle |
| Initialize per-session state | session.created |
| Flush or clean up best-effort state | session.deleted |
| Goal | Preferred action |
|---|---|
| Run a script, write a log, call another program | bash |
| Show a user-visible message | notify |
| Ask for approval before a tool runs | confirm |
| Show lightweight ongoing state in the UI | setStatus |
| Ask the agent to do something next | tool |
hooks:
- id: lint-on-idle
event: session.idle
actions:
- bash: "npm run lint"Without an id, a later file cannot cleanly replace or disable the hook.
tool.before.* blocks the agent path. Keep these hooks short and predictable.
Good uses:
- static confirmation
- simple allow or deny policy
- fast shell checks
Bad uses:
- slow network calls
- long formatting jobs
- expensive repository scans
If the workflow depends on changed paths, file.changed is usually better than tool.after.*.
Why:
- it gives you
filesandchanges - it works across recognized mutation tools
- it keeps path-based logic in one place
Use path conditions on tool.after.<name> when you deliberately want tool-specific post-processing, such as reacting only to tool.after.write events under src/**. The condition still needs changed paths; non-mutating tools such as read, grep, find, and ls will not match path filters.
If you want one action after a burst of edits, prefer session.idle.
Typical examples:
- summarize changes
- run a formatter once after several edits
- update a status file
If you mean "all changed files are in src/ and all are TypeScript", do this:
conditions:
- matchesAllPaths: "src/**"
- matchesAllPaths: "**/*.ts"Do not put both patterns into a single matchesAllPaths entry if you need strict intersection semantics.
The clean pattern is:
- global file defines the default hook with
id - project file uses
override:ordisable: true
Example:
Global:
hooks:
- id: idle-notify
event: session.idle
actions:
- notify: "Global idle"Project:
hooks:
- override: idle-notify
event: session.idle
actions:
- notify: "Project idle"Avoid building important workflows around these assumptions:
command:actions working on PItool:calling a tool directlysession.deletedmeaning "the session was definitely closed"runIn: mainswitching the actual bash execution context- untrusted project hooks loading automatically
When a hook does not fire:
- check that PI printed a load summary
- confirm the hook file path is the one PI actually loaded
- confirm the project is trusted if using a project hook file
- confirm the event name matches a real PI tool or lifecycle event
- if using path conditions, confirm the event actually carries changed paths
- run with
PI_YAML_HOOKS_DEBUG=1
After editing hooks.yaml, agents should do a tiny proof test:
- trigger the smallest event that should match
- observe PI output or UI behavior
- if using
bash, inspect the side effect or logged file - only then claim the hook is working