@tadasant/pi-hooks

AIR hooks for the Pi coding agent: run hooks.json/HOOK.json artifacts inside a Pi session, plus a Pi-native superset for what AIR cannot express

Packages

Package details

extension

Install @tadasant/pi-hooks from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@tadasant/pi-hooks
Package
@tadasant/pi-hooks
Version
0.2.0
Published
Sep 6, 2026
Downloads
216/mo · 24/wk
Author
tadasant
License
MIT
Types
extension
Size
105.7 KB
Dependencies
0 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./extensions/hooks.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@tadasant/pi-hooks

AIR hooks for the Pi coding agent. Run hooks.json / HOOK.json artifacts inside a real Pi session — plus a Pi-native superset for the things AIR's schema has no vocabulary for.

pi install npm:@tadasant/pi-hooks

Why this exists

Pi ships a first-class extension API: subscribe to tool_call, return { block: true }, done. That is the primitive. What Pi has no concept of is hooks — a lifecycle event bound to a command by configuration rather than by a TypeScript module you write and maintain. The word appears nowhere in Pi's docs.

AIR already defines that artifact, vendor-neutrally, and this package is its Pi runtime.

AIR hooks

An AIR hook is two layers: an entry in a hooks.json index, and a directory whose HOOK.json carries the runtime definition.

// hooks.json — the index
{
  "block-prod-deploy": {
    "title": "Block Production Deploys",
    "description": "Refuse any command that would deploy straight to production",
    "path": "hooks/block-prod-deploy"
  }
}
// hooks/block-prod-deploy/HOOK.json — the runtime definition
{
  "event": "pre_tool_call",
  "matcher": "deploy.*production",
  "command": "./guard.sh",
  "timeout_seconds": 10,
  "env": { "WEBHOOK_URL": "${SLACK_WEBHOOK_URL}" },
  "x-config": { "severity": "error" }
}

The hook runs from its own directory, so a relative ./guard.sh resolves. A non-zero exit blocks the event, with stderr as the reason handed to the model — which is what makes an AIR guardrail a guardrail.

Point Pi at a catalog with an air.json:

{ "name": "my-config", "catalogs": ["./catalog"] }

or name the index directly with { "hooks": ["./catalog/hooks.json"] }. Discovery looks for air.json, then .air/air.json; PI_HOOKS_AIR overrides it.

What a hook script receives

Channel Contents
stdin The whole event as JSON — never truncated. A guard that cannot parse it should refuse, not continue; the bundled ones do.
PI_HOOK_EVENT tool_call, tool_result, …
PI_HOOK_TOOL Tool name
PI_HOOK_INPUT Tool arguments, as JSON
PI_HOOK_CWD The project directory (the hook itself runs from its own)
AIR_HOOK_ID The hook's qualified AIR id
AIR_HOOK_CONFIG The merged x-config, with ${VAR} resolved

Oversized values are truncated in the environment variables, so a hook inspecting a large payload should read stdin.

The stdin payload speaks both dialects

AIR specifies no stdin schema for a hook: its reference adapter registers the hook with Claude Code, which supplies the payload. So a hook written once for the AIR ecosystem reads Claude Code's field names — and this package sends them alongside its own, in the same object. A portable AIR hook needs no Pi-specific branch.

Claude Code / AIR Pi-native Present on
hook_event_name (PreToolUse, PostToolUse, SessionStart, …) event every event except before_agent_start, which is Pi's alone
tool_name toolName tool_call, tool_result, user_bash
tool_input input same
tool_response (the result as text) content tool_result
prompt prompt user_prompt, before_agent_start
source reason session_start
cwd cwd every event

What a hook may print on stdout

Either dialect, as one JSON object. Anything that is not JSON with a key this layer understands is ordinary output, so echo hello remains a perfectly good hook.

A control object does not cancel a non-zero exit unless it blocked. One that only annotated leaves the question of whether to allow the event open, so the hook's own failure still answers it — a hook that errored must never read as a hook that allowed.

Claude Code / AIR Pi-native Effect
{"decision":"block","reason":…} {"block":true,"reason":…} Refuses the event where Pi allows a veto (tool_call, user_bash, user_prompt)
{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":…}} The same, spelled the way PreToolUse spells it
{"hookSpecificOutput":{"additionalContext":…}} Text for the model: appended to the tool result on tool_result, injected as context on before_agent_start
{"continue":false,"stopReason":…} {"block":true,"terminate":true,"reason":…} Refuses the event and asks Pi to end the agent loop
{"systemMessage":…} {"notify":…} Shown in the Pi UI, at warning level rather than notify's info
{"content":…} Replaces the tool result
{"patchInput":{"a.b":…}} Rewrites the tool input before the tool runs

On tool_result, content substitutes and additionalContext adds — a hook that only wants to annotate a result should use the latter, so the command's own output still reaches the model. A decision: "block" on post_tool_call cannot undo a call that already ran (it cannot on Claude Code either), so its reason is appended to the result instead of being dropped.

Two limits Pi's API imposes, both reported on stderr by hook name rather than swallowed. additionalContext has a channel only on post_tool_call and before_agent_start — notably not on user_prompt_submit, because Pi's input handler can accept or refuse a prompt but cannot add to it. And ending the agent loop works only from pre_tool_call, the one veto Pi gives an extension a terminate flag on; elsewhere continue: false still refuses the event and its stopReason still reaches the model, but the loop carries on.

Event mapping

AIR's vocabulary is agent-agnostic and broader than Pi's surface:

AIR event Pi event
session_start session_start
session_end session_shutdown
pre_tool_call tool_call (can block)
post_tool_call tool_result
user_prompt_submit user_prompt (can block)
stop agent_settled

Claude Code's PascalCase spellings (SessionStart, PreToolUse, …) are accepted as identity mappings, as AIR specifies. default_in_roots on a hook entry is accepted but not acted on — Pi has no roots concept, so every hook in a loaded index is active. (@tadasant/pi-plugins does honour it, for plugin selection.) pre_commit, post_commit, subagent_stop, notification, and pre_compact are not activated — Pi has no git-commit lifecycle, no subagent concept, and no extension-visible notification event, and this package does not expose compaction. A hook using one of those loads with a named warning rather than silently never firing.

An AIR matcher is scoped to the fields each event carries, matched case-insensitively:

Event Matched against
pre_tool_call / post_tool_call The tool name alone when the matcher names a tool (so Write does not also fire on git write-tree); otherwise tool name or input.command, so a word like deploy still matches a shell command
user_prompt_submit The prompt text
session_start / session_end The session reason (startup, resume, quit, …)
stop Nothing — the event carries no payload, so a matcher here loads with a warning

Claude's tool names (Bash, Edit, Write, …) are aliased onto Pi's.

default_in_roots is accepted but not acted on: Pi has no roots concept, so every hook in a loaded index is active. (@tadasant/pi-plugins does honour it for selecting plugins.)

Bundled AIR hooks

The package ships a small AIR catalog of guardrails. Adopt it by naming it as a catalog in your air.json:

{
  "name": "my-config",
  "catalogs": ["./node_modules/@tadasant/pi-hooks/air"]
}
Hook What it stops
block-secret-access Reading, writing, or printing .env, private keys, .netrc, .npmrc, service-account JSON — through the file tools or bash. .env.example/.env.sample are exempt. Both branches honour the same x-config, so a secretPaths/allowPaths overlay changes the whole hook.
block-dangerous-bash rm -rf of /, ~, or the working tree (any flag order, with or without a trailing / or glob); curl … | sh and bash <(curl …); sudo; git push --force/-f and pushes naming main/master/HEAD; git reset --hard, git clean -fd, git checkout .; DROP TABLE/TRUNCATE. Sees through git -C <dir>.
session-git-status Advisory — reports repository state at session start. Never blocks.

The three git/shell concerns are one hook rather than three because every AIR hook is a process spawn on Pi's hot path, and all three are scoped to the same bash tool and event. Fork the directory if you want only some of the rules.

These are ordinary AIR artifacts: read them, copy them, or fork them.

The Pi-native superset

AIR's HOOK.json can run a command and block on its exit code. Pi can do more than that, and this package exposes the extra in its own config file — a superset, not a replacement. Reach for it when you need a block reason without writing a script, or need to rewrite a tool's input:

The $schema below describes this superset only — an AIR index placed at the same path validates against AIR's hooks schema instead.

// .pi/hooks.json
{
  "$schema": "https://raw.githubusercontent.com/tadasant/pi-extensions/main/packages/pi-hooks/schema/hooks.schema.json",
  "hooks": [
    {
      "name": "no-migrations-without-review",
      "on": "tool_call",
      "match": { "tool": ["write", "edit"], "input": { "path": "db/migrate/**" } },
      "action": { "type": "block", "reason": "Migrations are written by a human." }
    },
    {
      "name": "fail-fast-bash",
      "on": "tool_call",
      "match": { "tool": "bash", "not": { "input": { "command": "/^set -/" } } },
      "action": { "type": "patch-input", "set": { "command": "set -o pipefail\n{{input.command}}" } }
    }
  ]
}

Both formats load together and are dispatched by the same runner, so a project can use either or both. The two are told apart by shape: an AIR index is a map of id -> { description, path }, the superset has a top-level hooks array. The $schema above describes the superset only — an editor will flag an AIR index against it, so leave the line off when a file holds AIR entries.

Actions: block (with a written reason, optionally terminating the agent loop), patch-input (rewrite the arguments Pi is about to execute), command (same contract as an AIR hook, plus a JSON control object on stdout), notify, and context (inject a message into the conversation on before_agent_start).

Matching: globs where * stays inside a path segment and ** crosses them, /regex/flags, ! negation, and all/any/not combinators. Within a list, positives are ORed and negatives ANDed, so ["**/.env*", "!**/.env.example"] reads the way it looks.

Run /hooks inside Pi to see everything that loaded, from both sources, and /hooks reload after editing.

Where the superset config is read from

Lowest precedence first; every file found is merged, and AIR hooks always load first:

Location Scope
$PI_CODING_AGENT_DIR/hooks.json (default ~/.pi/agent/hooks.json) All projects
.pi/hooks.json in the working directory This project

hooks.jsonc is accepted at both, and // and /* */ comments are allowed in either extension. PI_HOOKS_CONFIG (colon-separated, POSIX paths) replaces discovery.

Hook ordering and errors

AIR hooks load first, then the Pi-native config; within a file, declaration order. The first hook that blocks wins and the rest are skipped for that event. A hook that throws is logged to stderr and the event continues, unless it sets "continueOnError": false. A malformed hook is reported by name at startup and skipped; it never takes the session down.

Security

Hooks execute arbitrary commands with your permissions, exactly like the extensions they are built on. Treat a hooks.json from someone else the way you would treat a shell script from someone else — and note that a project-local .pi/hooks.json is auto-discovered, so cloning a repository and starting Pi in it is enough to adopt whatever that file says — and the same is true of an air.json naming a catalog. extends can reach any readable path or resolvable package.

The bundled AIR hooks are a guardrail against an agent making a mistake, not a sandbox against an adversary: they match command text, and anyone willing to obfuscate a command can get past them. Patterns are also compiled and run on Pi's main thread, so a catastrophically-backtracking regex in your own config will hang the agent.

License

MIT