name: hooks-author
disable-model-invocation: true
description:
When to reach for a user hook (~/.pi/agent/hooks.json / <repo>/.pi/hooks.json) over an ad-hoc bash command or a
full pi extension. Use when the user asks things like "can I run X every time pi does Y", "from now on when Z happens,
do W", "log every bash command", "format files after edit", "inject context into every prompt", or to wire a Claude
Code hook into pi. Do not suggest a hook for one-shot tasks, to replace a built-in gate, or for behavior that needs
new pi events or tool registration.
Hooks Author
The hooks extension lets a user drop a shell script at a tool-call (or session) boundary without writing TypeScript.
This skill teaches WHEN a hook is the right tool, which event to pick, and how to shape the script so the runner reads
the response correctly.
Reference: config/pi/extensions/hooks.md is the full spec (schema, payload, decision
matrix, env vars). Read it once before authoring a new hook; come back here for the judgment calls.
Decision: hook vs ad-hoc command vs extension
Three options for "do X when pi does Y". Pick by how durable and how integrated the behavior needs to be.
| You want… | Use |
|---|---|
| To run a bash command once, right now | Just run the bash |
| Side-effect or context injection that should fire on every tool call / prompt / session, configured per-user or repo | Hook |
| A new tool, a new slash command, a new pi event listener, or behavior that needs typed pi state | TypeScript extension |
| A new permissions allow / deny rule | bash-permissions |
| A new filesystem read / write policy | filesystem |
Reach for a hook when all of these hold:
- The trigger is one of the five Claude-Code-compatible events:
PreToolUse,PostToolUse,UserPromptSubmit,Stop,SessionStart. - The behavior is user-trusted - the user (not the model) chose to install it.
- The work is short enough to fit in a script call and finish under the
timeout(default 60s). - The right answer is the same every time the trigger fires; you don't need new pi state or a new tool.
If any of those break, write an extension instead. Extensions are the right home for new tools, new slash commands, typed event payloads, or anything that has to compose with another extension's state.
Pick the event
| Event | Fires on | Can block? |
Use it for |
|---|---|---|---|
PreToolUse |
A tool call that's already passed the built-in gates | Yes | Audit log, last-mile deny, redact arguments before the tool runs. |
PostToolUse |
A tool result on its way back | No | Format-on-write, lint-on-edit, append context to the tool result. |
UserPromptSubmit |
A user turn about to start | Yes | Inject cwd / branch / ticket context into the system prompt. |
SessionStart |
pi session is starting | No | Warm caches, print a banner, kick off a background sync. |
Stop |
pi session is shutting down | No | Flush a log, post a summary somewhere. |
Important constraints:
PostToolUse,Stop,SessionStartare fire-and-forget for decisions - ablockhere is illegal and gets logged + treated ascontinue. Don't author a "block on post" hook; that's aPreToolUsejob.- Hooks fire after
bash-permissions,filesystem, andsandboxhave approved the call. A bash command the permissions gate already denied never reaches aPreToolUsehook - don't try to use a hook to weaken or override a built-in gate.
Match the right tool
matcher semantics (full reference in hooks.md):
| Form | Matches |
|---|---|
omitted / "" / "*" |
every tool |
"bash" |
exact name |
"edit,write" |
comma-separated list (exact each) |
"re:^edit$" |
JS regex (no flags) |
For events without a tool dimension (UserPromptSubmit, Stop, SessionStart) omit matcher entirely.
Author the script, not the config
Keep the JSONC entry thin. Logic lives in the script the entry calls - that's what gets edited, tested, and version controlled separately from the config.
A typical hook script:
#!/usr/bin/env bash
set -euo pipefail
# 1. Read the payload (single JSON object on stdin).
payload=$(cat)
# 2. Do the work. `jq` is the easiest way to pull fields.
tool=$(jq -r '.tool // empty' <<< "$payload")
cmd=$(jq -r '.input.command // empty' <<< "$payload")
# 3. Append to a log, format a file, etc.
printf '%s\t%s\t%s\n' "$(date -Iseconds)" "$tool" "$cmd" >> ~/.pi/bash.log
# 4. Empty stdout = `continue`. Exit 0.
Decision protocol cheat sheet (full matrix in hooks.md):
- Empty stdout, exit 0 →
continue. Default. The tool / prompt proceeds. - Plain-text stdout, exit 0 →
continuewith the text appended asadditionalContext. - JSON stdout with
{"decision": "continue"}→ same as above; use this shape when you also needadditionalContextorreasonset explicitly. - JSON stdout with
{"decision": "allow"}→ skip remaining hooks for this event (the next hook in the array does not run). The tool still runs. - JSON stdout with
{"decision": "block", "reason": "<text>"}→ onPreToolUse/UserPromptSubmitonly; short-circuits and surfacesreasonto the model. - Non-zero exit code →
blockwithstderrasreason. Mirrors Claude Code.
Anti-patterns inside the script:
- Don't read stdin twice.
catonce into a variable; the payload is gone after the first read. - Don't print debug logging to stdout. It will be appended to the tool result as
additionalContext. Usestderr(orPI_HOOKS_TRACE=<path>from the runner side) for diagnostics. - Don't shell out to
pifrom inside a hook. Hooks fire inside the running session; re-entering it from a subprocess will not do what you want.
Author the config
~/.pi/agent/hooks.json is JSONC - // and /* */ comments are allowed, trailing commas are not. Start from
config/pi/hooks-example.json and uncomment the event you need. Project-scoped hooks live
at <repo>/.pi/hooks.json and merge with the user file on every event (deny-wins-block - the first block from any
layer short-circuits).
Recommended timeouts:
- Audit / log hooks: 2000-5000 ms.
- Formatters / linters that shell out to another binary: 10000-30000 ms.
- Anything talking to the network: bump up explicitly; don't rely on the 60s default.
The sandboxed field is plumbed end-to-end but the wrap lands in a follow-up commit. Setting "sandboxed": true today
is reserved - leave it at the default false unless you're tracking the follow-up.
Verify the hook fires
After installing a hook, smoke-test it:
pi /hooks- the entry should appear under the correct scope (user / project / session) and event.- Run the matching trigger (
bash,edit, etc.) and confirm the side effect (log line written, file formatted, prompt context injected). - If silent, set
PI_HOOKS_DEBUG=1(notify on every fire) orPI_HOOKS_TRACE=~/.pi/agent/hooks.trace(one line per fire, works in-p/ RPC mode where notifications go nowhere) and rerun.
Anti-patterns
- Suggesting a hook for a one-shot task. "Run prettier on this file once" is a bash command, not a
PostToolUseentry. Reserve hooks for things that should fire on every matching event. - Using a hook as a permissions gate. Permissions live in
bash-permissions.json. APreToolUsehook that re-implements deny rules will drift from the real gate and confuse later readers. - Putting logic in the JSONC entry. The
commandfield is a shell invocation - keep it pointing at a script. Pipelines, conditionals, or environment branching belong in the script, not inhooks.json. - Authoring a hook that needs a new pi event. The five supported events are the whole surface. If the trigger isn't on that list, you want an extension, not a hook.
- Forgetting
set -euo pipefail. Hook scripts run unattended; a silent failure mid-script is worse than a loud exit-1block.
Quick reference
| You want to… | Event | Matcher example | Decision shape |
|---|---|---|---|
| Log every bash command | PreToolUse |
"bash" |
empty stdout (continue) |
Deny rm -rf even if permissions allowed it |
PreToolUse |
"bash" |
JSON block with reason |
| Run prettier after edit / write | PostToolUse |
"edit,write" |
empty stdout (continue) |
| Append a tool result note | PostToolUse |
"*" |
plain-text stdout |
| Inject branch / cwd into the prompt | UserPromptSubmit |
(omit) | plain-text stdout |
| Cancel a turn that mentions a forbidden word | UserPromptSubmit |
(omit) | JSON block with reason |
| Warm a cache at session start | SessionStart |
(omit) | fire-and-forget |
| Post a summary at session end | Stop |
(omit) | fire-and-forget |