hooks
Claude Code-style hooks that intercept any point in the agent lifecycle. Three hook types:
- Command: run a shell command. Event data passed via
PI_HOOK_EVENTenv var. Exit 0 = allow, exit 2 = block (stderr becomes the reason). - Prompt: single LLM call for evaluation (not yet implemented).
- Agent: spawn a subagent with tool access and a prompt
template. Use
$ARGUMENTSfor event data injection.
Hooks can be sync (blocking, can return accept/reject decisions) or async (fire-and-forget background tasks). Matchers filter which tools or events trigger each hook using regex patterns.
Subprocess safety defaults:
- command/agent hook subprocess output is bounded independently for
stdoutandstderr(1 MiB each) - output beyond the cap is truncated with an
[output truncated]marker - timeout/abort termination escalates deterministically:
SIGTERM→ grace period →SIGKILL - agent hooks resolve their runner in this order:
TALLOW_HOOK_AGENT_RUNNERoverride → current tallow executable (when detectable) →tallowon PATH → legacypifallback
Configure in hooks.json at the project root or in settings.
Events prefixed with before_ or session_before_ support
blocking (can cancel the operation). tool_call and input
also support blocking. All other events are observe-only.
All pi events
Section titled “All pi events”Every event you can hook into:
Tool lifecycle
Section titled “Tool lifecycle”| Event | Description | Matcher field | Can block? |
|---|---|---|---|
tool_call | Before a tool executes | toolName | Yes |
tool_result | After a tool returns | toolName | No |
user_bash | When user runs ! or !! commands | command | No |
Agent lifecycle
Section titled “Agent lifecycle”| Event | Description | Matcher field | Can block? |
|---|---|---|---|
before_agent_start | After user submits, before agent loop starts | No | |
agent_start | When the agent starts processing | No | |
agent_end | When the agent finishes processing | No | |
turn_start | At the start of each agent turn | No | |
turn_end | At the end of each agent turn | No | |
input | When the user submits input | Yes |
Subagent lifecycle
Section titled “Subagent lifecycle”| Event | Description | Matcher field | Can block? |
|---|---|---|---|
subagent_start | When a subagent spawns | agent_type | No |
subagent_stop | When a subagent completes | agent_type | No |
Worktree lifecycle
Section titled “Worktree lifecycle”| Event | Description | Matcher field | Can block? |
|---|---|---|---|
worktree_create | When a managed worktree is created | scope | No |
worktree_remove | When a managed worktree is removed | scope | No |
Claude-compatible aliases are also supported in translated .claude hooks:
WorktreeCreate→worktree_createWorktreeRemove→worktree_remove
Worktree payloads include worktreePath, repoRoot, scope, timestamp,
and optional agentId for subagent-scoped events.
Session lifecycle
Section titled “Session lifecycle”| Event | Description | Matcher field | Can block? |
|---|---|---|---|
session_start | When a session starts or resumes | No | |
session_shutdown | When a session is shutting down | No | |
session_before_compact | Before context compaction | Yes | |
session_compact | After context is compacted | No | |
session_before_switch | Before switching sessions | Yes | |
session_switch | After switching sessions | No | |
session_before_fork | Before a session fork | Yes | |
session_fork | After a session fork | No | |
session_before_tree | Before session tree navigation | Yes | |
session_tree | After session tree navigation | No |
| Event | Description | Matcher field | Can block? |
|---|---|---|---|
context | Before each LLM call with message context | No | |
model_select | When the model changes (set, cycle, restore) | source | No |
notification | When a notification is emitted via event bus | type | No |
teammate_idle | When a team teammate becomes idle | teammate | No |
task_completed | When a team task completes | assignee | No |
setup | Before session starts (via --init / --maintenance) | trigger | No |
Once-hooks
Section titled “Once-hooks”Add "once": true to any hook handler to run it exactly once.
The hook executes on the first matching event, then auto-disables
itself. State persists to ~/.tallow/hooks-state.json so it
survives session restarts.
Use cases: welcome messages, first-time setup, one-shot migration checks.
{ "session_start": [ { "hooks": [ { "type": "command", "command": "echo '{\"ok\": true, \"additionalContext\": \"👋 Welcome!\"}'", "once": true, "timeout": 5 } ] } ]}For sync hooks, state is recorded after successful execution
(ok: true), so a failing once-hook retries on the next event.
For async hooks, state is recorded immediately before execution
to prevent race conditions from duplicate events.
Multi-source merge
Section titled “Multi-source merge”Hooks are loaded from all sources and merged additively at session start. Matchers are concatenated per event, nothing is replaced. Every source participates:
hooks.jsonfrom local paths in~/.tallow/settings.jsonpackages(lowest priority)~/.tallow/hooks.json(global standalone)~/.tallow/settings.json→hookskey (global settings).tallow/hooks.json(project standalone, trusted projects only).tallow/settings.json→hookskey (project settings, trusted projects only)~/.tallow/extensions/*/hooks.json(global extension hooks).tallow/extensions/*/hooks.json(project extension hooks, trusted projects only).claude/settings.json→hookskey (project Claude hooks, translated)~/.claude/settings.json→hookskey (global Claude hooks, translated)- Runtime:
hooks:mergeevent bus (other extensions)
When a project is untrusted (or trust is stale), project .tallow hook
sources are blocked. Trust with /trust-project, inspect with
/trust-status, and revoke with /untrust-project.
This means any extension or package can ship a hooks.json
alongside its code and it automatically participates in the
hook system. A hook from a package, a global extension, and
your project’s hooks.json all fire on the same event if
their matchers match.
Included hook
Section titled “Included hook”tallow ships one basic starter hook: a tool_call command hook
that matches bash invocations. It’s intentionally minimal, just
enough to demonstrate the pattern.
{ "hooks": { "tool_call": [ { "matcher": "bash", "hooks": [ { "type": "command", "command": "~/.tallow/extensions/hooks/tool-call/tool-call.sh", "timeout": 5 } ] } ] }}Adding your own hooks
Section titled “Adding your own hooks”The pattern is straightforward. Here are a few examples to start from:
Auto-review after writes
Section titled “Auto-review after writes”Spawn a reviewer agent asynchronously after every write or
edit. The agent checks the change and queues its result for
the next turn.
{ "tool_result": [ { "matcher": "write|edit", "hooks": [ { "type": "agent", "agent": "reviewer", "prompt": "Review this file change for issues:\n\n$ARGUMENTS", "timeout": 60, "async": true } ] } ]}Block destructive bash commands
Section titled “Block destructive bash commands”A sync command hook that inspects the bash input before execution. The shell script exits 2 to block, with the reason on stderr.
{ "tool_call": [ { "matcher": "bash", "hooks": [ { "type": "command", "command": "~/.tallow/hooks/check-destructive.sh", "timeout": 5 } ] } ]}#!/bin/bash# PI_HOOK_EVENT contains JSON with the tool call detailsINPUT=$(echo "$PI_HOOK_EVENT" | jq -r '.input.command // empty')if echo "$INPUT" | grep -qE 'rm\s+-rf\s+/'; then echo "Blocked: refusing to rm -rf a root path" >&2 exit 2fiexit 0Completion check on agent end
Section titled “Completion check on agent end”After the agent finishes, spawn a reviewer to verify all tasks were completed.
{ "agent_end": [ { "hooks": [ { "type": "agent", "agent": "reviewer", "prompt": "Check if all tasks were completed. Return { \"ok\": true } or { \"ok\": false, \"reason\": \"...\" }.\n\n$ARGUMENTS", "async": true } ] } ]}Track subagent lifecycle
Section titled “Track subagent lifecycle”Log when subagents start and stop. The matcher filters on
agent_type (the agent name).
{ "subagent_start": [ { "hooks": [ { "type": "command", "command": "echo \"Subagent started: $(echo $PI_HOOK_EVENT | jq -r .agent_type)\" >> /tmp/subagent.log", "async": true } ] } ], "subagent_stop": [ { "hooks": [ { "type": "command", "command": "echo \"Subagent stopped: $(echo $PI_HOOK_EVENT | jq -r '.agent_type + \" exit=\" + (.exit_code|tostring)')\" >> /tmp/subagent.log", "async": true } ] } ]}Gate compaction
Section titled “Gate compaction”Block compaction unless the session has been running for a while.
{ "session_before_compact": [ { "hooks": [ { "type": "command", "command": "echo $PI_HOOK_EVENT | jq -e '.tokensBefore > 50000' > /dev/null || (echo 'Too early to compact' >&2; exit 2)", "timeout": 5 } ] } ]}Log model changes
Section titled “Log model changes”Track when the model switches, filtered to cycle events only.
{ "model_select": [ { "matcher": "cycle", "hooks": [ { "type": "command", "command": "echo \"Model cycled to: $(echo $PI_HOOK_EVENT | jq -r .modelName)\" >> /tmp/model.log", "async": true } ] } ]}